diff --git a/packages/vchart/__tests__/visual/README.md b/packages/vchart/__tests__/visual/README.md index 143b05f3f2..c6734a527e 100644 --- a/packages/vchart/__tests__/visual/README.md +++ b/packages/vchart/__tests__/visual/README.md @@ -31,6 +31,7 @@ Rush 安装还需要项目现有的 node-canvas 编译依赖;按仓库贡献 ```sh node packages/vchart/scripts/visual-test.mjs node packages/vchart/scripts/visual-test.mjs --baseline <完整的40位commit-sha> +node packages/vchart/scripts/visual-test.mjs --baseline <完整的40位commit-sha> --offline node packages/vchart/scripts/visual-test.mjs --dir components node packages/vchart/scripts/visual-test.mjs --dir components/label node packages/vchart/scripts/visual-test.mjs --case pie-label @@ -49,6 +50,7 @@ node packages/vchart/scripts/visual-test.mjs --check - 默认每次从官方仓库获取 develop,并固定本次 SHA;fork 的 `origin` 不影响基线选择。断网或拉取失败会报错,缓存不会冒充最新版本。 - 指定 SHA 仍从官方仓库获取该提交;用于复现已知基线。首期不支持任意仓库或共同祖先自动选择。 +- `--offline` 仅配合完整 `--baseline` 使用:宿主先准备官方提交和同配置依赖,执行时复用本地提交与依赖存储。两侧完整 Rush 配置及根目录、各项目的 `package.json` 必须相同;锁文件相同或缓存命中也不能绕过清单检查。项目链接各自解析到基线或当前源码,不执行联网 fetch/install,无法满足时直接失败。 - 当前工作区包含未提交修改,每次重新构建;构建过程中源码发生变化会要求重跑。 - 基线按自己的锁文件独立安装和构建。只缓存最近一次成功的基线构建,每次重新截图;缓存失效由 SHA、锁文件、Node、系统架构和构建配方决定;报告样式修改不使缓存失效,内容损坏会重建。 - 自比较只构建本地一次,在两套隔离 context 中执行;它验证测试确定性,不能证明图表结果正确。 @@ -97,7 +99,7 @@ node packages/vchart/node_modules/@playwright/test/cli.js show-report .vchart-vi ## 用例约定 -当前 262 个本地用例在 `cases/index.mjs` 显式注册,一用例一文件,按主要验证目的归入 `charts/`、`components/`、`data/`、`layout/`、`theme/`、`interaction/` 和 `api/`。详见 [目录导航、用例说明和待补场景](./cases/README.md)。元数据仅在清单中维护,每项包含 `id`、`purpose`、`file`,`sourceExample` 为可选的真实公开示例来源。 +当前 264 个本地用例在 `cases/index.mjs` 显式注册,一用例一文件,按主要验证目的归入 `charts/`、`components/`、`data/`、`layout/`、`theme/`、`interaction/` 和 `api/`。详见 [目录导航、用例说明和待补场景](./cases/README.md)。元数据仅在清单中维护,每项包含 `id`、`purpose`、`file`,`sourceExample` 为可选的真实公开示例来源。 ID 必须唯一且符合 `^[a-z][a-z0-9-]*$`;file 使用 cases 内的 `./<目录>/.mjs`,目录层级和文件名使用小写字母、数字与连字符。缺文件、非法导出、越界、符号链接和空选择集在构建前失败,未登记的模块不会自动执行。 diff --git a/packages/vchart/__tests__/visual/cases/README.md b/packages/vchart/__tests__/visual/cases/README.md index ee39f99bbb..3db17f70df 100644 --- a/packages/vchart/__tests__/visual/cases/README.md +++ b/packages/vchart/__tests__/visual/cases/README.md @@ -1,6 +1,6 @@ # 本地视觉测试用例 -一份 case 一个文件,按主要验证目的归类,`index.mjs` 是唯一可执行清单。开发者或编码 Agent 根据改动选择测试范围;工具不推断源码影响范围。当前已登记 262 个用例,具体 ID、目的和文件用 `--list` 查询,配置分支与限制见 [覆盖清单](./COVERAGE.md)。 +一份 case 一个文件,按主要验证目的归类,`index.mjs` 是唯一可执行清单。开发者或编码 Agent 根据改动选择测试范围;工具不推断源码影响范围。当前已登记 264 个用例,具体 ID、目的和文件用 `--list` 查询,配置分支与限制见 [覆盖清单](./COVERAGE.md)。 ## 目录与选择 diff --git a/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-flush.mjs b/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-flush.mjs index 31fe1b75ed..86105aa99d 100644 --- a/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-flush.mjs +++ b/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-flush.mjs @@ -1,5 +1,5 @@ import { verifySourceSpec, verifyRendered } from '../../../helpers.mjs'; -import { interactionFrame } from '../../../interaction-helpers.mjs'; +import { interactionFrame, clippedAxisLabelTarget } from '../../../interaction-helpers.mjs'; /** * BugServer case IDs: 64b7beba14220f89e4720c80 * 迁移边界:来源固定 350×200;执行时缩窄到 180×200,形成截断后悬停,保留 flush/autoLimit 配置和原始数据。 @@ -43,22 +43,7 @@ export default { // 执行来源配置或回调要求的动作并保存实际结果。 await page.evaluate(() => window.__visualChart.resize(180, 200)); - const p = await page.evaluate(() => { - const g = window.__visualChart - .getStage() - .find( - g => - g.name === 'axis-label' && - g.type === 'text' && - g.cliped && - g.globalAABBBounds.y1 > 30 && - g.globalAABBBounds.y2 < 500, - true - ); - if (!g) throw Error('来源未产生截断轴标签'); - const b = g.globalAABBBounds; - return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(g.attribute.text) }; - }); + const p = await clippedAxisLabelTarget(page); await page.mouse.move(p.x, p.y); await interactionFrame(page); await page.evaluate(p => (window.__poptipTarget = p), p); diff --git a/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-rotated.mjs b/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-rotated.mjs index f063b0ed8d..730be8e927 100644 --- a/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-rotated.mjs +++ b/packages/vchart/__tests__/visual/cases/components/axis/axis-poptip-rotated.mjs @@ -1,5 +1,5 @@ import { verifySourceSpec, verifyRendered } from '../../../helpers.mjs'; -import { interactionFrame } from '../../../interaction-helpers.mjs'; +import { interactionFrame, clippedAxisLabelTarget } from '../../../interaction-helpers.mjs'; /** * BugServer case IDs: 64b7bf3f14220f89e4720c82 * 迁移边界:定位实际截断的中间轴标签,避免轴交点的遮挡;记录的旧坐标未被当作新宿主的目标。 @@ -86,22 +86,7 @@ export default { async exercise(page) { // 执行来源配置或回调要求的动作并保存实际结果。 - const p = await page.evaluate(() => { - const g = window.__visualChart - .getStage() - .find( - g => - g.name === 'axis-label' && - g.type === 'text' && - g.cliped && - g.globalAABBBounds.y1 > 30 && - g.globalAABBBounds.y2 < 500, - true - ); - if (!g) throw Error('来源未产生截断轴标签'); - const b = g.globalAABBBounds; - return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(g.attribute.text) }; - }); + const p = await clippedAxisLabelTarget(page); await page.mouse.move(p.x, p.y); await interactionFrame(page); await page.evaluate(p => (window.__poptipTarget = p), p); diff --git a/packages/vchart/__tests__/visual/interaction-helpers.mjs b/packages/vchart/__tests__/visual/interaction-helpers.mjs index 8d391aa76e..5770685ba0 100644 --- a/packages/vchart/__tests__/visual/interaction-helpers.mjs +++ b/packages/vchart/__tests__/visual/interaction-helpers.mjs @@ -34,12 +34,35 @@ export async function interactionTarget(page, name, index = 0, seriesIndex) { y: matrix.b * local.x + matrix.d * local.y + matrix.f }; const b = g.globalAABBBounds; + // 混合系列可能遮住柱体中点;优先找可命中内点,刷选覆盖层仍保留原中心坐标。 + if (name === 'bar') { + for (const fraction of [0.5, 0.25, 0.75]) { + const x = b.x1 + (b.x2 - b.x1) * fraction, + y = (b.y1 + b.y2) / 2; + if (window.__visualChart.getStage().pick(x, y)?.graphic === g) return { x, y }; + } + } return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 }; }, { name, index, seriesIndex } ); } +/** 选实际可命中的截断轴标签,排除被轴交点遮挡的包围盒中心。 */ +export async function clippedAxisLabelTarget(page) { + return page.evaluate(() => { + const stage = window.__visualChart.getStage(); + const graphic = stage.find(g => { + if (g.name !== 'axis-label' || g.type !== 'text' || !g.cliped) return false; + const b = g.globalAABBBounds; + return b.y1 > 30 && b.y2 < 500 && stage.pick((b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2)?.graphic === g; + }, true); + if (!graphic) throw new Error('来源未产生可命中的截断轴标签'); + const b = graphic.globalAABBBounds; + return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(graphic.attribute.text) }; + }); +} + /** 让输入事件后的绘制队列完成;是否生效仍由独立 verify 检查真实状态。 */ export async function interactionFrame(page) { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); diff --git a/packages/vchart/scripts/visual-offline.test.mjs b/packages/vchart/scripts/visual-offline.test.mjs new file mode 100644 index 0000000000..7360cea7c7 --- /dev/null +++ b/packages/vchart/scripts/visual-offline.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { parseOptions } from './visual-test.mjs'; +import { resolveBaseline, offlineDependencies, baselineBuild } from './visual/build.mjs'; + +/** 离线模式既不联网,也不能让基线链接借用候选源码或旧配置缓存。 */ +test('离线基线固定提交、依赖来源和缓存配置', async () => { + assert.throws(() => parseOptions(['--offline']), /需要/); + assert.equal(parseOptions(['--offline', '--baseline', 'a'.repeat(40)]).offline, true); + const root = await fs.mkdtemp(path.join(tmpdir(), 'vchart-offline-')); + const worktree = await fs.mkdtemp(path.join(tmpdir(), 'vchart-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 }); + for (const [file, content] of [ + ['common/config/rush/pnpm-lock.yaml', 'lockfileVersion: 5.4\n'], + ['common/config/rush/pnpm-config.json', '{"useWorkspaces":true}\n'], + ['rush.json', '{}\n'], + ['package.json', '{}\n'], + ['.gitignore', 'node_modules/\n.vchart-visual/\n'] + ]) + await fs.writeFile(path.join(root, file), content); + for (const name of ['vchart', 'vutils-extension']) { + 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/vutils-extension/version.txt'), 'baseline'); + await fs.mkdir(path.join(root, 'packages/vchart/node_modules/@visactor'), { recursive: true }); + await fs.symlink( + '../../../vutils-extension', + path.join(root, 'packages/vchart/node_modules/@visactor/vutils-extension'), + '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/vutils-extension/version.txt'), 'current'); + await fs.mkdir(path.join(worktree, 'packages/vutils-extension'), { recursive: true }); + await fs.writeFile(path.join(worktree, 'packages/vutils-extension/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/vchart/node_modules/@visactor/vutils-extension/version.txt'), + 'utf8' + ), + 'baseline' + ); + const runDir = path.join(root, '.vchart-visual/test-run'); + await fs.mkdir(runDir, { recursive: true }); + const report = { baseline: {} }, + cacheRuntime = { + offline: true, + defer() {}, + async run(command, args) { + assert.equal(command, 'git'); + assert.equal(args[0], 'worktree'); + await fs.mkdir(args[3], { recursive: true }); + } + }; + const buildOptions = { buildBundle: async (_root, destination) => fs.writeFile(destination, 'cache fixture') }; + await baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions); + await baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions); + assert.equal(report.baseline.cacheHit, true); + // 锁文件不变也不能共享不同的项目声明,已有基线缓存不能绕过检查。 + await fs.writeFile(path.join(root, 'packages/vchart/package.json'), '{"dependencies":{"beta":"1.0.0"}}\n'); + await assert.rejects( + () => offlineDependencies(root, path.join(worktree, 'manifest-mismatch'), sha), + /依赖配置不同/ + ); + await assert.rejects( + () => baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions), + /依赖配置不同/ + ); + await fs.writeFile(path.join(root, 'packages/vchart/package.json'), '{}\n'); + await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":false}\n'); + await assert.rejects( + () => baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions), + /依赖配置不同/ + ); + 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), /依赖配置不同/); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(worktree, { recursive: true, force: true }); + } +}); diff --git a/packages/vchart/scripts/visual-source-snapshot.test.mjs b/packages/vchart/scripts/visual-source-snapshot.test.mjs new file mode 100644 index 0000000000..7d893ce9b7 --- /dev/null +++ b/packages/vchart/scripts/visual-source-snapshot.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { snapshotSource } from './visual/runner.mjs'; +import { workingTree } from './visual/build.mjs'; + +test('结束快照记录真实源码,截图后编辑或快照读取失败均阻止通过', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'vchart-source-snapshot-')); + const git = args => + execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { + cwd: root, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@example.invalid', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@example.invalid' + } + }); + try { + git(['init', '--quiet']); + await fs.writeFile(path.join(root, 'source.js'), 'before'); + git(['add', '.']); + git(['commit', '--quiet', '-m', 'fixture']); + const local = await workingTree(root), + stable = { local, issues: [] }; + await snapshotSource(root, stable); + assert.deepEqual(stable.localAfter, local); + assert.deepEqual(stable.issues, []); + await fs.writeFile(path.join(root, 'source.js'), 'edited-after-screenshot'); + const changed = { local, issues: [] }; + await snapshotSource(root, changed); + assert.equal(changed.localAfter.head, local.head); + assert.notEqual(changed.localAfter.digest, local.digest); + assert.equal(changed.issues[0].code, 'WORKSPACE_CHANGED'); + const missing = { local, issues: [] }; + await snapshotSource(path.join(root, 'missing'), missing); + assert.equal(missing.issues[0].code, 'SOURCE_SNAPSHOT_FAILED'); + const unprepared = { issues: [] }; + await snapshotSource(root, unprepared); + assert.equal(unprepared.localAfter, undefined); + assert.deepEqual(unprepared.issues, []); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/vchart/scripts/visual-targets.test.mjs b/packages/vchart/scripts/visual-targets.test.mjs new file mode 100644 index 0000000000..1803d2aba1 --- /dev/null +++ b/packages/vchart/scripts/visual-targets.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runInNewContext } from 'node:vm'; +import { interactionTarget, clippedAxisLabelTarget } from '../__tests__/visual/interaction-helpers.mjs'; + +/** 将定位函数放入独立页面上下文,用覆盖层和轴交点验证真实命中规则。 */ +function pageFor(chart) { + return { + evaluate: (fn, input) => runInNewContext(`(${fn.toString()})(input)`, { input, window: { __visualChart: chart } }) + }; +} + +test('混合系列定位避开被面积覆盖的柱心,刷选覆盖层仍使用几何中心', async () => { + const graphic = { attribute: {}, globalAABBBounds: { x1: 0, y1: 0, x2: 100, y2: 100, width: () => 100 } }; + let covered = false; + const page = pageFor({ + getChart: () => ({ getAllSeries: () => [{ getMarks: () => [{ name: 'bar', getGraphics: () => [graphic] }] }] }), + getStage: () => ({ pick: x => ({ graphic: !covered && x < 50 ? graphic : {} }) }) + }); + const visible = await interactionTarget(page, 'bar'); + assert.equal(visible.x, 25); + assert.equal(visible.y, 50); + covered = true; + const center = await interactionTarget(page, 'bar'); + assert.equal(center.x, 50); + assert.equal(center.y, 50); + await assert.rejects(() => interactionTarget(page, 'missing'), /缺少交互图元/); +}); + +test('截断轴标签定位拒绝交点遮挡,无可命中标签时明确失败', async () => { + const labels = [470, 370].map(y => ({ + name: 'axis-label', + type: 'text', + cliped: true, + attribute: { text: 'full-' + y }, + globalAABBBounds: { x1: 20, x2: 90, y1: y - 5, y2: y + 5 } + })); + let blocked = false; + const stage = { + find: predicate => labels.find(predicate), + pick: (_x, y) => ({ graphic: !blocked && y < 400 ? labels[1] : {} }) + }; + const page = pageFor({ getStage: () => stage }); + const target = await clippedAxisLabelTarget(page); + assert.equal(target.text, 'full-370'); + assert.equal(target.y, 370); + blocked = true; + await assert.rejects(() => clippedAxisLabelTarget(page), /可命中的截断轴标签/); +}); diff --git a/packages/vchart/scripts/visual-test.mjs b/packages/vchart/scripts/visual-test.mjs index 84ea7ad9ee..129e2bec67 100644 --- a/packages/vchart/scripts/visual-test.mjs +++ b/packages/vchart/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; @@ -56,7 +58,7 @@ export async function main(configuration = {}) { if (options.help) { console.log( command + - ' [--baseline <40位SHA> | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 components/label)。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VChart develop。退出码:0 通过,1 视觉差异,2 执行错误。' + ' [--baseline <40位SHA> [--offline] | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 components/label)。\n--offline 使用宿主已准备的提交与同锁依赖,不访问网络。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VChart develop。退出码:0 通过,1 视觉差异,2 执行错误。' ); return; } diff --git a/packages/vchart/scripts/visual/build.mjs b/packages/vchart/scripts/visual/build.mjs index 5dbc280d40..d37af21065 100644 --- a/packages/vchart/scripts/visual/build.mjs +++ b/packages/vchart/scripts/visual/build.mjs @@ -41,6 +41,12 @@ export async function workingTree(root) { } /** 从固定官方地址获取并固定提交;fork 的 origin 不参与解析。 */ export async function resolveBaseline(root, sha, runtime, log) { + if (runtime.offline) { + // 宿主已从官方仓库准备固定提交,隔离执行期间不再 fetch。 + 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' }); @@ -48,6 +54,42 @@ export async function resolveBaseline(root, sha, runtime, log) { if (sha && resolved !== sha.toLowerCase()) throw fault('BASELINE_FETCH_FAILED', '基线 SHA 与请求不一致'); return resolved; } +/** 缓存命中前核对 Rush 配置和项目清单,锁不变也不能共享不同依赖链接。 */ +async function assertOfflineConfiguration(root, sha) { + const files = ['common/config/rush', 'rush.json', 'package.json', '**/package.json']; + if ( + git(root, ['diff', sha, '--', ...files]) || + git(root, ['ls-files', '--others', '--exclude-standard', '--', ...files]) + ) + 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', + '--', + '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.cp(await fs.realpath(path.join(root, relative)), path.join(worktree, relative), { + recursive: true, + verbatimSymlinks: true + }); + } + } +} /** 执行既有最小构建链,仅删除已知且被忽略的 UMD 目标以排除旧文件。 */ export async function build(root, destination, runtime, log) { const output = 'packages/vchart/build/index.js'; @@ -68,7 +110,14 @@ export async function build(root, destination, runtime, log) { [path.join(root, 'common/scripts/install-run-rushx.js'), ...args], path.join(root, folder), log, - { code: 'BUILD_FAILED', env: buildEnv } + { + code: 'BUILD_FAILED', + env: { + ...buildEnv, + ...(runtime.offline ? { NODE_OPTIONS: '--max-old-space-size=2048' } : {}), + NODE_PATH: path.join(root, 'tools/bundler/node_modules') + } + } ); try { if (!(await fs.stat(path.join(root, output))).size) throw new Error('构建产物为空'); @@ -125,11 +174,13 @@ export async function baselineBuild( timed, { buildBundle = build, buildKey = '', outputDirectory = '.vchart-visual' } = {} ) { + if (runtime.offline) await assertOfflineConfiguration(root, sha); const log = path.join(runDir, 'build.log'); const key = digest( JSON.stringify({ sha, 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, @@ -159,10 +210,18 @@ export async function baselineBuild( }); 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' } - }) + 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', () => buildBundle(worktree, path.join(runDir, 'baseline.js'), runtime, log)); await publishCache(cache, key, await fs.readFile(path.join(runDir, 'baseline.js'))); diff --git a/packages/vchart/scripts/visual/runner.mjs b/packages/vchart/scripts/visual/runner.mjs index 322af24657..9484389a06 100644 --- a/packages/vchart/scripts/visual/runner.mjs +++ b/packages/vchart/scripts/visual/runner.mjs @@ -155,6 +155,28 @@ export async function acquireLock(output, owner) { await fs.rm(lock, { recursive: true, force: true }); }; } +/** 截图及清理期间也可能发生编辑,结束快照必须保留并阻止旧构建冒充新源码。 */ +export async function snapshotSource(root, report) { + if (!report.local) return; + try { + report.localAfter = await workingTree(root); + if (report.local.head !== report.localAfter.head || report.local.digest !== report.localAfter.digest) + report.issues.push({ + phase: 'source', + category: 'execution', + code: 'WORKSPACE_CHANGED', + message: '检查前后源码或 HEAD 发生变化,请重跑' + }); + } catch (error) { + report.issues.push({ + phase: 'source', + category: 'execution', + code: 'SOURCE_SNAPSHOT_FAILED', + message: error.message + }); + } +} + /** 编排一次完整对比;所有准备失败和清理失败尽可能进入统一报告。 */ export async function runVisual(root, options, configuration = {}) { const { @@ -162,7 +184,7 @@ export async function runVisual(root, options, configuration = {}) { outputDirectory = '.vchart-visual', buildBundle = build } = configuration; - const runtime = createRuntime(); + const runtime = createRuntime({ offline: options.offline === true }); const detach = runtime.listenSignals(); const output = path.join(root, outputDirectory); const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -315,6 +337,7 @@ export async function runVisual(root, options, configuration = {}) { report.issues.push({ phase: 'cleanup', category: 'execution', code: 'CLEANUP_FAILED', message: String(error) }); } } + await snapshotSource(root, report); detach(); report.finalized = true; report.timings.totalMs = Math.round(performance.now() - started); diff --git a/packages/vchart/scripts/visual/runtime.mjs b/packages/vchart/scripts/visual/runtime.mjs index 8afac1aa6c..79f1d7ddcc 100644 --- a/packages/vchart/scripts/visual/runtime.mjs +++ b/packages/vchart/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; },