Files
Stephen Zhouandautofix-ci[bot] 04ba877c1a feat(web): export OpenAPI mapper types and compatible contract subpaths (#44915)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-28 14:39:37 +00:00

327 lines
13 KiB
JavaScript

/* oxlint-disable vitest/no-import-node-test, antfu/no-import-dist -- Exercise command sources through the real compiler and adapters. */
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { test } from 'node:test'
import { setTimeout as delay } from 'node:timers/promises'
import { pathToFileURL } from 'node:url'
import { build, createServer } from 'vite'
import loader from '../dist/tooling/loader.js'
import { inspectSource, resolveDocument } from '../dist/tooling/source.js'
import { openapi } from '../dist/tooling/vite.js'
import { rpcProcess } from './rpc.mjs'
/* oxlint-enable vitest/no-import-node-test, antfu/no-import-dist */
const root = resolve(import.meta.dirname, '..')
const compiler = join(root, 'node_modules/typescript/bin/tsc')
function fixture(t) {
const directory = mkdtempSync(join(root, 'test/.source-'))
t.after(() => rmSync(directory, { recursive: true, force: true }))
mkdirSync(join(directory, 'unwatched'))
const file = join(directory, 'pets.openapi-source')
const schema = join(directory, 'schema.txt')
writeFileSync(schema, 'string')
writeFileSync(
join(directory, 'export.mjs'),
`
import { readFileSync, appendFileSync } from 'node:fs';
appendFileSync('unwatched/calls', '1');
console.error('Exporter diagnostic');
console.log(JSON.stringify({openapi:'3.1.0',info:{title:'Pets',version:'1'},paths:{
'/pets':{get:{'x-source-location':{file:'./backend.py',line:7},responses:{200:{description:'OK',content:{'application/json':{schema:{type:readFileSync('schema.txt','utf8')}}}}}}},
'/unused':{get:{responses:{204:{description:'OK'}}}}
},components:{schemas:{Pet:{type:readFileSync('schema.txt','utf8')}}}}));
`,
)
const content = JSON.stringify({
command: ['node', 'export.mjs'],
inputs: ['export.mjs', '*.txt'],
})
writeFileSync(file, content)
const config = {
compilerOptions: {
strict: true,
noEmit: true,
incremental: true,
skipLibCheck: true,
target: 'ES2022',
module: 'NodeNext',
},
contentMappers: [
{
package: root,
extensions: ['.openapi-source'],
options: { sources: ['./pets.openapi-source'] },
},
],
include: ['*.ts'],
}
writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify(config))
return { directory, file, content, schema, config }
}
test('command sources stay in memory, cache by backend inputs, and do not cache failed exports', (t) => {
const { directory, file, content, schema } = fixture(t)
const before = inspectSource(content, file)
const first = resolveDocument(content, file)
assert.equal(
JSON.parse(first.content).paths['/pets'].get.responses[200].content['application/json'].schema
.type,
'string',
)
assert(first.dependencies.includes(schema))
writeFileSync(join(directory, 'unrelated.log'), 'Not an exporter input')
assert.equal(inspectSource(content, file).identity, before.identity)
assert.deepEqual(resolveDocument(content, file), first)
assert.equal(readFileSync(join(directory, 'unwatched/calls'), 'utf8'), '1')
writeFileSync(schema, 'number')
assert.notEqual(inspectSource(content, file).identity, before.identity)
assert.notEqual(resolveDocument(content, file).content, first.content)
const identity = inspectSource(content, file).identity
writeFileSync(join(directory, 'new.txt'), 'new source')
assert.notEqual(inspectSource(content, file).identity, identity)
writeFileSync(join(directory, 'export.mjs'), "console.error('export failed');process.exit(1);")
assert.throws(() => resolveDocument(content, file), /export failed/)
writeFileSync(join(directory, 'export.mjs'), "console.log('not JSON');")
assert.throws(() => resolveDocument(content, file), /JSON/)
writeFileSync(join(directory, 'export.mjs'), `console.log(${JSON.stringify(first.content)});`)
assert.equal(resolveDocument(content, file).content.trim(), first.content.trim())
})
test('excluded inputs do not invalidate contracts; optional inputs track creation, edits and removal', (t) => {
const { directory, file } = fixture(t)
mkdirSync(join(directory, 'tests'))
const ignored = join(directory, 'tests/schema.txt')
writeFileSync(ignored, 'unrelated')
const content = JSON.stringify({
command: ['node', 'export.mjs'],
inputs: ['**/*.txt', 'export.mjs'],
exclude: ['tests/**'],
optionalInputs: ['.env'],
})
const first = inspectSource(content, file)
assert(!first.dependencies.includes(ignored))
writeFileSync(ignored, 'another test')
assert.equal(inspectSource(content, file).identity, first.identity)
const env = join(directory, '.env')
assert(first.dependencies.includes(env))
writeFileSync(env, 'SETTING=one')
const created = inspectSource(content, file)
assert.notEqual(created.identity, first.identity)
writeFileSync(env, 'SETTING=two')
assert.notEqual(inspectSource(content, file).identity, created.identity)
rmSync(env)
assert.equal(inspectSource(content, file).identity, first.identity)
})
test('TS incremental checks invalidate when backend input changes without touching the descriptor', (t) => {
const { directory, schema, config } = fixture(t)
writeFileSync(
join(directory, 'index.ts'),
`import { api } from './pets.openapi-source';
import type { Schemas, Inputs, Outputs } from './pets.openapi-source';
const input: Inputs['pets']['get'] = undefined;
const result: string = await api.pets.get(input);
const schema: string = null! as Schemas['Pet'];
const output: string = null! as Outputs['pets']['get'];`,
)
const compile = () =>
spawnSync(process.execPath, [compiler, '--runExternalCode', '-p', directory], {
encoding: 'utf8',
})
const first = compile()
assert.equal(first.status, 0, first.stdout + first.stderr)
writeFileSync(schema, 'number')
const changed = compile()
assert.notEqual(changed.status, 0)
assert.match(changed.stdout + changed.stderr, /number.*string/)
config.contentMappers[0].options.sources = []
writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify(config))
const untracked = compile()
assert.notEqual(untracked.status, 0)
assert.match(untracked.stdout + untracked.stderr, /options.sources/)
})
test('Node, Vite and the Next loader consume the same command source and track its inputs', async (t) => {
const { directory, file, schema } = fixture(t)
const consumer = join(directory, 'consumer.mjs')
writeFileSync(
consumer,
"import { api } from './pets.openapi-source'; export const load = api.pets.get;",
)
const node = spawnSync(
process.execPath,
[
'--import',
join(root, 'dist/tooling/register.js'),
'--input-type=module',
'-e',
`
import { load } from ${JSON.stringify(pathToFileURL(consumer).href)};
globalThis.fetch = async () => new Response(JSON.stringify('pet'), {headers:{'content-type':'application/json'}});
if (await load(undefined, {client: (await import('@dify/openapi-content-mapper')).createClient({baseUrl:'https://example.com'})}) !== 'pet') throw new Error('Wrong response');
`,
],
{ cwd: root, encoding: 'utf8' },
)
assert.equal(node.status, 0, node.stderr)
const bundled = await build({
configFile: false,
root: directory,
logLevel: 'silent',
plugins: [openapi()],
build: { write: false, lib: { entry: consumer, formats: ['es'] }, minify: false },
})
const code = (Array.isArray(bundled) ? bundled : [bundled])
.flatMap((output) => output.output)
.filter((item) => item.type === 'chunk')
.map((item) => item.code)
.join('\n')
assert(code.includes('/pets'))
assert(!code.includes('/unused'))
assert(!code.includes('export.mjs'))
const dependencies = new Set()
const run = () =>
new Promise((resolve, reject) =>
loader.call({
resourcePath: join(root, 'dist/tooling/openapi-namespace.js'),
resourceQuery: `?${new URLSearchParams({ 'openapi-document': file, 'openapi-router': '[]' })}`,
getOptions: () => ({}),
getResolve: () => async () => {
throw new Error('Unexpected resolution')
},
addDependency: (file) => dependencies.add(file),
async: () => (error, code) => (error ? reject(error) : resolve(code)),
}),
)
const before = await run()
assert(before.includes('pets'))
assert(dependencies.has(schema))
const exporter = join(directory, 'export.mjs')
writeFileSync(exporter, readFileSync(exporter, 'utf8').replace("'/pets'", "'/animals'"))
const after = await run()
assert(after.includes('animals'))
assert(!after.includes('"pets"'))
})
for (const external of [false, true]) {
test(`Vite refreshes command sources for input additions and removals (${external ? 'outside' : 'inside'} root)`, async (t) => {
const { directory, file, content } = fixture(t)
writeFileSync(file, JSON.stringify({ ...JSON.parse(content), optionalInputs: ['backend.env'] }))
const exporter = join(directory, 'export.mjs')
writeFileSync(
exporter,
readFileSync(exporter, 'utf8')
.replace('readFileSync, appendFileSync', 'readFileSync, appendFileSync, existsSync')
.replace(
"'/pets':",
"[existsSync('backend.env') ? '/configured' : existsSync('extra.txt') ? '/animals' : '/pets']:",
),
)
const frontend = external ? join(directory, 'frontend') : directory
mkdirSync(frontend, { recursive: true })
writeFileSync(
join(frontend, 'entry.ts'),
`export { api } from '${external ? '..' : '.'}/pets.openapi-source'`,
)
const server = await createServer({
configFile: false,
root: frontend,
logLevel: 'silent',
plugins: [openapi()],
server: { port: 0, fs: { allow: [directory] } },
})
t.after(() => server.close())
// New files created during the initial scan are ignored by Chokidar.
const watcherReady = new Promise((resolve) => server.watcher.once('ready', resolve))
await server.listen()
await watcherReady
await server.transformRequest('/entry.ts')
const specUrl = external ? `/@fs${file}` : '/pets.openapi-source'
await server.transformRequest(specUrl)
const url = `${specUrl}?openapi-router=%5B%5D`
assert.match((await server.transformRequest(url)).code, /pets/)
if (external) {
// External dependencies are discovered on first import, after root readiness.
// Wait until the recursively watched child has actually been scanned.
const deadline = Date.now() + 10000
while (!server.watcher.getWatched()[join(directory, 'unwatched')]?.includes('calls')) {
assert(Date.now() < deadline, 'External dependency watcher did not become ready')
await delay(10)
}
}
async function changeInput(input, mutate, expected) {
const send = server.ws.send.bind(server.ws)
let timeout
const refreshed = new Promise((resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(`No Vite reload after changing ${input}`)),
10000,
)
server.ws.send = (...args) => {
if (args[0]?.type === 'full-reload' && args[0].triggeredBy === input) resolve()
return send(...args)
}
})
try {
mutate()
await refreshed
assert.match((await server.transformRequest(url)).code, expected)
} finally {
clearTimeout(timeout)
server.ws.send = send
}
}
const extra = join(directory, 'extra.txt')
await changeInput(extra, () => writeFileSync(extra, 'new backend route'), /animals/)
await changeInput(extra, () => rmSync(extra), /pets/)
const env = join(directory, 'backend.env')
await changeInput(env, () => writeFileSync(env, 'SETTING=value'), /configured/)
await changeInput(env, () => rmSync(env), /pets/)
})
}
test('the language server refreshes command-source hover types after backend changes', async (t) => {
const { directory, schema } = fixture(t)
const source =
"import { api } from './pets.openapi-source';\nconst pet = await api.pets.get();\npet;"
writeFileSync(join(directory, 'index.ts'), source)
const rpc = rpcProcess(process.execPath, [compiler, '--lsp', '--stdio'], root)
t.after(() => rpc.close())
const uri = pathToFileURL(join(directory, 'index.ts')).href
await rpc.request('initialize', {
processId: process.pid,
rootUri: pathToFileURL(directory).href,
initializationOptions: { runExternalCode: true },
capabilities: {
general: { positionEncodings: ['utf-16'] },
textDocument: { hover: { contentFormat: ['plaintext'] } },
},
})
rpc.notify('initialized', {})
rpc.notify('textDocument/didOpen', {
textDocument: { uri, languageId: 'typescript', version: 1, text: source },
})
const hover = () =>
rpc.request('textDocument/hover', {
textDocument: { uri },
position: { line: 2, character: 1 },
})
assert.match(JSON.stringify(await hover()), /pet: string/)
const methodHover = await rpc.request('textDocument/hover', {
textDocument: { uri },
position: { line: 1, character: source.split('\n')[1].indexOf('.get') + 2 },
})
assert.match(JSON.stringify(methodHover), /Open backend implementation/)
assert.match(JSON.stringify(methodHover), /backend.py#L7/)
writeFileSync(schema, 'number')
rpc.notify('workspace/didChangeWatchedFiles', {
changes: [{ uri: pathToFileURL(schema).href, type: 2 }],
})
assert.match(JSON.stringify(await hover()), /pet: number/)
await rpc.request('shutdown')
rpc.notify('exit')
})