mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
327 lines
13 KiB
JavaScript
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')
|
|
})
|