From a2953ba953a792cf30724dab0b89d2ee0d60bd41 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 20 Oct 2026 18:33:03 +0800 Subject: [PATCH 1/5] feat: support offline visual workflow verification --- packages/vtable/__tests__/visual/README.md | 2 ++ packages/vtable/scripts/visual-test.mjs | 4 ++- packages/vtable/scripts/visual-test.test.mjs | 29 ++++++++++++++++++++ packages/vtable/scripts/visual/build.mjs | 24 +++++++++++++++- packages/vtable/scripts/visual/runner.mjs | 2 +- packages/vtable/scripts/visual/runtime.mjs | 3 +- 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/vtable/__tests__/visual/README.md b/packages/vtable/__tests__/visual/README.md index 7bd590170..c94203a4a 100644 --- a/packages/vtable/__tests__/visual/README.md +++ b/packages/vtable/__tests__/visual/README.md @@ -28,6 +28,8 @@ node packages/vtable/scripts/visual-test.mjs --baseline <完整的40位commit-sh 按用例需要构建核心 `vtable`,以及 `editors`、`gantt`、`plugins`、`sheet` 的本地 UMD 包;透视图还加载当前依赖锁定的 `vchart` 浏览器包。目录仅影响用例与所需包的选择;不会自动推断源码影响范围。 +自动化隔离环境可使用 `--baseline <完整 SHA> --offline`。宿主须提前从官方仓库准备该提交、两侧相同锁文件对应的依赖及浏览器;执行期间不 fetch 或安装依赖。两侧仍分别构建、执行原断言和截图比较,锁文件或 Rush 配置不同时明确报错,需要分别准备依赖后再运行。该模式不等于 `--self-compare`。 + 结果写入 Git 忽略的 `.vtable-visual/runs//`:`index.html` 可离线查看基线、当前和差异三图,`agent-summary.md` 便于快速定位失败,`summary.json` 保留结构化证据。退出码 `0` 表示所选用例均通过,`1` 表示视觉差异,`2` 表示执行、构建、资源或清理错误。报告可以整体复制,单独复制 HTML 会丢失相对图片。 正常结束、失败或 Ctrl+C 后,工具会回收本轮测试子进程、浏览器、HTTP 服务和临时基线 worktree。SIGKILL/断电后,先确认进程已结束,再检查 `git worktree list` 和 `.vtable-visual/running.lock`;不要删除其他任务的 worktree。 diff --git a/packages/vtable/scripts/visual-test.mjs b/packages/vtable/scripts/visual-test.mjs index dce81a59d..115833e5d 100644 --- a/packages/vtable/scripts/visual-test.mjs +++ b/packages/vtable/scripts/visual-test.mjs @@ -14,6 +14,7 @@ export function parseOptions(args) { tokens: true, options: { baseline: { type: 'string' }, + offline: { type: 'boolean' }, case: { type: 'string' }, dir: { type: 'string' }, 'self-compare': { type: 'boolean' }, @@ -43,6 +44,7 @@ export function parseOptions(args) { if (values.baseline !== undefined && !/^[a-f0-9]{40}$/i.test(values.baseline)) throw fault('INVALID_ARGUMENT', '--baseline 需要完整 40 位 SHA'); if (values.baseline && values['self-compare']) throw fault('INVALID_ARGUMENT', '--baseline 与 --self-compare 互斥'); + if (values.offline && !values.baseline) throw fault('INVALID_ARGUMENT', '--offline 需要已准备的 --baseline 完整 SHA'); if (values.case !== undefined && !/^[a-z][a-z0-9-]*$/.test(values.case)) throw fault('INVALID_ARGUMENT', '--case 需要有效用例 ID'); return values; @@ -53,7 +55,7 @@ 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> [--offline] | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 table/cells)。\n--offline 使用宿主已准备的提交与同锁依赖,不访问网络。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VTable develop。退出码:0 通过,1 视觉差异,2 执行错误。' ); return; } diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs index 5d81e29be..3a96db450 100644 --- a/packages/vtable/scripts/visual-test.test.mjs +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -4,6 +4,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseOptions } from './visual-test.mjs'; import { loadCases, selectCases } from './visual/runner.mjs'; +import { resolveBaseline, offlineDependencies } from './visual/build.mjs'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; /** 用最小契约检查拦住无效范围、重复 ID 和缺失来源模块。 */ test('视觉用例清单与范围选择', async () => { @@ -17,3 +21,28 @@ test('视觉用例清单与范围选择', async () => { assert.throws(() => parseOptions(['--dir', 'pivot', '--case', 'list-basic']), /互斥/); assert.throws(() => selectCases(cases, { dir: 'missing' }), /没有匹配用例/); }); + +/** 离线入口不得回退联网;不同锁文件不能借用已有依赖获得通过。 */ +test('离线基线使用固定提交和同锁依赖', async () => { + assert.throws(() => parseOptions(['--offline']), /需要/); + assert.equal(parseOptions(['--offline', '--baseline', 'a'.repeat(40)]).offline, true); + const root = await fs.mkdtemp(path.join(tmpdir(), 'vtable-offline-')); + const worktree = await fs.mkdtemp(path.join(tmpdir(), 'vtable-offline-base-')); + const git = args => execFileSync('git', args, { cwd: root, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@example.invalid', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@example.invalid' } }).trim(); + try { + await fs.mkdir(path.join(root, 'common/config/rush'), { recursive: true }); + await fs.mkdir(path.join(root, 'common/temp'), { recursive: true }); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'lockfileVersion: 5.4\n'); + await fs.writeFile(path.join(root, 'rush.json'), '{}\n'); + await fs.writeFile(path.join(root, 'package.json'), '{}\n'); + git(['init', '--quiet']); git(['add', '.']); git(['-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'fixture']); + const sha = git(['rev-parse', 'HEAD']); + const runtime = { offline: true, run: () => assert.fail('离线模式不能启动 fetch') }; + assert.equal(await resolveBaseline(root, sha, runtime, ''), sha); + await assert.rejects(() => resolveBaseline(root, undefined, runtime, ''), /基线/); + await offlineDependencies(root, worktree, sha); + assert.equal(await fs.realpath(path.join(worktree, 'common/temp')), await fs.realpath(path.join(root, 'common/temp'))); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'changed\n'); + await assert.rejects(() => offlineDependencies(root, worktree, sha), /依赖配置不同/); + } finally { await fs.rm(root, { recursive: true, force: true }); await fs.rm(worktree, { recursive: true, force: true }); } +}); diff --git a/packages/vtable/scripts/visual/build.mjs b/packages/vtable/scripts/visual/build.mjs index 9919c7c2f..a1a6a45d7 100644 --- a/packages/vtable/scripts/visual/build.mjs +++ b/packages/vtable/scripts/visual/build.mjs @@ -34,11 +34,33 @@ export async function workingTree(root) { } /** 每次从官方仓库解析本轮 develop 或指定提交,不使用 fork 的 origin。 */ export async function resolveBaseline(root, sha, runtime, log) { + if (runtime.offline) { + // 自动化宿主已从官方仓库准备固定提交;隔离执行期间不再访问网络。 + if (!/^[a-f0-9]{40}$/i.test(sha ?? '') || git(root, ['rev-parse', `${sha}^{commit}`]) !== sha.toLowerCase()) + throw fault('BASELINE_FETCH_FAILED', '离线基线提交缺失或不符'); + return sha.toLowerCase(); + } await runtime.run('git', ['fetch', '--no-tags', official, sha || 'refs/heads/develop'], root, log, { code: 'BASELINE_FETCH_FAILED' }); const resolved = git(root, ['rev-parse', 'FETCH_HEAD^{commit}']); if (sha && resolved !== sha.toLowerCase()) throw fault('BASELINE_FETCH_FAILED', '基线 SHA 与请求不一致'); return resolved; } +/** 同锁文件的离线基线复用只读依赖,构建产物仍在本次独立工作区生成。 */ +export async function offlineDependencies(root, worktree, sha) { + for (const file of ['common/config/rush/pnpm-lock.yaml', 'rush.json']) + if (git(root, ['show', `${sha}:${file}`]) !== (await fs.readFile(path.join(root, file), 'utf8')).trim()) + throw fault('DEPENDENCIES_CHANGED', '两侧依赖配置不同,需要分别准备后再运行'); + await fs.mkdir(path.join(worktree, 'common'), { recursive: true }); + await fs.symlink(path.join(root, 'common/temp'), path.join(worktree, 'common/temp'), 'dir'); + const packages = git(root, ['ls-files', '--', '**/package.json']).split('\n').filter(Boolean); + for (const file of ['package.json', ...packages]) { + const relative = path.join(path.dirname(file), 'node_modules'); + if (await fs.stat(path.join(root, relative)).catch(error => error.code === 'ENOENT' ? null : Promise.reject(error))) { + await fs.mkdir(path.dirname(path.join(worktree, relative)), { recursive: true }); + await fs.symlink(path.join(root, relative), path.join(worktree, relative), 'dir'); + } + } +} /** 构建所选包及其工作区依赖,并仅复制本轮产物到隔离运行目录。 */ export async function build(root, runDir, phase, names, runtime, log) { const rushx = path.join(root, 'common/scripts/install-run-rushx.js'); @@ -131,7 +153,7 @@ export async function baselineBuild(root, runDir, sha, names, runtime, report, t execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: root, stdio: 'pipe', timeout: 60000 }); }); await runtime.run('git', ['worktree', 'add', '--detach', worktree, sha], root, log, { code: 'BUILD_FAILED' }); - await timed('baselineInstallMs', () => runtime.run(process.execPath, ['common/scripts/install-run-rush.js', 'install', '--ignore-hooks'], worktree, log, { code: 'BUILD_FAILED', env: { NODE_ENV: 'development' } })); + await timed('baselineInstallMs', () => runtime.offline ? offlineDependencies(root, worktree, sha) : runtime.run(process.execPath, ['common/scripts/install-run-rush.js', 'install', '--ignore-hooks'], worktree, log, { code: 'BUILD_FAILED', env: { NODE_ENV: 'development' } })); await timed('baselineCompileMs', () => build(worktree, runDir, 'baseline', names, runtime, log)); await publishCache(cache, key, new Map(await Promise.all(names.map(async name => [name, await fs.readFile(path.join(runDir, `baseline-${name}.js`))])))); } diff --git a/packages/vtable/scripts/visual/runner.mjs b/packages/vtable/scripts/visual/runner.mjs index de3613b10..cb03dc746 100644 --- a/packages/vtable/scripts/visual/runner.mjs +++ b/packages/vtable/scripts/visual/runner.mjs @@ -158,7 +158,7 @@ export async function acquireLock(output, owner) { } /** 编排一次完整对比;所有准备失败和清理失败尽可能进入统一报告。 */ export async function runVisual(root, options) { - const runtime = createRuntime(); + const runtime = createRuntime({ offline: options.offline === true }); const detach = runtime.listenSignals(); const output = path.join(root, '.vtable-visual'); const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`; diff --git a/packages/vtable/scripts/visual/runtime.mjs b/packages/vtable/scripts/visual/runtime.mjs index 600ef78e6..bb22ad87a 100644 --- a/packages/vtable/scripts/visual/runtime.mjs +++ b/packages/vtable/scripts/visual/runtime.mjs @@ -36,7 +36,7 @@ export async function cleanupAll(actions) { } /** 管理单次运行的进程组;只有入口显式调用 listenSignals 才注册处理器。 */ -export function createRuntime() { +export function createRuntime({ offline = false } = {}) { const children = new Set(); const cleanups = []; let interrupted = false; @@ -60,6 +60,7 @@ export function createRuntime() { killTimer.unref(); } return { + offline, get interrupted() { return interrupted; }, From 36c07406c52c2997e0c44ef135dc082a16ca46f7 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 20 Oct 2026 19:24:19 +0800 Subject: [PATCH 2/5] fix: isolate offline baseline workspace dependencies --- packages/vtable/scripts/visual-test.test.mjs | 24 +++++++++++++++++++- packages/vtable/scripts/visual/build.mjs | 13 +++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs index 3a96db450..30f82c928 100644 --- a/packages/vtable/scripts/visual-test.test.mjs +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseOptions } from './visual-test.mjs'; import { loadCases, selectCases } from './visual/runner.mjs'; -import { resolveBaseline, offlineDependencies } from './visual/build.mjs'; +import { resolveBaseline, offlineDependencies, baselineBuild } from './visual/build.mjs'; import fs from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -35,14 +35,36 @@ test('离线基线使用固定提交和同锁依赖', async () => { await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'lockfileVersion: 5.4\n'); await fs.writeFile(path.join(root, 'rush.json'), '{}\n'); await fs.writeFile(path.join(root, 'package.json'), '{}\n'); + await fs.writeFile(path.join(root, '.gitignore'), 'node_modules/\n.vtable-visual/\n'); + for (const name of ['vtable', 'vtable-gantt']) { + await fs.mkdir(path.join(root, 'packages', name), { recursive: true }); + await fs.writeFile(path.join(root, 'packages', name, 'package.json'), '{}\n'); + } + await fs.writeFile(path.join(root, 'packages/vtable/version.txt'), 'baseline'); + await fs.mkdir(path.join(root, 'packages/vtable-gantt/node_modules/@visactor'), { recursive: true }); + await fs.symlink('../../../vtable', path.join(root, 'packages/vtable-gantt/node_modules/@visactor/vtable'), 'dir'); git(['init', '--quiet']); git(['add', '.']); git(['-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'fixture']); const sha = git(['rev-parse', 'HEAD']); + await fs.writeFile(path.join(root, 'packages/vtable/version.txt'), 'current'); + await fs.mkdir(path.join(worktree, 'packages/vtable'), { recursive: true }); + await fs.writeFile(path.join(worktree, 'packages/vtable/version.txt'), 'baseline'); const runtime = { offline: true, run: () => assert.fail('离线模式不能启动 fetch') }; assert.equal(await resolveBaseline(root, sha, runtime, ''), sha); await assert.rejects(() => resolveBaseline(root, undefined, runtime, ''), /基线/); await offlineDependencies(root, worktree, sha); assert.equal(await fs.realpath(path.join(worktree, 'common/temp')), await fs.realpath(path.join(root, 'common/temp'))); + assert.equal(await fs.readFile(path.join(worktree, 'packages/vtable-gantt/node_modules/@visactor/vtable/version.txt'), 'utf8'), 'baseline'); + // 空产物缓存只验证命中顺序,不启动构建或浏览器。 + const runDir = path.join(root, '.vtable-visual/test-run'); await fs.mkdir(runDir, { recursive: true }); + const cachedReport = { baseline: {} }, cacheRuntime = { offline: true, defer() {}, async run(command, args) { + if (command === 'git' && args[0] === 'worktree') await fs.mkdir(args[3], { recursive: true }); + return 0; + } }; + await baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()); + await baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()); + assert.equal(cachedReport.baseline.cacheHit, true); await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'changed\n'); await assert.rejects(() => offlineDependencies(root, worktree, sha), /依赖配置不同/); + await assert.rejects(() => baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()), /依赖配置不同/); } finally { await fs.rm(root, { recursive: true, force: true }); await fs.rm(worktree, { recursive: true, force: true }); } }); diff --git a/packages/vtable/scripts/visual/build.mjs b/packages/vtable/scripts/visual/build.mjs index a1a6a45d7..f678f32e3 100644 --- a/packages/vtable/scripts/visual/build.mjs +++ b/packages/vtable/scripts/visual/build.mjs @@ -45,19 +45,23 @@ export async function resolveBaseline(root, sha, runtime, log) { if (sha && resolved !== sha.toLowerCase()) throw fault('BASELINE_FETCH_FAILED', '基线 SHA 与请求不一致'); return resolved; } -/** 同锁文件的离线基线复用只读依赖,构建产物仍在本次独立工作区生成。 */ -export async function offlineDependencies(root, worktree, sha) { +/** 缓存命中也必须先确认两侧依赖配置一致。 */ +async function assertOfflineConfiguration(root, sha) { for (const file of ['common/config/rush/pnpm-lock.yaml', 'rush.json']) if (git(root, ['show', `${sha}:${file}`]) !== (await fs.readFile(path.join(root, file), 'utf8')).trim()) throw fault('DEPENDENCIES_CHANGED', '两侧依赖配置不同,需要分别准备后再运行'); +} +/** 共用第三方存储,复制项目链接以保持每侧工作区包解析到各自源码。 */ +export async function offlineDependencies(root, worktree, sha) { + await assertOfflineConfiguration(root, sha); await fs.mkdir(path.join(worktree, 'common'), { recursive: true }); await fs.symlink(path.join(root, 'common/temp'), path.join(worktree, 'common/temp'), 'dir'); - const packages = git(root, ['ls-files', '--', '**/package.json']).split('\n').filter(Boolean); + const packages = git(root, ['ls-files', '--', 'packages/**/package.json', 'tools/**/package.json', 'share/**/package.json']).split('\n').filter(Boolean); for (const file of ['package.json', ...packages]) { const relative = path.join(path.dirname(file), 'node_modules'); if (await fs.stat(path.join(root, relative)).catch(error => error.code === 'ENOENT' ? null : Promise.reject(error))) { await fs.mkdir(path.dirname(path.join(worktree, relative)), { recursive: true }); - await fs.symlink(path.join(root, relative), path.join(worktree, relative), 'dir'); + await fs.cp(await fs.realpath(path.join(root, relative)), path.join(worktree, relative), { recursive: true, verbatimSymlinks: true }); } } } @@ -133,6 +137,7 @@ async function publishCache(cache, key, files) { } /** 按 SHA、锁文件、环境、包集合和构建配方缓存最近一次官方产物。 */ export async function baselineBuild(root, runDir, sha, names, runtime, report, timed) { + if (runtime.offline) await assertOfflineConfiguration(root, sha); const log = path.join(runDir, 'build.log'); const key = digest(JSON.stringify({ sha, names, lock: git(root, ['show', `${sha}:common/config/rush/pnpm-lock.yaml`]), From ff1ef7e733b8130d98a304156d5ff969d811ce65 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 20 Oct 2026 19:31:39 +0800 Subject: [PATCH 3/5] fix: validate complete Rush dependency configuration before cache --- packages/vtable/scripts/visual-test.test.mjs | 4 ++++ packages/vtable/scripts/visual/build.mjs | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs index 30f82c928..908699d36 100644 --- a/packages/vtable/scripts/visual-test.test.mjs +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -34,6 +34,7 @@ test('离线基线使用固定提交和同锁依赖', async () => { await fs.mkdir(path.join(root, 'common/temp'), { recursive: true }); await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'lockfileVersion: 5.4\n'); await fs.writeFile(path.join(root, 'rush.json'), '{}\n'); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":true}\n'); await fs.writeFile(path.join(root, 'package.json'), '{}\n'); await fs.writeFile(path.join(root, '.gitignore'), 'node_modules/\n.vtable-visual/\n'); for (const name of ['vtable', 'vtable-gantt']) { @@ -63,6 +64,9 @@ test('离线基线使用固定提交和同锁依赖', async () => { await baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()); await baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()); assert.equal(cachedReport.baseline.cacheHit, true); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":false}\n'); + await assert.rejects(() => baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()), /依赖配置不同/); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":true}\n'); await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'changed\n'); await assert.rejects(() => offlineDependencies(root, worktree, sha), /依赖配置不同/); await assert.rejects(() => baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()), /依赖配置不同/); diff --git a/packages/vtable/scripts/visual/build.mjs b/packages/vtable/scripts/visual/build.mjs index f678f32e3..76ca758a9 100644 --- a/packages/vtable/scripts/visual/build.mjs +++ b/packages/vtable/scripts/visual/build.mjs @@ -47,9 +47,9 @@ export async function resolveBaseline(root, sha, runtime, log) { } /** 缓存命中也必须先确认两侧依赖配置一致。 */ async function assertOfflineConfiguration(root, sha) { - for (const file of ['common/config/rush/pnpm-lock.yaml', 'rush.json']) - if (git(root, ['show', `${sha}:${file}`]) !== (await fs.readFile(path.join(root, file), 'utf8')).trim()) - throw fault('DEPENDENCIES_CHANGED', '两侧依赖配置不同,需要分别准备后再运行'); + if (git(root, ['diff', sha, '--', 'common/config/rush', 'rush.json']) || + git(root, ['ls-files', '--others', '--exclude-standard', '--', 'common/config/rush'])) + throw fault('DEPENDENCIES_CHANGED', '两侧依赖配置不同,需要分别准备后再运行'); } /** 共用第三方存储,复制项目链接以保持每侧工作区包解析到各自源码。 */ export async function offlineDependencies(root, worktree, sha) { @@ -141,6 +141,7 @@ export async function baselineBuild(root, runDir, sha, names, runtime, report, t const log = path.join(runDir, 'build.log'); const key = digest(JSON.stringify({ sha, names, lock: git(root, ['show', `${sha}:common/config/rush/pnpm-lock.yaml`]), + dependencyConfig: git(root, ['rev-parse', `${sha}:common/config/rush`]), node: process.version, platform: process.platform, arch: process.arch, recipe: digest(await fs.readFile(fileURLToPath(import.meta.url))), buildEnv, tools: git(root, ['show', `${sha}:rush.json`]) From 04c03355cae0621e6b40007fe49eda8bda6db301 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 20 Oct 2026 19:48:57 +0800 Subject: [PATCH 4/5] fix: bound offline visual build heap within isolated budget --- packages/vtable/scripts/visual-test.test.mjs | 3 ++- packages/vtable/scripts/visual/build.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs index 908699d36..74aab3eb9 100644 --- a/packages/vtable/scripts/visual-test.test.mjs +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -57,8 +57,9 @@ test('离线基线使用固定提交和同锁依赖', async () => { assert.equal(await fs.readFile(path.join(worktree, 'packages/vtable-gantt/node_modules/@visactor/vtable/version.txt'), 'utf8'), 'baseline'); // 空产物缓存只验证命中顺序,不启动构建或浏览器。 const runDir = path.join(root, '.vtable-visual/test-run'); await fs.mkdir(runDir, { recursive: true }); - const cachedReport = { baseline: {} }, cacheRuntime = { offline: true, defer() {}, async run(command, args) { + const cachedReport = { baseline: {} }, cacheRuntime = { offline: true, defer() {}, async run(command, args, _cwd, _log, options) { if (command === 'git' && args[0] === 'worktree') await fs.mkdir(args[3], { recursive: true }); + if (command === process.execPath) assert.equal(options.env.NODE_OPTIONS, '--max-old-space-size=2048'); return 0; } }; await baselineBuild(root, runDir, sha, [], cacheRuntime, cachedReport, (_name, action) => action()); diff --git a/packages/vtable/scripts/visual/build.mjs b/packages/vtable/scripts/visual/build.mjs index 76ca758a9..634025e3c 100644 --- a/packages/vtable/scripts/visual/build.mjs +++ b/packages/vtable/scripts/visual/build.mjs @@ -69,7 +69,8 @@ export async function offlineDependencies(root, worktree, sha) { export async function build(root, runDir, phase, names, runtime, log) { const rushx = path.join(root, 'common/scripts/install-run-rushx.js'); // Gantt 的 bundler.config 从 bundler 工具包解析 Rollup 插件。 - const env = { ...buildEnv, NODE_PATH: path.join(root, 'tools/bundler/node_modules') }; + // 离线宿主使用 3 GiB 硬预算,提前回收堆内对象并给运行库及工作区留出空间。 + const env = { ...buildEnv, ...(runtime.offline ? { NODE_OPTIONS: '--max-old-space-size=2048' } : {}), NODE_PATH: path.join(root, 'tools/bundler/node_modules') }; await runtime.run(process.execPath, [rushx, 'build'], path.join(root, 'tools/bundler'), log, { code: 'BUILD_FAILED', env }); const bundle = path.join(root, 'tools/bundler/bin/index.js'); const need = new Set(names); From 596d1451ce25794bb1b20c5bcf8051d983d94902 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 20 Oct 2026 20:13:45 +0800 Subject: [PATCH 5/5] test: leave font-independent room for automatic column resizing --- .../double-click-auto-column-width.mjs | 5 ++-- packages/vtable/scripts/visual-test.test.mjs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs index 22f924207..1c04dc63c 100644 --- a/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs +++ b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs @@ -25,7 +25,8 @@ export default { }); await page.mouse.move(edge.x, edge.y); await page.mouse.down(); - await page.mouse.move(edge.x + 120, edge.y, { steps: 10 }); + // 留出不同字体测量宽度的余量,保证双击前确实明显宽于内容。 + await page.mouse.move(edge.x + 240, edge.y, { steps: 10 }); await page.mouse.up(); const resized = await page.evaluate(() => { const b = window.__visualTable.getCellRect(2, 0).bounds; @@ -40,7 +41,7 @@ export default { await page.evaluate(() => { const b = window.__visualTable.getCellRect(2, 0).bounds; const width = b.x2 - b.x1; - if (window.__draggedColumnWidth < 240 || width >= window.__draggedColumnWidth - 20 || width < 150) + if (window.__draggedColumnWidth < 360 || width >= window.__draggedColumnWidth - 20 || width < 150) throw new Error(`双击自动列宽未生效:${window.__draggedColumnWidth} -> ${width}`); }); } diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs index 74aab3eb9..cf5289c09 100644 --- a/packages/vtable/scripts/visual-test.test.mjs +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -8,6 +8,29 @@ import { resolveBaseline, offlineDependencies, baselineBuild } from './visual/bu import fs from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; +import autoColumnWidth from '../__tests__/visual/cases/interaction/double-click-auto-column-width.mjs'; + +/** 复现 Linux 字体测量为 263px 时仍保留收缩断言,双击无响应必须失败。 */ +test('双击列宽用例在较宽字体下有效且拒绝无响应', async () => { + const previousWindow = globalThis.window, previousDocument = globalThis.document; + let width, pointer = 0, dragging = false, responds = true; + globalThis.window = { __visualTable: { getCellRect: () => ({ bounds: { x1: 250, x2: 250 + width, y1: 0, y2: 40 } }) } }; + globalThis.document = { getElementById: () => ({ getBoundingClientRect: () => ({ x: 0, y: 0 }) }) }; + const page = { evaluate: fn => fn(), mouse: { + async move(x) { if (dragging) width += x - pointer; pointer = x; }, + async down() { dragging = true; }, async up() { dragging = false; }, + async dblclick() { if (responds) width = 263; } + } }; + try { + width = 150; await autoColumnWidth.exercise(page); await autoColumnWidth.verify(page); + assert.equal(window.__draggedColumnWidth, 390); assert.equal(width, 263); + width = 150; responds = false; await autoColumnWidth.exercise(page); + await assert.rejects(() => autoColumnWidth.verify(page), /双击自动列宽未生效/); + } finally { + if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; + } +}); /** 用最小契约检查拦住无效范围、重复 ID 和缺失来源模块。 */ test('视觉用例清单与范围选择', async () => {