Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/vtable/__tests__/visual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run-id>/`:`index.html` 可离线查看基线、当前和差异三图,`agent-summary.md` 便于快速定位失败,`summary.json` 保留结构化证据。退出码 `0` 表示所选用例均通过,`1` 表示视觉差异,`2` 表示执行、构建、资源或清理错误。报告可以整体复制,单独复制 HTML 会丢失相对图片。

正常结束、失败或 Ctrl+C 后,工具会回收本轮测试子进程、浏览器、HTTP 服务和临时基线 worktree。SIGKILL/断电后,先确认进程已结束,再检查 `git worktree list` 和 `.vtable-visual/running.lock`;不要删除其他任务的 worktree。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`);
});
}
Expand Down
4 changes: 3 additions & 1 deletion packages/vtable/scripts/visual-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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;
Expand All @@ -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 <id>]\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 <id>]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 table/cells)。\n--offline 使用宿主已准备的提交与同锁依赖,不访问网络。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VTable develop。退出码:0 通过,1 视觉差异,2 执行错误。'
);
return;
}
Expand Down
79 changes: 79 additions & 0 deletions packages/vtable/scripts/visual-test.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ 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, baselineBuild } from './visual/build.mjs';
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 () => {
Expand All @@ -17,3 +44,55 @@ 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, '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']) {
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, _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());
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()), /依赖配置不同/);
} finally { await fs.rm(root, { recursive: true, force: true }); await fs.rm(worktree, { recursive: true, force: true }); }
});
33 changes: 31 additions & 2 deletions packages/vtable/scripts/visual/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,43 @@ 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;
}
/** 缓存命中也必须先确认两侧依赖配置一致。 */
async function assertOfflineConfiguration(root, sha) {
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) {
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 });
}
}
}
/** 构建所选包及其工作区依赖,并仅复制本轮产物到隔离运行目录。 */
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);
Expand Down Expand Up @@ -111,9 +138,11 @@ 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`]),
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`])
Expand All @@ -131,7 +160,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' } }));
Comment thread
Rui-Sun marked this conversation as resolved.
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`))]))));
}
2 changes: 1 addition & 1 deletion packages/vtable/scripts/visual/runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
Expand Down
3 changes: 2 additions & 1 deletion packages/vtable/scripts/visual/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,6 +60,7 @@ export function createRuntime() {
killTimer.unref();
}
return {
offline,
get interrupted() {
return interrupted;
},
Expand Down
Loading