From 7b06e97b82ed3839906c59dd0f5a2fb6ef21ee38 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Mon, 12 Oct 2026 22:57:15 +0800 Subject: [PATCH 01/11] feat: export frozen visual suites with complete resources --- packages/vtable/__tests__/visual/README.md | 21 + packages/vtable/scripts/visual-suite.test.mjs | 246 +++++++++++ packages/vtable/scripts/visual-test.mjs | 16 +- packages/vtable/scripts/visual/runner.mjs | 10 +- packages/vtable/scripts/visual/runtime.mjs | 17 +- packages/vtable/scripts/visual/suite.mjs | 384 ++++++++++++++++++ 6 files changed, 685 insertions(+), 9 deletions(-) create mode 100644 packages/vtable/scripts/visual-suite.test.mjs create mode 100644 packages/vtable/scripts/visual/suite.mjs diff --git a/packages/vtable/__tests__/visual/README.md b/packages/vtable/__tests__/visual/README.md index 7bd590170..f8ca655e1 100644 --- a/packages/vtable/__tests__/visual/README.md +++ b/packages/vtable/__tests__/visual/README.md @@ -32,6 +32,27 @@ node packages/vtable/scripts/visual-test.mjs --baseline <完整的40位commit-sh 正常结束、失败或 Ctrl+C 后,工具会回收本轮测试子进程、浏览器、HTTP 服务和临时基线 worktree。SIGKILL/断电后,先确认进程已结束,再检查 `git worktree list` 和 `.vtable-visual/running.lock`;不要删除其他任务的 worktree。 +## 冻结套件导出 + +平台接入和归档可以复用同一个命令导出完整用例与当前构建,不需要 BugServer 登录: + +```sh +node packages/vtable/scripts/visual-test.mjs --export-suite .vtable-visual/suites/current +node packages/vtable/scripts/visual-test.mjs --export-suite .vtable-visual/suites/next --previous-suite .vtable-visual/suites/current/suite-manifest.json +``` + +导出目录必须是新目录,位于工作区外或 Git 忽略目录;已有目录不会被覆盖。此模式不能与单例、目录、基线、列举或环境预检选项混用。它使用既有构建链生成所需的 `current-.js`,冻结原格式用例、页面、Playwright 运行器和辅助文件,并保存包版本与依赖锁文件。完整清单包含实际使用的 editors、gantt、plugins、sheet 和锁定 VChart 资源;VChart 版本来自已安装包,不使用 latest 或 CDN。此模式不下载官方基线、不运行截图、不接受视觉差异。 + +`suite-manifest.json` 使用 `schemaVersion=1`、`contractVersion=1.0`、`kind=suite_manifest`。所有文件记录相对路径、字节大小和 SHA-256;`suiteDigest` 标识规范化完整清单,`filesDigest` 标识冻结文件集合,`bundleDigest` 标识实际产品资源。`caseDigest` 包含用例、相对导入、公共运行器和使用的资源,截图键固定为 `final`,对应现有单例最终截图。历史 BugServer IDs 仅记录来源,不用于覆盖 Photo 用例,也不会写回源码。命令的 JSON 输出包含清单位置及按稳定 case ID 识别的 add/update/archive;归档只表示清单差异,不删除平台数据。 + +导出时校验唯一 ID/模块、完整资源、导入路径、符号链接及构建期工作区变化;失败退出 `2`,回收本次子进程,保留日志和 export-failure.json,移除不可消费的成功清单;重试使用新目录。套件列举和导入会执行 JavaScript:平台须把导出/发现放入隔离 worker,不能在有内网或业务凭据的控制进程执行。清单中的环境要求固定 Node 22、实际 Playwright 版本和现有截图设置;完整镜像、浏览器、字体和网络隔离仍由平台环境验收负责,导出成功不表示视觉测试或隔离验收通过。 + +确定性和非法输入检查: + +```sh +node --test packages/vtable/scripts/visual-suite.test.mjs +``` + ## 用例维护 目录说明及来源 ID 规则见 [用例指南](./cases/README.md)。开发完成后运行相关目录;提交 PR 前运行默认全量比较并检查差异。新功能或缺陷修复可以加独立用例。不得用放宽截图阈值、跳过语义断言或接受差异代替修复不稳定用例。工具自检: diff --git a/packages/vtable/scripts/visual-suite.test.mjs b/packages/vtable/scripts/visual-suite.test.mjs new file mode 100644 index 000000000..3599e2c9a --- /dev/null +++ b/packages/vtable/scripts/visual-suite.test.mjs @@ -0,0 +1,246 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSuiteManifest, compareSuiteCases, canonicalJson, exportSuite } from './visual/suite.mjs'; +import { digest, freezeSuite } from './visual/runtime.mjs'; +import { parseOptions } from './visual-test.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const product = 'vtable'; +const source = { head: 'a'.repeat(40), digest: 'b'.repeat(64), dirty: true }; + +/** 只在独立忽略目录构造输入,不修改真实用例或正式基准。 */ +async function fixture( + t, + items = [ + { id: 'example', purpose: '验证依赖摘要', file: './table/example.mjs' }, + { id: 'neighbor', purpose: '验证未变用例', file: './table/neighbor.mjs' } + ] +) { + items = items.map(item => ({ + ...item, + bundles: item.id === 'neighbor' ? ['vtable'] : ['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'] + })); + const parent = path.join(root, `.${product}-visual`); + await fs.mkdir(parent, { recursive: true }); + const directory = await fs.mkdtemp(path.join(parent, 'suite-test-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.mkdir(path.join(directory, 'suite/cases/table'), { recursive: true }); + await fs.writeFile( + path.join(directory, 'suite/cases/index.mjs'), + `export const cases = ${JSON.stringify( + items + )};\nexport async function loadCase(item) { return (await import(new URL(item.file, import.meta.url))).default; }\n` + ); + await fs.writeFile(path.join(directory, 'suite/helpers.mjs'), 'export const value = 1;\n'); + for (const item of items) + if (item.file.startsWith('./table/')) + await fs.writeFile( + path.join(directory, 'suite/cases', item.file), + "import { value } from '../../helpers.mjs';\nexport default { mount() { return { value }; }, async verify() {} };\n" + ); + for (const file of ['visual.spec.mjs', 'playwright.config.mjs', 'reporter.mjs']) + await fs.writeFile(path.join(directory, 'suite', file), "import './cases/index.mjs';\nimport './settings.mjs';\n"); + await fs.writeFile( + path.join(directory, 'suite/page.html'), + '' + ); + await fs.writeFile( + path.join(directory, 'suite/settings.mjs'), + 'export const settings = { viewport: {width: 1000, height: 800}, comparison: {threshold: 0.1, maxDiffPixels: 0} };' + ); + for (const name of ['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart']) + await fs.writeFile(path.join(directory, `current-${name}.js`), `var Fixture_${name} = {};\n`); + return directory; +} + +test('same frozen bytes produce identical manifests regardless of output directory', async t => { + const a = await fixture(t), + b = await fixture(t); + const first = await createSuiteManifest(root, a, source), + second = await createSuiteManifest(root, b, source); + assert.deepEqual(first, second); + assert.equal(first.schemaVersion, 1); + assert.equal(first.contractVersion, '1.0'); + assert.equal(first.sourceCommit, source.head); + assert.match(first.suiteDigest, /^[a-f0-9]{64}$/); + assert.equal( + first.suiteDigest, + digest(canonicalJson(Object.fromEntries(Object.entries(first).filter(([key]) => key !== 'suiteDigest')))) + ); + assert.equal(new Set(first.files.map(file => file.path)).size, first.files.length); + for (const file of first.files) { + const bytes = await fs.readFile(path.join(a, file.path)); + assert.equal(file.digest, digest(bytes)); + assert.equal(file.size, bytes.length); + assert.ok(!path.isAbsolute(file.path) && !file.path.split('/').includes('..')); + } + assert.deepEqual(compareSuiteCases(first, second), { add: [], update: [], archive: [] }); +}); + +test('case, shared helper and artifact changes invalidate their recorded identities', async t => { + const directory = await fixture(t), + first = await createSuiteManifest(root, directory, source); + await fs.appendFile(path.join(directory, 'suite/cases/table/example.mjs'), '\n// 独立用例输入改变。\n'); + const one = await createSuiteManifest(root, directory, source); + assert.deepEqual(compareSuiteCases(first, one), { add: [], update: ['example'], archive: [] }); + await fs.writeFile(path.join(directory, 'suite/helpers.mjs'), 'export const value = 2;\n'); + const shared = await createSuiteManifest(root, directory, source); + assert.deepEqual(compareSuiteCases(one, shared).update, ['example', 'neighbor']); + const artifact = 'current-vtable.js'; + await fs.appendFile(path.join(directory, artifact), '// 新构建\n'); + const changed = await createSuiteManifest(root, directory, source); + assert.notEqual(changed.bundleDigest, shared.bundleDigest); + assert.notEqual(changed.suiteDigest, shared.suiteDigest); + assert.deepEqual(compareSuiteCases(shared, changed).update, ['example', 'neighbor']); +}); + +test('stable IDs distinguish additions and archives, while corrupted snapshots fail', async t => { + const a = await fixture(t), + b = await fixture(t, [ + { id: 'example', purpose: '验证依赖摘要', file: './table/example.mjs' }, + { id: 'new-case', purpose: '验证新增', file: './table/new-case.mjs' } + ]); + const first = await createSuiteManifest(root, a, source), + second = await createSuiteManifest(root, b, source); + const changes = compareSuiteCases(first, second); + assert.deepEqual(changes.add, ['new-case']); + assert.deepEqual(changes.archive, ['neighbor']); + assert.deepEqual(compareSuiteCases(null, first).add, ['example', 'neighbor']); + assert.throws(() => compareSuiteCases({ ...first, repository: 'Other/Repo' }, second), { + code: 'CASE_MANIFEST_INVALID' + }); + const corrupt = structuredClone(first); + corrupt.cases[0].purpose = '被篡改'; + assert.throws(() => compareSuiteCases(corrupt, second), { code: 'CASE_MANIFEST_INVALID' }); +}); + +test('missing resources, imports, duplicate IDs, ambiguous modules and path escapes fail', async t => { + const missing = await fixture(t); + await fs.rm(path.join(missing, 'current-vtable.js')); + await assert.rejects(createSuiteManifest(root, missing, source)); + for (const specifier of [ + './missing.mjs', + '../../../../secret.mjs', + '/etc/passwd', + '/suite/../../secret.mjs', + 'node:fs' + ]) { + const directory = await fixture(t); + await fs.appendFile( + path.join(directory, 'suite/cases/table/example.mjs'), + `\nasync function extra() { await import(${JSON.stringify(specifier)}); }\n` + ); + await assert.rejects(createSuiteManifest(root, directory, source), { code: 'CASE_MANIFEST_INVALID' }); + } + for (const items of [ + [ + { id: 'example', purpose: 'a', file: './table/example.mjs' }, + { id: 'example', purpose: 'b', file: './table/neighbor.mjs' } + ], + [ + { id: 'example', purpose: 'a', file: './table/example.mjs' }, + { id: 'neighbor', purpose: 'b', file: './table/example.mjs' } + ], + [{ id: 'example', purpose: 'a', file: '../outside.mjs' }] + ]) { + const directory = await fixture(t, items); + await assert.rejects(createSuiteManifest(root, directory, source), { code: 'CASE_MANIFEST_INVALID' }); + } + const link = await fixture(t); + await fs.symlink(path.join(link, 'suite/helpers.mjs'), path.join(link, 'suite/escaped.mjs')); + await assert.rejects(createSuiteManifest(root, link, source), { code: 'CASE_MANIFEST_INVALID' }); + const resourceLink = await fixture(t); + await fs.rm(path.join(resourceLink, 'current-vtable.js')); + await fs.symlink(path.join(resourceLink, 'suite/helpers.mjs'), path.join(resourceLink, 'current-vtable.js')); + await assert.rejects(createSuiteManifest(root, resourceLink, source), { code: 'CASE_MANIFEST_INVALID' }); +}); + +test('freeze rejects symlink roots, and export never overwrites an existing directory', async t => { + const directory = await fixture(t), + target = `${directory}-copy`, + link = `${directory}-link`; + t.after(() => Promise.all([fs.rm(target, { recursive: true, force: true }), fs.rm(link, { force: true })])); + const frozen = await freezeSuite(path.join(directory, 'suite'), target); + assert.ok(frozen.every(file => Number.isInteger(file.size))); + await fs.symlink(path.join(directory, 'suite'), link); + await assert.rejects(freezeSuite(link, `${target}-outside`), { code: 'CASE_MANIFEST_INVALID' }); + const sentinel = path.join(directory, 'keep.txt'); + await fs.writeFile(sentinel, 'keep'); + await assert.rejects(exportSuite(root, directory), { code: 'EEXIST' }); + assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep'); + await assert.rejects(fs.access(path.join(root, `.${product}-visual/running.lock`))); +}); + +test('suite export options remain separate from local case, directory and baseline modes', () => { + assert.equal(parseOptions(['--export-suite', '.vtable-visual/export'])['export-suite'], '.vtable-visual/export'); + assert.equal(parseOptions(['--export-suite', 'out', '--previous-suite', 'old.json'])['previous-suite'], 'old.json'); + for (const args of [ + ['--export-suite', ''], + ['--previous-suite', 'old.json'], + ['--export-suite', 'out', '--previous-suite', ''], + ['--export-suite', 'out', '--case', 'example'], + ['--export-suite', 'out', '--dir', 'charts'], + ['--export-suite', 'out', '--list'], + ['--export-suite', 'out', '--check'], + ['--export-suite', 'out', '--baseline', 'a'.repeat(40)], + ['--export-suite', 'out', '--self-compare'], + ['--export-suite', 'out', '--export-suite', 'other'] + ]) + assert.throws(() => parseOptions(args), { code: 'INVALID_ARGUMENT' }); + assert.equal(parseOptions(['--dir', 'table']).dir, 'table'); + assert.equal(parseOptions(['--case', 'list-basic']).case, 'list-basic'); +}); + +test('extension and locked VChart bundles carry their actual package identity', async t => { + // 依赖产物改变时不能复用此前的套件或单例身份。 + const directory = await fixture(t), + first = await createSuiteManifest(root, directory, source); + assert.deepEqual( + first.resources.map(resource => resource.resourceId), + ['editors', 'gantt', 'plugins', 'sheet', 'vchart', 'vtable'] + ); + const require = (await import('node:module')).createRequire(path.join(root, 'packages/vtable/package.json')); + assert.equal( + first.resources.find(resource => resource.resourceId === 'vchart').packageVersion, + require('@visactor/vchart/package.json').version + ); + for (const name of ['editors', 'vchart']) { + await fs.appendFile(path.join(directory, `current-${name}.js`), '// 依赖产物更新\n'); + const next = await createSuiteManifest(root, directory, source); + assert.notEqual(next.bundleDigest, first.bundleDigest); + assert.notEqual( + next.cases.find(item => item.caseId === 'example').caseDigest, + first.cases.find(item => item.caseId === 'example').caseDigest + ); + assert.equal( + next.cases.find(item => item.caseId === 'neighbor').caseDigest, + first.cases.find(item => item.caseId === 'neighbor').caseDigest + ); + } +}); + +test('browser imports use the existing /suite namespace without host path fallback', async t => { + const directory = await fixture(t); + await fs.appendFile( + path.join(directory, 'suite/cases/table/example.mjs'), + '\nasync function browserImport(page) { await page.evaluate(async () => { await import("./helpers.mjs"); await import(new URL("./helpers.mjs", location.href).href); }); return import("/suite/helpers.mjs"); }\n' + ); + const manifest = await createSuiteManifest(root, directory, source); + assert.equal(manifest.cases.length, 2); + assert.ok(manifest.files.some(file => file.path === 'suite/helpers.mjs')); +}); + +test('failed preparation retains diagnostics and removes the consumable manifest', async t => { + // 使用缺少产品目录的独立输入,失败必须可追溯且不能生成可注册清单。 + const directory = await fixture(t), + destination = path.join(directory, '.vtable-visual/export'); + await assert.rejects(exportSuite(directory, destination)); + const failure = JSON.parse(await fs.readFile(path.join(destination, 'export-failure.json'), 'utf8')); + assert.equal(failure.status, 'failed'); + assert.ok(failure.error.message); + await assert.rejects(fs.access(path.join(destination, 'suite-manifest.json'))); + await assert.rejects(fs.access(path.join(directory, '.vtable-visual/running.lock'))); +}); diff --git a/packages/vtable/scripts/visual-test.mjs b/packages/vtable/scripts/visual-test.mjs index dce81a59d..54490ba4c 100644 --- a/packages/vtable/scripts/visual-test.mjs +++ b/packages/vtable/scripts/visual-test.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { createRuntime, fault } from './visual/runtime.mjs'; import { loadCases, preflight, runVisual } from './visual/runner.mjs'; +import { exportSuite } from './visual/suite.mjs'; /** 解析公共命令;帮助不加载 Playwright,非法选项仍明确报错。 */ export function parseOptions(args) { @@ -19,6 +20,8 @@ export function parseOptions(args) { 'self-compare': { type: 'boolean' }, list: { type: 'boolean' }, check: { type: 'boolean' }, + 'export-suite': { type: 'string' }, + 'previous-suite': { type: 'string' }, help: { type: 'boolean' } } })); @@ -26,6 +29,12 @@ export function parseOptions(args) { throw fault('INVALID_ARGUMENT', error.message, error); } if (values.help) return values; + if (values['export-suite'] !== undefined) { + if (!values['export-suite'].trim() || (values['previous-suite'] !== undefined && !values['previous-suite'].trim()) || tokens.some(token => token.kind === 'option' && !['export-suite', 'previous-suite'].includes(token.name)) || ['export-suite', 'previous-suite'].some(name => tokens.filter(token => token.kind === 'option' && token.name === name).length > 1)) + throw fault('INVALID_ARGUMENT', '--export-suite 必须独立使用,仅可附带 --previous-suite'); + return values; + } + if (values['previous-suite'] !== undefined) throw fault('INVALID_ARGUMENT', '--previous-suite 需要 --export-suite'); if (tokens.filter(token => token.kind === 'option' && token.name === 'dir').length > 1) throw fault('INVALID_ARGUMENT', '--dir 只能指定一次'); if (values.dir !== undefined && !/^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/.test(values.dir)) @@ -53,10 +62,15 @@ async function main() { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); if (options.help) { console.log( - 'node packages/vtable/scripts/visual-test.mjs [--baseline <40位SHA> | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 table/cells)。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VTable develop。退出码:0 通过,1 视觉差异,2 执行错误。' + 'node packages/vtable/scripts/visual-test.mjs [--baseline <40位SHA> | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 table/cells)。\n--list 列举用例;--check 本机环境检查;--help 帮助\n--export-suite <新目录> [--previous-suite <清单JSON>] 导出冻结套件和当前构建,不截图或更新基准。\n默认:官方 VisActor/VTable develop。退出码:0 通过,1 视觉差异,2 执行错误。' ); return; } + if (options['export-suite']) { + const previous = options['previous-suite'] ? JSON.parse(await fs.readFile(path.resolve(root, options['previous-suite']), 'utf8')) : null; + console.log(JSON.stringify(await exportSuite(root, options['export-suite'], previous))); + return; + } if (options.list || options.check) { const cases = await loadCases(path.join(root, 'packages/vtable/__tests__/visual')); if (options.list) { diff --git a/packages/vtable/scripts/visual/runner.mjs b/packages/vtable/scripts/visual/runner.mjs index 41b3da65b..793f0654f 100644 --- a/packages/vtable/scripts/visual/runner.mjs +++ b/packages/vtable/scripts/visual/runner.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; -import { createRuntime, serve, fault, digest, atomicJson, fileManifest } from './runtime.mjs'; +import { createRuntime, serve, fault, digest, atomicJson, fileManifest, freezeSuite } from './runtime.mjs'; import { git, workingTree, resolveBaseline, build, baselineBuild, official } from './build.mjs'; import { saveReport } from '../../__tests__/visual/report.mjs'; @@ -17,10 +17,10 @@ export async function loadCases(suite, caseId) { if (!Array.isArray(cases) || !cases.length) throw new Error('用例清单为空'); const ids = new Set(); for (const item of cases) { - if (!/^[a-z][a-z0-9-]*$/.test(item.id) || ids.has(item.id)) throw new Error(`非法或重复 ID:${item.id}`); + if (typeof item.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.id) || ids.has(item.id)) throw new Error(`非法或重复 ID:${item.id}`); ids.add(item.id); if ( - !item.purpose || + typeof item.purpose !== 'string' || !item.purpose.trim() || (item.sourceExample !== undefined && (typeof item.sourceExample !== 'string' || !item.sourceExample.trim())) || !Array.isArray(item.bundles) || !item.bundles.length || item.bundles.some(name => !['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'].includes(name)) || new Set(item.bundles).size !== item.bundles.length || typeof item.file !== 'string' || @@ -198,9 +198,7 @@ export async function runVisual(root, options) { releaseLock = await acquireLock(output, { runId, pid: process.pid, startedAt: report.startedAt, runDir }); const suite = path.join(runDir, 'suite'); const source = path.join(root, 'packages/vtable/__tests__/visual'); - await fileManifest(source); - await fs.cp(source, suite, { recursive: true }); - report.frozenFiles = await fileManifest(suite); + report.frozenFiles = await freezeSuite(source, suite); report.suiteDigest = digest(JSON.stringify(report.frozenFiles)); const { settings } = await import(pathToFileURL(path.join(suite, 'settings.mjs'))); report.comparison = settings.comparison; diff --git a/packages/vtable/scripts/visual/runtime.mjs b/packages/vtable/scripts/visual/runtime.mjs index 600ef78e6..0feff1d9e 100644 --- a/packages/vtable/scripts/visual/runtime.mjs +++ b/packages/vtable/scripts/visual/runtime.mjs @@ -161,13 +161,26 @@ export async function serve(runDir) { /** 按相对路径递归冻结代码资源,不跟随符号链接到工作区外。 */ export async function fileManifest(directory, relative = '') { + if (!relative && !(await fs.lstat(directory)).isDirectory()) throw fault('CASE_MANIFEST_INVALID', '冻结输入必须是普通目录'); const entries = []; for (const name of (await fs.readdir(path.join(directory, relative))).sort()) { const file = path.posix.join(relative, name); const stat = await fs.lstat(path.join(directory, file)); if (stat.isSymbolicLink()) throw fault('CASE_MANIFEST_INVALID', `冻结输入不能包含符号链接:${file}`); if (stat.isDirectory()) entries.push(...(await fileManifest(directory, file))); - else if (stat.isFile()) entries.push({ path: file, digest: digest(await fs.readFile(path.join(directory, file))) }); + else if (stat.isFile()) { + const bytes = await fs.readFile(path.join(directory, file)); + entries.push({ path: file, digest: digest(bytes), size: bytes.length }); + } else throw fault('CASE_MANIFEST_INVALID', `冻结输入必须是普通文件:${file}`); } - return entries; + return relative ? entries : entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +} + +/** 冻结完整套件并核对复制内容,供本地对比和平台导出共用。 */ +export async function freezeSuite(source, destination) { + const before = await fileManifest(source); + await fs.cp(source, destination, { recursive: true, force: false, errorOnExist: true }); + const files = await fileManifest(destination); + if (JSON.stringify(before) !== JSON.stringify(files)) throw fault('WORKSPACE_CHANGED', '冻结期间套件发生变化'); + return files; } diff --git a/packages/vtable/scripts/visual/suite.mjs b/packages/vtable/scripts/visual/suite.mjs new file mode 100644 index 000000000..abd4fbc75 --- /dev/null +++ b/packages/vtable/scripts/visual/suite.mjs @@ -0,0 +1,384 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { createRuntime, digest, fault, atomicJson, fileManifest, freezeSuite } from './runtime.mjs'; +import { git, workingTree, build, bundleFiles } from './build.mjs'; +import { loadCases, acquireLock } from './runner.mjs'; + +const product = 'vtable'; +const repository = 'VisActor/VTable'; +const productKey = 'VTable'; +const resourceFiles = Object.fromEntries( + Object.entries(bundleFiles).map(([name, [folder]]) => [name, [`current-${name}.js`, `${folder}/package.json`]]) +); + +/** 规范化 JSON 对象键;有语义顺序的数组由清单构造方排序。 */ +export function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + if (![Object.prototype, null].includes(Object.getPrototypeOf(value))) + throw fault('CASE_MANIFEST_INVALID', '清单对象必须是 JSON 数据'); + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}`; + } + if ( + value === null || + ['string', 'boolean'].includes(typeof value) || + (typeof value === 'number' && Number.isFinite(value)) + ) + return JSON.stringify(value); + throw fault('CASE_MANIFEST_INVALID', '清单只能包含 JSON 数据'); +} + +/** 用已安装的 TypeScript 解析真实导入,拒绝缺文件、越界和未声明的动态依赖。 */ +async function importGraph(root, directory, files) { + const ts = createRequire(path.join(root, 'tools/bundler/package.json'))('typescript'); + const known = new Set(files.map(file => file.path)), + graph = new Map(); + for (const file of files.filter(file => /\.(mjs|html)$/.test(file.path) && file.path.startsWith('suite/'))) { + const content = await fs.readFile(path.join(directory, file.path), 'utf8'); + const scripts = file.path.endsWith('.html') + ? [...content.matchAll(/]*type=["']module["'][^>]*>([\s\S]*?)<\/script>/gi)].map(match => match[1]) + : [content]; + const dependencies = new Set(); + function add(specifier, browser) { + // Node 与 Playwright 仅由公共运行器使用,用例必须能在浏览器直接导入。 + if (specifier.startsWith('node:') || specifier === '@playwright/test') { + if (file.path.startsWith('suite/cases/')) + throw fault('CASE_MANIFEST_INVALID', `用例不能导入运行时依赖:${file.path}`); + return; + } + if ((!specifier.startsWith('.') && !specifier.startsWith('/suite/')) || /[\\?#]/.test(specifier)) + throw fault('CASE_MANIFEST_INVALID', `未声明的导入:${file.path} → ${specifier}`); + const dependency = path.posix.normalize( + specifier.startsWith('/suite/') + ? specifier.slice(1) + : path.posix.join(browser ? 'suite' : path.posix.dirname(file.path), specifier) + ); + if (!dependency.startsWith('suite/') || !known.has(dependency)) + throw fault('CASE_MANIFEST_INVALID', `缺失或越界依赖:${file.path} → ${specifier}`); + dependencies.add(dependency); + } + function visit(node, browser = false) { + // 清单的唯一动态导入由 loadCases 校验全部 item.file,其余导入须有字面路径。 + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ['evaluate', 'evaluateHandle', 'waitForFunction'].includes(node.expression.name.text) + ) { + // Playwright 序列化回调在 page.html 执行,导入相对于页面而非 Node 用例文件。 + ts.forEachChild(node, child => visit(child, browser || child === node.arguments[0])); + return; + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier) add(node.moduleSpecifier.text, browser); + } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteralLike(argument)) add(argument.text, browser); + else if ( + argument && + ts.isPropertyAccessExpression(argument) && + argument.name.text === 'href' && + ts.isNewExpression(argument.expression) && + argument.expression.expression.getText() === 'URL' && + argument.expression.arguments?.length === 2 && + ts.isStringLiteralLike(argument.expression.arguments[0]) && + argument.expression.arguments[1].getText() === (browser ? 'location.href' : 'import.meta.url') + ) + add(argument.expression.arguments[0].text, browser); + else if ( + !( + file.path === 'suite/cases/index.mjs' && + argument && + ts.isNewExpression(argument) && + argument.expression.getText() === 'URL' && + argument.arguments?.length === 2 && + ts.isPropertyAccessExpression(argument.arguments[0]) && + argument.arguments[0].name.text === 'file' && + ts.isIdentifier(argument.arguments[0].expression) && + argument.arguments[1].getText() === 'import.meta.url' + ) + ) + throw fault('CASE_MANIFEST_INVALID', `无法冻结动态导入:${file.path}`); + } else if ( + ts.isNewExpression(node) && + node.expression.getText() === 'URL' && + node.arguments?.[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + add(node.arguments[0].text, browser); + } + ts.forEachChild(node, child => visit(child, browser)); + } + for (const script of scripts) { + const source = ts.createSourceFile(file.path, script, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + if (source.parseDiagnostics.length) throw fault('CASE_MANIFEST_INVALID', `套件语法错误:${file.path}`); + visit(source); + } + graph.set(file.path, [...dependencies].sort()); + } + return graph; +} + +/** 收集入口及所有相对导入,不用未声明的本机文件补齐套件。 */ +function dependencyPaths(graph, entries) { + const found = new Set(); + function visit(entry) { + // 循环导入只记录一次,摘要仍包含循环中的全部文件。 + if (found.has(entry)) return; + found.add(entry); + for (const dependency of graph.get(entry) ?? []) visit(dependency); + } + for (const entry of entries) visit(entry); + return [...found].sort(); +} + +/** 描述已经冻结且已构建的套件;不下载资源、不更新任何标准图。 */ +export async function createSuiteManifest(root, directory, source) { + const suite = path.join(directory, 'suite'); + const suiteFiles = (await fileManifest(suite)).map(file => ({ ...file, path: `suite/${file.path}` })); + const graph = await importGraph(root, directory, suiteFiles); + const cases = await loadCases(suite); + const modules = new Set(); + for (const item of cases) { + if (modules.has(item.file)) throw fault('CASE_MANIFEST_INVALID', `多个 ID 使用同一模块:${item.file}`); + modules.add(item.file); + } + const names = ['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'].filter( + name => name === 'vtable' || cases.some(item => item.bundles.includes(name)) + ); + const inputs = path.join(directory, 'inputs'); + await fs.mkdir(inputs, { recursive: true }); + await fs.copyFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), path.join(inputs, 'pnpm-lock.yaml')); + const lockDigest = digest(await fs.readFile(path.join(inputs, 'pnpm-lock.yaml'))); + const resources = []; + for (const name of names) { + const [relative, packageFile] = + name === 'vchart' + ? [ + 'current-vchart.js', + createRequire(path.join(root, 'packages/vtable/package.json')).resolve('@visactor/vchart/package.json') + ] + : resourceFiles[name]; + if (!(await fs.lstat(path.join(directory, relative))).isFile()) + throw fault('CASE_MANIFEST_INVALID', `产物必须是普通文件:${relative}`); + const bytes = await fs.readFile(path.join(directory, relative)); + if (!bytes.length) throw fault('CASE_MANIFEST_INVALID', `产物为空:${relative}`); + const packageBytes = await fs.readFile(path.resolve(root, packageFile)); + const pkg = JSON.parse(packageBytes); + if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') + throw fault('CASE_MANIFEST_INVALID', `包身份不完整:${name}`); + await fs.writeFile(path.join(inputs, `${name}.package.json`), packageBytes); + resources.push({ + resourceId: name, + path: relative, + digest: digest(bytes), + size: bytes.length, + packageName: pkg.name, + packageVersion: pkg.version, + lockDigest + }); + } + resources.sort((a, b) => (a.resourceId < b.resourceId ? -1 : a.resourceId > b.resourceId ? 1 : 0)); + const inputFiles = (await fileManifest(inputs)).map(file => ({ ...file, path: `inputs/${file.path}` })); + const files = [ + ...suiteFiles, + ...inputFiles, + ...resources.map(({ path, digest, size }) => ({ path, digest, size })) + ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const byPath = new Map(files.map(file => [file.path, file])); + const shared = dependencyPaths(graph, [ + 'suite/visual.spec.mjs', + 'suite/playwright.config.mjs', + 'suite/reporter.mjs', + 'suite/page.html', + 'suite/settings.mjs' + ]); + for (const file of shared) if (!byPath.has(file)) throw fault('CASE_MANIFEST_INVALID', `缺少公共运行器:${file}`); + const { settings } = await import(pathToFileURL(path.join(suite, 'settings.mjs'))); + const manifest = { + schemaVersion: 1, + contractVersion: '1.0', + kind: 'suite_manifest', + repository, + productKey, + sourceCommit: source.head, + sourceDigest: digest(canonicalJson({ head: source.head, worktreeDigest: source.digest })), + engine: 'playwright', + adapterVersion: '1.0.0', + files, + cases: [], + resources, + environmentRequirements: { + nodeMajor: 22, + playwrightVersion: createRequire(path.join(root, `packages/${product}/package.json`))( + '@playwright/test/package.json' + ).version, + browserName: 'chromium', + settings + } + }; + for (const item of [...cases].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) { + const modulePath = `suite/cases/${item.file.slice(2)}`; + const text = await fs.readFile(path.join(directory, modulePath), 'utf8'); + const provenance = [...text.matchAll(/^\s*(?:\*|\/\/)\s*BugServer case IDs:\s*([^\r\n]+)/gm)].flatMap(match => + match[1].split(',').map(id => id.trim()) + ); + if (provenance.some(id => !/^[a-f0-9]{24}$/.test(id)) || new Set(provenance).size !== provenance.length) + throw fault('CASE_MANIFEST_INVALID', `BugServer 来源 ID 非法:${item.id}`); + const definition = { + caseId: item.id, + modulePath, + purpose: item.purpose, + screenshots: [{ screenshotId: 'final' }], + provenance: { bugserverCaseIds: provenance.sort(), sourceExample: item.sourceExample ?? null }, + resourceIds: names.filter(name => name === 'vtable' || item.bundles.includes(name)) + }; + const dependencies = [...new Set([...shared, ...dependencyPaths(graph, [modulePath])])] + .sort() + .map(file => byPath.get(file)); + manifest.cases.push({ + ...definition, + caseDigest: digest( + canonicalJson({ + definition, + dependencies, + resources: resources.filter(resource => definition.resourceIds.includes(resource.resourceId)) + }) + ) + }); + } + manifest.bundleDigest = digest(canonicalJson(resources)); + manifest.filesDigest = digest(canonicalJson(files)); + manifest.suiteDigest = digest(canonicalJson(manifest)); + return manifest; +} + +/** 按稳定所有权键识别新增、修改和归档,不使用历史 Photo ID 匹配。 */ +export function compareSuiteCases(previous, current) { + function checked(manifest) { + // 读取旧快照也校验摘要和命名空间,损坏数据不能生成同步计划。 + const { suiteDigest, ...content } = manifest; + if ( + manifest.kind !== 'suite_manifest' || + manifest.schemaVersion !== 1 || + manifest.contractVersion !== '1.0' || + manifest.repository !== repository || + manifest.productKey !== productKey || + !Array.isArray(manifest.cases) || + !manifest.cases.length || + digest(canonicalJson(content)) !== suiteDigest + ) + throw fault('CASE_MANIFEST_INVALID', '旧套件版本、来源或摘要不符'); + const map = new Map(); + for (const item of manifest.cases) { + if ( + typeof item.caseId !== 'string' || + !/^[a-z][a-z0-9-]*$/.test(item.caseId) || + !/^[a-f0-9]{64}$/.test(item.caseDigest) || + map.has(item.caseId) + ) + throw fault('CASE_MANIFEST_INVALID', '套件 ID 或 caseDigest 非法'); + map.set(item.caseId, item.caseDigest); + } + return map; + } + const before = previous ? checked(previous) : new Map(), + after = checked(current); + return { + add: [...after.keys()].filter(id => !before.has(id)).sort(), + update: [...after.keys()].filter(id => before.has(id) && before.get(id) !== after.get(id)).sort(), + archive: [...before.keys()].filter(id => !after.has(id)).sort() + }; +} + +/** 导出当前工作区的完整套件和新构建;仅成功目录可作为平台输入。 */ +export async function exportSuite(root, destination, previous = null) { + if (Number(process.versions.node.split('.')[0]) !== 22 || !['darwin', 'linux'].includes(process.platform)) + throw fault('PREFLIGHT_FAILED', '需要 Node.js 22 和 macOS/Linux'); + destination = path.resolve(root, destination); + if (destination === root || destination.startsWith(`${root}${path.sep}`)) { + try { + git(root, ['check-ignore', '--no-index', destination]); + } catch { + throw fault('INVALID_ARGUMENT', '导出目录须位于工作区外或 Git 忽略目录'); + } + } + const runtime = createRuntime(), + detach = runtime.listenSignals(); + let owned = false, + result, + primary; + try { + const output = path.join(root, `.${product}-visual`); + await fs.mkdir(output, { recursive: true }); + runtime.defer( + await acquireLock(output, { + runId: `suite-${randomUUID()}`, + pid: process.pid, + startedAt: new Date().toISOString(), + runDir: destination + }) + ); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.mkdir(destination); + owned = true; + const source = await workingTree(root); + const frozen = await freezeSuite( + path.join(root, `packages/${product}/__tests__/visual`), + path.join(destination, 'suite') + ); + // 导入前完成路径检查;平台必须把这一步和后续发现放在隔离 worker。 + await importGraph( + root, + destination, + (await fileManifest(path.join(destination, 'suite'))).map(file => ({ ...file, path: `suite/${file.path}` })) + ); + const cases = await loadCases(path.join(destination, 'suite')); + const names = ['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'].filter( + name => name === 'vtable' || cases.some(item => item.bundles.includes(name)) + ); + await build(root, destination, 'current', names, runtime, path.join(destination, 'build.log')); + const manifest = await createSuiteManifest(root, destination, source); + const frozenAfter = manifest.files + .filter(file => file.path.startsWith('suite/')) + .map(file => ({ ...file, path: file.path.slice(6) })); + if (canonicalJson(frozen) !== canonicalJson(frozenAfter)) + throw fault('WORKSPACE_CHANGED', '用例发现或构建修改了冻结套件'); + const after = await workingTree(root); + if (source.head !== after.head || source.digest !== after.digest) + throw fault('WORKSPACE_CHANGED', '导出期间工作区发生变化'); + const changes = compareSuiteCases(previous, manifest); + await atomicJson(path.join(destination, 'suite-manifest.json'), manifest); + result = { + directory: destination, + manifestPath: path.join(destination, 'suite-manifest.json'), + suiteDigest: manifest.suiteDigest, + bundleDigest: manifest.bundleDigest, + changes + }; + } catch (error) { + primary = error; + } + const cleanup = await runtime.cleanup(); + detach(); + if ((!result || cleanup.length) && owned) { + // 失败日志是排查依据,保留目录但移除可被消费的成功清单。 + await fs.rm(path.join(destination, 'suite-manifest.json'), { force: true }); + await atomicJson(path.join(destination, 'export-failure.json'), { + schemaVersion: 1, + status: 'failed', + error: primary + ? { code: primary.visualError ? primary.code : 'EXECUTION_FAILED', message: primary.message } + : null, + cleanup + }); + } + if (cleanup.length) throw fault('CLEANUP_FAILED', JSON.stringify({ primary: primary?.message ?? null, cleanup })); + if (primary) throw primary; + return result; +} From b0722b5f9287dab2c09bad9363b1d4ed35480fc7 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Mon, 12 Oct 2026 23:06:29 +0800 Subject: [PATCH 02/11] fix: trace JavaScript helper dependencies in frozen suites --- packages/vtable/scripts/visual-suite.test.mjs | 13 +++++++++++++ packages/vtable/scripts/visual/suite.mjs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/vtable/scripts/visual-suite.test.mjs b/packages/vtable/scripts/visual-suite.test.mjs index 3599e2c9a..c349e4bce 100644 --- a/packages/vtable/scripts/visual-suite.test.mjs +++ b/packages/vtable/scripts/visual-suite.test.mjs @@ -117,6 +117,19 @@ test('stable IDs distinguish additions and archives, while corrupted snapshots f assert.throws(() => compareSuiteCases(corrupt, second), { code: 'CASE_MANIFEST_INVALID' }); }); +test('JavaScript helpers include their transitive imports in case identities', async t => { + // 浏览器辅助文件可用 .js,嵌套依赖变化必须使受影响用例失效。 + const directory = await fixture(t); + await fs.appendFile(path.join(directory, 'suite/helpers.mjs'), "import './transitive.js';\n"); + await fs.writeFile(path.join(directory, 'suite/transitive.js'), "export { marker } from './nested.mjs';\n"); + await fs.writeFile(path.join(directory, 'suite/nested.mjs'), 'export const marker = 1;\n'); + const first = await createSuiteManifest(root, directory, source); + await fs.writeFile(path.join(directory, 'suite/nested.mjs'), 'export const marker = 2;\n'); + const second = await createSuiteManifest(root, directory, source); + assert.deepEqual(compareSuiteCases(first, second).update, ['example', 'neighbor']); +}); + + test('missing resources, imports, duplicate IDs, ambiguous modules and path escapes fail', async t => { const missing = await fixture(t); await fs.rm(path.join(missing, 'current-vtable.js')); diff --git a/packages/vtable/scripts/visual/suite.mjs b/packages/vtable/scripts/visual/suite.mjs index abd4fbc75..96790b0d3 100644 --- a/packages/vtable/scripts/visual/suite.mjs +++ b/packages/vtable/scripts/visual/suite.mjs @@ -39,7 +39,7 @@ async function importGraph(root, directory, files) { const ts = createRequire(path.join(root, 'tools/bundler/package.json'))('typescript'); const known = new Set(files.map(file => file.path)), graph = new Map(); - for (const file of files.filter(file => /\.(mjs|html)$/.test(file.path) && file.path.startsWith('suite/'))) { + for (const file of files.filter(file => /\.(m?js|html)$/.test(file.path) && file.path.startsWith('suite/'))) { const content = await fs.readFile(path.join(directory, file.path), 'utf8'); const scripts = file.path.endsWith('.html') ? [...content.matchAll(/]*type=["']module["'][^>]*>([\s\S]*?)<\/script>/gi)].map(match => match[1]) From ebb8746040d6baa2e9455f08c237da76af58f3b0 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 13 Oct 2026 12:05:21 +0800 Subject: [PATCH 03/11] feat: validate frozen visual candidates against specified baselines --- packages/vtable/__tests__/visual/README.md | 45 ++ .../vtable/__tests__/visual/cases/README.md | 6 + .../__tests__/visual/playwright.config.mjs | 2 + packages/vtable/__tests__/visual/report.mjs | 114 ++- packages/vtable/__tests__/visual/reporter.mjs | 4 + .../vtable/__tests__/visual/visual.spec.mjs | 33 +- .../vtable/scripts/visual-candidate.test.mjs | 416 +++++++++++ packages/vtable/scripts/visual-suite.test.mjs | 2 +- packages/vtable/scripts/visual-test.mjs | 38 +- packages/vtable/scripts/visual/build.mjs | 34 +- packages/vtable/scripts/visual/candidate.mjs | 666 ++++++++++++++++++ packages/vtable/scripts/visual/runner.mjs | 32 +- packages/vtable/scripts/visual/suite.mjs | 15 +- 13 files changed, 1377 insertions(+), 30 deletions(-) create mode 100644 packages/vtable/scripts/visual-candidate.test.mjs create mode 100644 packages/vtable/scripts/visual/candidate.mjs diff --git a/packages/vtable/__tests__/visual/README.md b/packages/vtable/__tests__/visual/README.md index f8ca655e1..c2f60360e 100644 --- a/packages/vtable/__tests__/visual/README.md +++ b/packages/vtable/__tests__/visual/README.md @@ -53,6 +53,51 @@ node packages/vtable/scripts/visual-test.mjs --export-suite .vtable-visual/suite node --test packages/vtable/scripts/visual-suite.test.mjs ``` +## 冻结候选与指定只读基准 + +同一用例还可以验证冻结构建并与平台提供的 PNG 集合比较,不重新构建或采集基准: + +```sh +node packages/vtable/scripts/visual-test.mjs --run-suite .vtable-visual/suites/current --run-request /path/to/run-request.json --baseline-set /path/to/baselines +# 可选 --output <新目录>;默认输出到 .vtable-visual/runs/ +``` + +三个输入必须配套,不能混用 `--case`、`--dir`、`--baseline`、`--self-compare` 或导出选项。输出须是新目录,位于工作区外或 Git 忽略目录,不能包含或覆盖输入;已有目录不会被覆盖。`suite-manifest.json` 需要 `adapterVersion=1.1.0`,增加实际提交的 `sourceParents` 与 `sourceDirty`;旧导出须重新生成。 + +`run-request.json` 使用公共接口契约 v1 的 `run_request`:固定仓库、产品、被测提交与源摘要、suite/bundle/policy 摘要、环境 ID、assetRevision、selection 和 executionKey。仅支持 `engines=["playwright"]`,Photo 的 suite/environment 为 `null`,截图键为 `final`。`full` 必须包含冻结清单全部用例;`targeted` 必须逐项列出 ID,不扩展为全量。选择摘要为规范化 items 的 SHA-256,执行键为规范化 `{identity, engines, selectionDigest, rerunSequence}` 的 SHA-256。`pr_head` 必须测试 head;`pr_merge` 必须绑定实际父提交 `[baseSha, headSha]`。本机 `local_workspace` 可包含 dirty 修改,但属于辅助证据,不能替代可信 CI 结果。 + +基准目录提供 `baseline-manifest.json`: + +```json +{ + "schemaVersion": 1, + "contractVersion": "1.0", + "kind": "baseline_manifest", + "artifactId": "selected-baselines", + "repository": "VisActor/VTable", + "productKey": "VTable", + "environmentId": "trusted-worker-environment-id", + "assetRevision": null, + "renderer": null, + "entries": [ + {"caseId": "list-basic", "screenshotId": "final", "baselineExpected": false, "path": null, "digest": null} + ] +} +``` + +此示例只声明一个首次基准项。请求 `inputArtifacts` 必须引用该 artifactId 和清单**原始文件字节**的 SHA-256。环境 ID 和 assetRevision 必须与请求一致,所有选中截图均须有唯一条目;路径使用普通相对 POSIX 文件路径,禁止符号链接、越界和内容摘要不符。既有 PNG 填写 `baselineExpected=true`、相对 `path` 与 PNG 的 SHA-256;既有基准意外丢失仍声明 `true`,不得改成首次基准。只要使用既有 PNG,`renderer` 必须精确记录其实际 `{node, platform, arch, playwright, browser, settings}`,并与运行时实测一致。平台环境 ID 的完整镜像、字体、权限、内核和网络约束仍由可信控制器验收,本入口不建立操作系统隔离。 + +基准只复制到结果目录后用于原生 `toMatchSnapshot`,不开启基准更新;运行前后校验输入和副本不变。用例继续执行原有 mount、exercise/verify、有效绘制、连续稳定截图和 release。交互后再次检查绘制,空白、资源错误或语义断言失败不能发布候选图。 + +机器结果读取 `run-result.json`(`kind=run_result`、contractVersion=1.0)。逐截图保留三项检查、七类状态 `matched` / `different` / `no_baseline` / `assertion_failed` / `execution_error` / `skipped` / `cancelled`、图像摘要与证据路径;`complete` 仅表示全部选中截图具有终态报告,不表示比较通过。首次基准项经断言和稳定性验证可输出 `role=candidate` 的 PNG,但状态仍为 `no_baseline`、rawOutcome=failed、退出 `2`;意外缺基准另带 `MISSING_BASELINE`。只有完整的原生比较证据可返回 `different` 和退出 `1`;全部匹配返回 `0`;缺图、不完整、执行或清理错误返回 `2`。标准基准的接受、写回与服务接入另由协调流程负责,本入口没有同步或接受命令。 + +`summary.json`、`agent-summary.md` 和三图 HTML 是同次结果的查看入口;其中“完整比较”仍仅表示所选截图均完成有基准的比较。不要用首次候选已验证推断比较通过,也不要把指定基准或自比较称为官方 develop 通过。 + +```sh +# 先完成构建或套件导出,随后运行真实浏览器及失败路径专项 +node --test packages/vtable/scripts/visual-candidate.test.mjs +``` + ## 用例维护 目录说明及来源 ID 规则见 [用例指南](./cases/README.md)。开发完成后运行相关目录;提交 PR 前运行默认全量比较并检查差异。新功能或缺陷修复可以加独立用例。不得用放宽截图阈值、跳过语义断言或接受差异代替修复不稳定用例。工具自检: diff --git a/packages/vtable/__tests__/visual/cases/README.md b/packages/vtable/__tests__/visual/cases/README.md index 1b2d86d10..9ca8f0530 100644 --- a/packages/vtable/__tests__/visual/cases/README.md +++ b/packages/vtable/__tests__/visual/cases/README.md @@ -19,3 +19,9 @@ 从 BugServer 迁移来源时,缺少 `interactions` 字段仅表示没有附加录制动作;仍须检查源码中的事件、定时器、异步调用、后续 API 更新及资源。保留目标配置、数据边界、动作顺序和最终状态。不得复制业务原文、内部地址、凭证、原始数据或截图到公开仓库;来源不明时先在仓库外私有目录复核。 新增或修改后先运行单例 `--self-compare --case `,再运行对应目录;提交前运行默认全量比较。若用例在官方基线不支持,报告为执行错误,应如实说明,不能把自比较当作双侧通过。 + +## 指定基准与首次候选 + +冻结套件执行仍使用同一份显式清单和原用例格式,步骤见 [工具说明](../README.md#冻结候选与指定只读基准)。新增用例保留稳定 ID、真实语义断言和交互状态验证;首次基准由输入清单明确声明 `baselineExpected=false`,不跳过绘制或稳定性检查。通过这些检查的 PNG 只进入运行结果暂存,`no_baseline` 不表示比较通过,也不自动变为正式基准。缺少既有基准、空白、缺图或断言失败必须保留错误。 + +运行器的故障注入和协议反例放在 `scripts/visual-candidate.test.mjs` 的独立副本中,不登记到产品截图清单、不提交破坏性 case。修改公共运行器后仍需执行相关测试和 PR 前官方 develop 全量比较。 diff --git a/packages/vtable/__tests__/visual/playwright.config.mjs b/packages/vtable/__tests__/visual/playwright.config.mjs index 17caa4b11..ab90ca2a5 100644 --- a/packages/vtable/__tests__/visual/playwright.config.mjs +++ b/packages/vtable/__tests__/visual/playwright.config.mjs @@ -5,6 +5,8 @@ import { settings } from './settings.mjs'; const runDir = process.env.VISUAL_RUN_DIR; const phase = process.env.VISUAL_PHASE; if (!runDir || !['baseline', 'current'].includes(phase)) throw new Error('请通过 visual-test.mjs 运行'); +if (process.env.VISUAL_EXTERNAL_BASELINE === '1' && phase !== 'current') + throw new Error('指定基准模式只运行候选侧,不允许更新基准'); export default defineConfig({ testDir: '.', diff --git a/packages/vtable/__tests__/visual/report.mjs b/packages/vtable/__tests__/visual/report.mjs index b485945b2..f404f7d7a 100644 --- a/packages/vtable/__tests__/visual/report.mjs +++ b/packages/vtable/__tests__/visual/report.mjs @@ -23,6 +23,8 @@ function selectionDescription(report) { const label = selection.mode === 'all' ? '全部本地用例' + : selection.mode === 'selection' + ? '指定集合 ' + selection.value : `${selection.mode === 'directory' ? '目录' : '单用例'} ${selection.value}`; return `${label};选中 ${selection.selectedCount} / ${selection.totalCount};其余 ${ selection.totalCount - selection.selectedCount @@ -297,9 +299,14 @@ function agentSummary(report) { /** 生成无需服务和网络的三图报告;原图链接保留完整分辨率。 */ function renderHtml(report) { - const labels = { passed: '通过', diff: '视觉差异', error: '执行错误', not_run: '未完成' }; + const labels = { passed: '通过', diff: '视觉差异', error: '执行错误', not_run: '未完成', no_baseline: '无指定基准' }; const link = (file, label) => `${escape(label)}`; - const baseline = report.baseline?.repository === 'working-tree' ? '工作区自比较' : '官方基线'; + const baseline = + report.baseline?.repository === 'frozen-images' + ? '指定冻结基准' + : report.baseline?.repository === 'working-tree' + ? '工作区自比较' + : '官方基线'; const cards = report.cases .map( item => `
@@ -390,9 +397,9 @@ function renderHtml(report) { ) .join('')}