chore: migrate js-yaml to v5 (#38639)

This commit is contained in:
Stephen Zhou 2026-07-10 12:03:30 +08:00 committed by GitHub
parent c3fb5344cb
commit b7255e6f27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 351 additions and 60 deletions

View File

@ -98,7 +98,6 @@
"devDependencies": {
"@dify/tsconfig": "workspace:*",
"@hono/node-server": "catalog:",
"@types/js-yaml": "catalog:",
"@types/lockfile": "catalog:",
"@types/node": "catalog:",
"@typescript/native": "catalog:",

View File

@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import yaml from 'js-yaml'
import { dump, load } from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppInfoCache } from '@/cache/app-info'
import { ENV_CACHE_DIR } from '@/store/dir'
@ -109,11 +109,11 @@ describe('AppMetaClient', () => {
await new AppMetaClient({ apps, host: mock.url, cache: seed }).get('app-1', [FieldInfo])
// Reuse that serialized entry as a valid sibling; corrupt the app-1 slot.
const file = yaml.load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
const file = load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
const validEntry = file.entries[`${mock.url}::app-1`]
await writeFile(
path,
yaml.dump({ entries: {
dump({ entries: {
[`${mock.url}::app-1`]: 'corrupted-string',
[`${mock.url}::sibling`]: validEntry,
} }),

View File

@ -2,7 +2,7 @@ import type { AppMeta } from '@/types/app-meta'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { dump, load } from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '@/store/dir'
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
@ -112,9 +112,9 @@ describe('app-info disk cache', () => {
await seed.set('h', 'app-2', metaInfoOnly('app-2'))
// Inject a corrupt sibling alongside the real one.
const file = yaml.load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record<string, unknown> }
const file = load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record<string, unknown> }
file.entries['h::app-1'] = 'corrupted-string-not-object'
await writeFile(appInfoPath(dir), yaml.dump(file), 'utf8')
await writeFile(appInfoPath(dir), dump(file), 'utf8')
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
expect(c.get('h', 'app-1')).toBeUndefined()
@ -122,7 +122,7 @@ describe('app-info disk cache', () => {
})
it('treats a non-object entries map as empty', async () => {
await writeFile(appInfoPath(dir), yaml.dump({ entries: 'not-an-object' }), 'utf8')
await writeFile(appInfoPath(dir), dump({ entries: 'not-an-object' }), 'utf8')
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
@ -137,7 +137,7 @@ describe('app-info disk cache', () => {
}
await c.set('h', 'app-1', slim)
const raw = await readFile(appInfoPath(dir), 'utf8')
const parsed = yaml.load(raw) as { entries: Record<string, unknown> }
const parsed = load(raw) as { entries: Record<string, unknown> }
expect(Object.keys(parsed.entries)).toHaveLength(1)
})
})

View File

@ -1,7 +1,7 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import yaml from 'js-yaml'
import { load } from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '@/store/dir'
import { CACHE_NUDGE, cachePath, getCache } from '@/store/manager'
@ -70,7 +70,7 @@ describe('NudgeStore', () => {
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = yaml.load(raw) as Record<string, unknown>
const parsed = load(raw) as Record<string, unknown>
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
})

View File

@ -2,7 +2,7 @@ import type { CommandConstructor, CommandEffect } from './command'
import type { CommandTree } from './registry'
import type { ArgValueType, FlagDefinition } from './types'
import type { HelpTopic } from '@/help/topics'
import yaml from 'js-yaml'
import { dump } from 'js-yaml'
import { CONTRACT, GLOBAL_FLAG_HELP } from '@/help/contract'
import { TOPICS } from '@/help/topics'
import { collectCommands } from './registry'
@ -41,7 +41,7 @@ function isStructured(format: string): boolean {
function serialize(value: unknown, format: string): string {
if (format === 'yaml')
return yaml.dump(value, { indent: 2, lineWidth: -1 })
return dump(value, { indent: 2, lineWidth: -1 })
return `${JSON.stringify(value, null, 2)}\n`
}

View File

@ -1,4 +1,4 @@
import yaml from 'js-yaml'
import { dump } from 'js-yaml'
import { OutputFormatNotSupportedError } from './errors'
export type RawOutput = {
@ -91,7 +91,7 @@ function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>):
case OutputFormat.JSON:
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case OutputFormat.YAML:
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
return dump(output.data.json(), { indent: 2, lineWidth: -1 })
case OutputFormat.NAME:
return `${toName(output.data)}\n`
default:
@ -107,7 +107,7 @@ function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
case OutputFormat.JSON:
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case OutputFormat.YAML:
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
return dump(output.data.json(), { indent: 2, lineWidth: -1 })
case OutputFormat.NAME:
return `${toName(output.data)}\n`
default:

View File

@ -16,7 +16,7 @@ export class ConcurrentAccessError extends BaseError {
type YamlMark = {
line: number
column: number
snippet?: string
snippet?: string | null
}
type YamlParseError = {

View File

@ -12,6 +12,18 @@ describe('YamlStore.doGet', () => {
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
})
it('returns default when content is empty', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('')
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
})
it('returns default when content contains only comments', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('# empty configuration\n')
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
})
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
@ -24,6 +36,51 @@ describe('YamlStore.doGet', () => {
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
})
it('resolves YAML merge keys', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('defaults: &defaults\n format: json\nselected:\n <<: *defaults\n')
expect(store.doGet({ key: 'selected.format', default: '' })).toBe('json')
})
it('parses timestamp scalars as dates', () => {
const store = new YamlStore('/irrelevant')
const timestamp = '2026-07-10T03:04:05.000Z'
store.setRawContent(`created_at: ${timestamp}\n`)
expect(store.doGet<Date | null>({ key: 'created_at', default: null })).toEqual(new Date(timestamp))
})
it('rejects multiple YAML documents', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n---\nname: bob\n')
expect(() => store.doGet({ key: 'name', default: '' }))
.toThrowError(/single document/)
})
it('rejects duplicate mapping keys', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\nname: bob\n')
expect(() => store.doGet({ key: 'name', default: '' }))
.toThrowError(/duplicated mapping key/)
})
it('rejects complex mapping keys', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('? [name, region]\n: deployment\n')
expect(() => store.doGet({ key: 'name', default: '' }))
.toThrowError(/does not support complex keys/)
})
it('parses explicit YAML sets as JavaScript sets', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('features: !!set\n workflow:\n chat:\n')
expect(store.doGet<Set<string>>({ key: 'features', default: new Set() }))
.toEqual(new Set(['workflow', 'chat']))
})
it('returns default for a missing flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')

View File

@ -3,7 +3,18 @@ import type { Platform } from '@/sys'
import { promises as fsp } from 'node:fs'
import { dirname } from 'node:path'
import { AsyncEntry } from '@napi-rs/keyring'
import yaml from 'js-yaml'
import {
binaryTag,
CORE_SCHEMA,
dump,
loadAll,
mergeTag,
omapTag,
pairsTag,
setTag,
timestampTag,
YAMLException,
} from 'js-yaml'
import lockfile from 'lockfile'
import { pid, resolvePlatform } from '@/sys'
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
@ -11,6 +22,7 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors'
const FILE_PERM = 0o600
const DIR_PERM = 0o700
const LOCK_STALE_MS = 30_000
const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag)
function lockAsync(path: string, opts: LockOptions): Promise<void> {
return new Promise((resolve, reject) => {
@ -202,7 +214,7 @@ export class YamlStore extends FileBasedStore {
async setTyped<T>(data: T): Promise<void> {
await this.withLock(async () => {
await this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
await this.flush()
})
}
@ -220,7 +232,7 @@ export class YamlStore extends FileBasedStore {
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
}
doUnset<T>(key: Key<T>): void {
@ -239,7 +251,7 @@ export class YamlStore extends FileBasedStore {
if (!(lastKey in current))
return
delete current[lastKey]
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
}
}
@ -247,10 +259,13 @@ function loadYaml(raw: string | undefined, file_path: string): Record<string, un
if (raw === undefined)
return null
try {
return (yaml.load(raw) ?? {}) as Record<string, unknown>
const documents = loadAll(raw, { schema: YAML_LOAD_SCHEMA })
if (documents.length > 1)
throw new YAMLException('expected a single document in the stream, but found more')
return (documents[0] ?? {}) as Record<string, unknown>
}
catch (err) {
if (err instanceof yaml.YAMLException)
if (err instanceof YAMLException)
throw new BadYamlFormatError(file_path, raw, err)
throw err
}

View File

@ -14,7 +14,7 @@ import { execSync, spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import yaml from 'js-yaml'
import { dump } from 'js-yaml'
/** Path to the dev entry point — no build required. */
export const BIN = resolve(__dirname, '../../../bin/dev.js')
@ -214,7 +214,7 @@ async function writeFileToken(configDir: string, host: string, email: string, be
version: 1,
tokens: { [host]: { [email]: bearer } },
}
await writeFile(join(configDir, 'tokens.yml'), yaml.dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 })
await writeFile(join(configDir, 'tokens.yml'), dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 })
}
/**

View File

@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@hey-api/openapi-ts'
import yaml from 'js-yaml'
import { loadOpenApiYaml } from './openapi-yaml'
type JsonObject = Record<string, unknown>
@ -267,7 +267,7 @@ const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => {
}
const normalizeEnterpriseOpenApi = () => {
const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
const openApi = loadOpenApiYaml(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi))
throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`)

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { loadOpenApiYaml } from './openapi-yaml'
describe('loadOpenApiYaml', () => {
it('preserves merge keys and timestamps used by v4 documents', () => {
const document = loadOpenApiYaml(`
defaults: &defaults
version: 2026-07-10T03:04:05Z
info:
<<: *defaults
`)
expect(document).toEqual({
defaults: { version: new Date('2026-07-10T03:04:05Z') },
info: { version: new Date('2026-07-10T03:04:05Z') },
})
})
it('rejects duplicate mapping keys', () => {
expect(() => loadOpenApiYaml('openapi: 3.0.0\nopenapi: 3.1.0\n'))
.toThrow(/duplicated mapping key/)
})
it('rejects multiple YAML documents', () => {
expect(() => loadOpenApiYaml('openapi: 3.0.0\n---\nopenapi: 3.1.0\n'))
.toThrow(/single document/)
})
it('uses v5 mapping and set semantics', () => {
expect(() => loadOpenApiYaml('? [name, region]\n: deployment\n'))
.toThrow(/does not support complex keys/)
expect(loadOpenApiYaml('features: !!set\n workflow:\n chat:\n'))
.toEqual({ features: new Set(['workflow', 'chat']) })
})
})

View File

@ -0,0 +1,23 @@
import {
binaryTag,
CORE_SCHEMA,
load,
mergeTag,
omapTag,
pairsTag,
setTag,
timestampTag,
} from 'js-yaml'
const OPENAPI_YAML_SCHEMA = CORE_SCHEMA.withTags(
binaryTag,
mergeTag,
omapTag,
pairsTag,
setTag,
timestampTag,
)
export function loadOpenApiYaml(input: string) {
return load(input, { schema: OPENAPI_YAML_SCHEMA })
}

View File

@ -20,6 +20,7 @@
"scripts": {
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"test": "vp test openapi-yaml.test.ts",
"type-check": "tsc"
},
"dependencies": {
@ -29,12 +30,12 @@
"devDependencies": {
"@dify/tsconfig": "workspace:*",
"@hey-api/openapi-ts": "catalog:",
"@types/js-yaml": "catalog:",
"@types/node": "catalog:",
"@typescript/native": "catalog:",
"eslint": "catalog:",
"js-yaml": "catalog:",
"typescript": "catalog:",
"vite-plus": "catalog:"
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}

41
pnpm-lock.yaml generated
View File

@ -213,9 +213,6 @@ catalogs:
'@types/js-cookie':
specifier: 3.0.6
version: 3.0.6
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
'@types/lockfile':
specifier: 1.0.4
version: 1.0.4
@ -418,8 +415,8 @@ catalogs:
specifier: 3.0.8
version: 3.0.8
js-yaml:
specifier: 4.3.0
version: 4.3.0
specifier: 5.2.1
version: 5.2.1
jsonschema:
specifier: 1.5.0
version: 1.5.0
@ -706,7 +703,7 @@ importers:
version: 3.1.0
js-yaml:
specifier: 'catalog:'
version: 4.3.0
version: 5.2.1
lockfile:
specifier: 'catalog:'
version: 1.0.4
@ -735,9 +732,6 @@ importers:
'@hono/node-server':
specifier: 'catalog:'
version: 2.0.8(hono@4.12.28)
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
'@types/lockfile':
specifier: 'catalog:'
version: 1.0.4
@ -823,9 +817,6 @@ importers:
'@hey-api/openapi-ts':
specifier: 'catalog:'
version: 0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3)
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
'@types/node':
specifier: 'catalog:'
version: 25.9.5
@ -837,13 +828,16 @@ importers:
version: 10.6.0(jiti@2.7.0)
js-yaml:
specifier: 'catalog:'
version: 4.3.0
version: 5.2.1
typescript:
specifier: 'catalog:'
version: '@typescript/typescript6@6.0.2'
vite-plus:
specifier: 'catalog:'
version: 0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(happy-dom@20.10.6)
packages/dev-proxy:
dependencies:
@ -1280,7 +1274,7 @@ importers:
version: 3.0.8
js-yaml:
specifier: 'catalog:'
version: 4.3.0
version: 5.2.1
jsonschema:
specifier: 'catalog:'
version: 1.5.0
@ -1519,9 +1513,6 @@ importers:
'@types/js-cookie':
specifier: 'catalog:'
version: 3.0.6
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
'@types/negotiator':
specifier: 'catalog:'
version: 0.6.4
@ -4928,9 +4919,6 @@ packages:
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -7503,6 +7491,10 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
js-yaml@5.2.1:
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
hasBin: true
jsdoc-type-pratt-parser@7.1.1:
resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==}
engines: {node: '>=20.0.0'}
@ -13323,8 +13315,6 @@ snapshots:
'@types/js-cookie@3.0.6': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.8': {}
@ -16289,6 +16279,10 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@5.2.1:
dependencies:
argparse: 2.0.1
jsdoc-type-pratt-parser@7.1.1: {}
jsdoc-type-pratt-parser@7.2.0: {}
@ -19703,7 +19697,6 @@ time:
'@tsslint/compat-eslint@3.1.4': '2026-06-16T18:21:23.786Z'
'@tsslint/config@3.1.4': '2026-06-16T18:21:26.545Z'
'@types/js-cookie@3.0.6': '2023-11-07T08:41:16.889Z'
'@types/js-yaml@4.0.9': '2023-11-07T20:20:13.264Z'
'@types/lockfile@1.0.4': '2023-11-07T20:23:21.070Z'
'@types/negotiator@0.6.4': '2025-06-07T02:18:17.532Z'
'@types/node@25.9.5': '2026-07-08T06:47:58.834Z'
@ -19772,7 +19765,7 @@ time:
jotai@2.20.1: '2026-06-11T06:30:45.782Z'
js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z'
js-cookie@3.0.8: '2026-05-29T10:51:39.065Z'
js-yaml@4.3.0: '2026-06-26T22:29:00.874Z'
js-yaml@5.2.1: '2026-07-02T00:55:21.714Z'
jsonschema@1.5.0: '2025-01-07T15:09:11.287Z'
katex@0.17.0: '2026-05-22T08:06:26.967Z'
knip@6.25.0: '2026-07-07T08:47:13.416Z'

View File

@ -120,7 +120,6 @@ catalog:
'@tsslint/compat-eslint': 3.1.4
'@tsslint/config': 3.1.4
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/lockfile': 1.0.4
'@types/negotiator': 0.6.4
'@types/node': 25.9.5
@ -188,7 +187,7 @@ catalog:
jotai-tanstack-query: 0.11.0
js-audio-recorder: 1.0.7
js-cookie: 3.0.8
js-yaml: 4.3.0
js-yaml: 5.2.1
jsonschema: 1.5.0
katex: 0.17.0
knip: 6.25.0

View File

@ -22,6 +22,20 @@ workflow:
expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false)
})
it('should reject disallowed node types inherited through YAML merge keys', () => {
const content = `
trigger: &trigger
type: trigger-webhook
workflow:
graph:
nodes:
- data:
<<: *trigger
`
expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false)
})
it('should reject malformed yaml and answer nodes in non-advanced mode', () => {
expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false)
expect(validateDSLContent(`
@ -43,6 +57,18 @@ workflow:
`, AppModeEnum.ADVANCED_CHAT)).toBe(true)
})
it('should accept empty yaml content', () => {
expect(validateDSLContent('', AppModeEnum.CHAT)).toBe(true)
})
it('should accept comment-only yaml content', () => {
expect(validateDSLContent('# No document', AppModeEnum.CHAT)).toBe(true)
})
it('should reject yaml streams with multiple documents', () => {
expect(validateDSLContent('---\n{}\n---\n{}', AppModeEnum.CHAT)).toBe(false)
})
it('should expose the invalid node sets per mode', () => {
expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual(
expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]),

View File

@ -1,8 +1,8 @@
import type { CommonNodeType, Node } from './types'
import { load as yamlLoad } from 'js-yaml'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import { loadYaml } from '@/utils/yaml'
import { BlockEnum, SupportUploadFileTypes } from './types'
type ParsedDSL = {
@ -58,7 +58,7 @@ export const getInvalidNodeTypes = (mode?: AppModeEnum) => {
export const validateDSLContent = (content: string, mode?: AppModeEnum) => {
try {
const data = yamlLoad(content) as ParsedDSL
const data = loadYaml(content) as ParsedDSL | undefined
const nodes = data?.workflow?.graph?.nodes ?? []
const invalidNodes = getInvalidNodeTypes(mode)
return !nodes.some((node: Node<CommonNodeType>) => invalidNodes.includes(node?.data?.type))

View File

@ -0,0 +1,105 @@
import { dslAppName, dslEnvVarSlots, encodeDslContent, isWorkflowDsl } from '../dsl'
describe('deployment DSL domain', () => {
describe('content encoding', () => {
it('should preserve Unicode content through base64 encoding', () => {
const content = 'app:\n name: 部署 🚀'
const decoded = new TextDecoder().decode(Uint8Array.from(atob(encodeDslContent(content)), character => character.charCodeAt(0)))
expect(decoded).toBe(content)
})
})
describe('app metadata', () => {
it('should trim the app name', () => {
expect(dslAppName('app:\n name: " Deployment app "')).toBe('Deployment app')
})
it('should return an empty app name for malformed content', () => {
expect(dslAppName('app: [')).toBe('')
})
it('should identify workflow apps', () => {
expect(isWorkflowDsl('app:\n mode: workflow')).toBe(true)
})
it('should reject non-workflow apps', () => {
expect(isWorkflowDsl('app:\n mode: advanced-chat')).toBe(false)
})
})
describe('environment variable slots', () => {
it('should return no slots for malformed content', () => {
expect(dslEnvVarSlots('workflow: [')).toEqual([])
})
it('should keep the first slot when names are duplicated', () => {
const content = `
workflow:
environment_variables:
- name: REGION
value: first
- name: " REGION "
value: second
`
expect(dslEnvVarSlots(content)).toEqual([{
key: 'REGION',
defaultValue: 'first',
hasDefaultValue: true,
}])
})
it('should omit masked secret defaults', () => {
const content = `
workflow:
environment_variables:
- name: API_KEY
value: '[__HIDDEN__]'
value_type: secret
`
expect(dslEnvVarSlots(content)).toEqual([{
key: 'API_KEY',
valueType: 'secret',
}])
})
it('should normalize unquoted timestamp defaults', () => {
const content = `
workflow:
environment_variables:
- name: START_AT
value: 2026-07-10T12:34:56Z
`
expect(dslEnvVarSlots(content)).toEqual([{
key: 'START_AT',
defaultValue: '"2026-07-10T12:34:56.000Z"',
hasDefaultValue: true,
}])
})
it('should apply values inherited through YAML merge keys', () => {
const content = `
defaults: &defaults
description: Deployment region
value: ap-southeast-1
value_type: string
workflow:
environment_variables:
- <<: *defaults
name: REGION
`
expect(dslEnvVarSlots(content)).toEqual([{
key: 'REGION',
description: 'Deployment region',
defaultValue: 'ap-southeast-1',
hasDefaultValue: true,
valueType: 'string',
}])
})
})
})

View File

@ -1,5 +1,5 @@
import { load as yamlLoad } from 'js-yaml'
import { AppModeEnum } from '@/types/app'
import { loadYaml } from '@/utils/yaml'
export type DslEnvVarSlot = {
key: string
@ -38,7 +38,7 @@ export function encodeDslContent(value: string) {
function parseDsl(content: string) {
try {
return yamlLoad(content) as DslMetadata | undefined
return loadYaml(content) as DslMetadata | undefined
}
catch {
return undefined

View File

@ -195,7 +195,6 @@
"@tsslint/compat-eslint": "catalog:",
"@tsslint/config": "catalog:",
"@types/js-cookie": "catalog:",
"@types/js-yaml": "catalog:",
"@types/negotiator": "catalog:",
"@types/node": "catalog:",
"@types/qs": "catalog:",

18
web/utils/yaml.spec.ts Normal file
View File

@ -0,0 +1,18 @@
import { loadYaml } from './yaml'
describe('loadYaml', () => {
it('should reject duplicate mapping keys', () => {
expect(() => loadYaml('name: first\nname: second\n'))
.toThrow(/duplicated mapping key/)
})
it('should reject complex mapping keys', () => {
expect(() => loadYaml('? [name, region]\n: deployment\n'))
.toThrow(/does not support complex keys/)
})
it('should parse explicit YAML sets as JavaScript sets', () => {
expect(loadYaml('features: !!set\n workflow:\n chat:\n'))
.toEqual({ features: new Set(['workflow', 'chat']) })
})
})

21
web/utils/yaml.ts Normal file
View File

@ -0,0 +1,21 @@
import {
binaryTag,
CORE_SCHEMA,
loadAll,
mergeTag,
omapTag,
pairsTag,
setTag,
timestampTag,
YAMLException,
} from 'js-yaml'
const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag)
export function loadYaml(input: string) {
const documents = loadAll(input, { schema: YAML_LOAD_SCHEMA })
if (documents.length > 1)
throw new YAMLException('expected a single document in the stream, but found more')
return documents[0]
}