diff --git a/.github/workflows/visactor-trusted-ci.yml b/.github/workflows/visactor-trusted-ci.yml new file mode 100644 index 0000000000..4921a4045e --- /dev/null +++ b/.github/workflows/visactor-trusted-ci.yml @@ -0,0 +1,36 @@ +name: VisActor trusted CI dispatch +run-name: VisActor trusted CI / ${{ inputs.request_digest }} + +# 只受理默认分支的固定控制请求;PR 代码不会在持有 GitHub 凭据的 job 中执行。 +on: + workflow_dispatch: + inputs: + request: + description: Version-bound CI request from the trusted controller + required: true + type: string + request_digest: + description: SHA-256 of the canonical request + required: true + type: string + +permissions: + contents: read + +jobs: + dispatch: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22.22.2' + - name: Confirm the bound request (does not execute tests) + env: + FLOW_CI_REQUEST: ${{ inputs.request }} + FLOW_CI_REQUEST_DIGEST: ${{ inputs.request_digest }} + run: node common/scripts/flow-ci-dispatch.mjs diff --git a/common/changes/@visactor/vtable/fix-selection-range-graphics_2026-10-16.json b/common/changes/@visactor/vtable/fix-selection-range-graphics_2026-10-16.json new file mode 100644 index 0000000000..9d8ca99f21 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-selection-range-graphics_2026-10-16.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "修复重复提交同一选区时新选框被误删、旧选框与填充柄残留的问题。", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "sunrui1992@foxmail.com" +} diff --git a/common/config/flow-ci.json b/common/config/flow-ci.json new file mode 100644 index 0000000000..4218c8f205 --- /dev/null +++ b/common/config/flow-ci.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "kind": "trusted_ci_policy", + "repository": "VisActor/VTable", + "baseRef": "develop", + "nodeMajor": 22, + "rushVersion": "5.148.0", + "pnpmVersion": "10.7.0", + "unitPackages": [ + "react-vtable", + "vtable", + "vtable-editors", + "vtable-gantt", + "vtable-plugins", + "vtable-search", + "vtable-sheet", + "vue-vtable" + ], + "buildTargets": [ + "@visactor/react-vtable", + "@visactor/vtable", + "@visactor/vtable-editors", + "@visactor/vtable-gantt", + "@visactor/vtable-plugins", + "@visactor/vtable-search", + "@visactor/vtable-sheet", + "@visactor/vue-vtable", + "@visactor/vtable-export", + "@visactor/vtable-calendar" + ], + "visualProduct": "vtable", + "visualSelection": "full", + "requiredChecks": [ + "VisActor Flow / Build", + "VisActor Flow / Unit", + "VisActor Flow / Repository visual" + ], + "firefoxPaste": true, + "visualSuites": [ + "core" + ], + "buildFormats": [ + "es", + "cjs" + ] +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a968e61234..cbbc9f3960 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -493,8 +493,8 @@ importers: specifier: workspace:* version: link:../../share/ts-config '@playwright/test': - specifier: 1.55.1 - version: 1.55.1 + specifier: 1.63.0 + version: 1.63.0 '@resvg/resvg-js': specifier: ^2.5.0 version: 2.6.2 @@ -2135,7 +2135,7 @@ importers: version: 4.9.5 vitest: specifier: 0.30.1 - version: 0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.55.1)(sass@1.43.5)(terser@5.17.1) + version: 0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.63.0)(sass@1.43.5)(terser@5.17.1) packages: @@ -2970,9 +2970,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@playwright/test@1.55.1': - resolution: {integrity: sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==} - engines: {node: '>=18'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} hasBin: true '@remix-run/router@1.4.0': @@ -5480,11 +5480,6 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7406,14 +7401,14 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.55.1: - resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} - engines: {node: '>=18'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} hasBin: true - playwright@1.55.1: - resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} - engines: {node: '>=18'} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} hasBin: true plugin-error@0.1.2: @@ -10610,9 +10605,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@playwright/test@1.55.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.55.1 + playwright: 1.63.0 '@remix-run/router@1.4.0': {} @@ -13523,9 +13518,6 @@ snapshots: nan: 2.26.2 optional: true - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -15959,13 +15951,11 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - playwright-core@1.55.1: {} + playwright-core@1.63.0: {} - playwright@1.55.1: + playwright@1.63.0: dependencies: - playwright-core: 1.55.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 plugin-error@0.1.2: dependencies: @@ -17839,7 +17829,7 @@ snapshots: sass: 1.43.5 terser: 5.17.1 - vitest@0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.55.1)(sass@1.43.5)(terser@5.17.1): + vitest@0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.63.0)(sass@1.43.5)(terser@5.17.1): dependencies: '@types/chai': 4.3.20 '@types/chai-subset': 1.3.6(@types/chai@4.3.20) @@ -17869,7 +17859,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: jsdom: 16.7.0 - playwright: 1.55.1 + playwright: 1.63.0 transitivePeerDependencies: - less - sass diff --git a/common/scripts/flow-ci-dispatch.mjs b/common/scripts/flow-ci-dispatch.mjs new file mode 100644 index 0000000000..123f540773 --- /dev/null +++ b/common/scripts/flow-ci-dispatch.mjs @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +/** 固定键顺序;派发摘要绑定完整输入,不依赖作者提供的展示标题。 */ +export function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])); + return value; +} + +/** 默认分支只确认受理身份;候选源码、构建及测试由无凭据隔离侧执行。 */ +export function dispatchReceipt(text, expectedDigest, policy, env) { + if (typeof text !== 'string' || Buffer.byteLength(text) > 4096) throw new Error('派发输入超限'); + const request = JSON.parse(text); + const keys = ['schemaVersion', 'kind', 'requestId', 'repository', 'pullRequest', 'headSha', 'baseSha', 'controlCommit', 'policyDigest', 'dependencyDigest']; + const sha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); + const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); + if (!request || Array.isArray(request) || Object.keys(request).sort().join() !== keys.sort().join() || + request.schemaVersion !== 1 || request.kind !== 'ci_request' || !hash(request.requestId) || + request.repository !== policy.repository || !Number.isSafeInteger(request.pullRequest) || request.pullRequest < 1 || + ![request.headSha, request.baseSha, request.controlCommit].every(sha) || ![request.policyDigest, request.dependencyDigest, expectedDigest].every(hash)) throw new Error('派发身份非法'); + const requestDigest = createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex'); + if (requestDigest !== expectedDigest || env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || + env.GITHUB_REPOSITORY !== policy.repository || env.GITHUB_REF !== 'refs/heads/' + policy.baseRef || + env.GITHUB_SHA !== request.controlCommit || env.GITHUB_RUN_ATTEMPT !== '1' || !/^[1-9][0-9]*$/.test(env.GITHUB_RUN_ID ?? '')) throw new Error('派发不是当前可信默认分支的首次运行'); + return { schemaVersion: 1, kind: 'ci_dispatch_receipt', requestDigest, request, workflowRunId: env.GITHUB_RUN_ID, + controlCommit: env.GITHUB_SHA, testsExecuted: false }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const policy = JSON.parse(await fs.readFile(new URL('../config/flow-ci.json', import.meta.url), 'utf8')); + console.log(JSON.stringify(dispatchReceipt(process.env.FLOW_CI_REQUEST, process.env.FLOW_CI_REQUEST_DIGEST, policy, process.env))); +} diff --git a/common/scripts/flow-ci-dispatch.test.mjs b/common/scripts/flow-ci-dispatch.test.mjs new file mode 100644 index 0000000000..64a39d56bc --- /dev/null +++ b/common/scripts/flow-ci-dispatch.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { canonical, dispatchReceipt } from './flow-ci-dispatch.mjs'; + +const policy = JSON.parse(await fs.readFile(new URL('../config/flow-ci.json', import.meta.url), 'utf8')); +const request = { schemaVersion: 1, kind: 'ci_request', requestId: 'a'.repeat(64), repository: policy.repository, pullRequest: 1, + headSha: 'b'.repeat(40), baseSha: 'c'.repeat(40), controlCommit: 'd'.repeat(40), policyDigest: 'e'.repeat(64), dependencyDigest: 'f'.repeat(64) }; +const hash = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); +const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: policy.repository, GITHUB_REF: 'refs/heads/develop', + GITHUB_SHA: request.controlCommit, GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '123' }; + +test('仅受理固定默认分支和精确输入,不宣称执行了测试', () => { + const result = dispatchReceipt(JSON.stringify(request), hash(request), policy, env); + assert.equal(result.testsExecuted, false); assert.equal(result.requestDigest, hash(request)); + assert.equal(result.workflowRunId, '123'); assert.deepEqual(result.request, request); +}); + +for (const change of [{ GITHUB_EVENT_NAME: 'pull_request_target' }, { GITHUB_EVENT_NAME: 'pull_request' }, + { GITHUB_REF: 'refs/pull/1/merge' }, { GITHUB_REF: 'refs/heads/untrusted' }, { GITHUB_REPOSITORY: 'fork/repository' }, + { GITHUB_SHA: '0'.repeat(40) }, { GITHUB_RUN_ATTEMPT: '2' }, { GITHUB_RUN_ID: '0' }]) test('拒绝不可信运行上下文 ' + JSON.stringify(change), () => { + assert.throws(() => dispatchReceipt(JSON.stringify(request), hash(request), policy, { ...env, ...change })); +}); + +for (const change of [{ pullRequest: 0 }, { repository: 'fork/repository' }, { headSha: 'main' }, { extra: true }, + { policyDigest: null }, { dependencyDigest: 'x' }, { kind: 'test_result' }]) test('拒绝未绑定输入 ' + JSON.stringify(change), () => { + const value = { ...request, ...change }; assert.throws(() => dispatchReceipt(JSON.stringify(value), hash(value), policy, env)); +}); + +test('摘要变化和超限输入不能复用受理记录', () => { + assert.throws(() => dispatchReceipt(JSON.stringify({ ...request, headSha: '0'.repeat(40) }), hash(request), policy, env)); + assert.throws(() => dispatchReceipt(' '.repeat(4097), hash(request), policy, env)); +}); + + +test('固定单测清单覆盖当前所有产品包,不因默认分支新增包而漏测', async () => { + const root = new URL('../../packages/', import.meta.url); const expected = []; + for (const entry of await fs.readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const name = entry.name; + try { const pkg = JSON.parse(await fs.readFile(new URL(name + '/package.json', root), 'utf8')); if (pkg.scripts?.test) expected.push(name); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + } + assert.deepEqual([...policy.unitPackages].sort(), expected.sort()); +}); diff --git a/packages/vtable/__tests__/listTable-highlightInRange.test.ts b/packages/vtable/__tests__/listTable-highlightInRange.test.ts index 0f25cbc68f..996da74354 100644 --- a/packages/vtable/__tests__/listTable-highlightInRange.test.ts +++ b/packages/vtable/__tests__/listTable-highlightInRange.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck // 有问题可对照demo unitTestListTable import records from './data/marketsales.json'; -import { ListTable } from '../src'; +import { ListTable, setCustomAlphabetCharSet } from '../src'; import { createDiv } from './dom'; global.__VERSION__ = 'none'; describe('listTable init test', () => { @@ -200,17 +200,33 @@ describe('listTable init test', () => { expect(listTable.getScrollTop()).toBe(scrollTop); }); test('listTable measureTextWidth', () => { - const measureTextWdith = listTable.measureText("家里方大化工撒个福建师大看哈 fdsfgj! *-+&5%#.,'.,。、", { - fontFamily: 'Arial', - fontSize: 15, - fontWeight: 'bold' - }); - expect(measureTextWdith).toEqual({ - fontBoundingBoxAscent: 14, - fontBoundingBoxDescent: 3, - width: 390.0501427283655, - height: 15 + // 固定原生字形度量,精确验证快速测量;不把操作系统字体的小数误差作为 API 契约。 + setCustomAlphabetCharSet(''); + const measure = jest.spyOn(CanvasRenderingContext2D.prototype, 'measureText').mockImplementation(function (text) { + expect(this.font).toBe('bold 15px Arial'); + return { + width: Array.from(text).reduce((width, char) => width + (char.charCodeAt(0) > 127 ? 16 : 8), 0), + fontBoundingBoxAscent: 14, + fontBoundingBoxDescent: 3 + }; }); + try { + const measureTextWidth = listTable.measureText("家里方大化工撒个福建师大看哈 fdsfgj! *-+&5%#.,'.,。、", { + fontFamily: 'Arial', + fontSize: 15, + fontWeight: 'bold' + }); + expect(measure).toHaveBeenCalled(); + expect(measureTextWidth).toEqual({ + fontBoundingBoxAscent: 14, + fontBoundingBoxDescent: 3, + width: 432, + height: 15 + }); + } finally { + measure.mockRestore(); + setCustomAlphabetCharSet(''); + } }); // test('listTable API getAllCells', () => { // expect(JSON.parse(JSON.stringify(listTable.getCellInfo(5, 5)))).toEqual({ diff --git a/packages/vtable/__tests__/listTable.test.ts b/packages/vtable/__tests__/listTable.test.ts index 718cccb2a6..fb8235a613 100644 --- a/packages/vtable/__tests__/listTable.test.ts +++ b/packages/vtable/__tests__/listTable.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck // 有问题可对照demo unitTestListTable import records from './data/marketsales.json'; -import { ListTable } from '../src'; +import { ListTable, setCustomAlphabetCharSet } from '../src'; import { createDiv } from './dom'; global.__VERSION__ = 'none'; describe('listTable init test', () => { @@ -249,17 +249,33 @@ describe('listTable init test', () => { dragTable.release(); }); test('listTable measureTextWidth', () => { - const measureTextWdith = listTable.measureText("家里方大化工撒个福建师大看哈 fdsfgj! *-+&5%#.,'.,。、", { - fontFamily: 'Arial', - fontSize: 15, - fontWeight: 'bold' - }); - expect(measureTextWdith).toEqual({ - fontBoundingBoxAscent: 14, - fontBoundingBoxDescent: 3, - width: 390.0501427283655, - height: 15 + // 固定原生字形度量,精确验证快速测量;不把操作系统字体的小数误差作为 API 契约。 + setCustomAlphabetCharSet(''); + const measure = jest.spyOn(CanvasRenderingContext2D.prototype, 'measureText').mockImplementation(function (text) { + expect(this.font).toBe('bold 15px Arial'); + return { + width: Array.from(text).reduce((width, char) => width + (char.charCodeAt(0) > 127 ? 16 : 8), 0), + fontBoundingBoxAscent: 14, + fontBoundingBoxDescent: 3 + }; }); + try { + const measureTextWidth = listTable.measureText("家里方大化工撒个福建师大看哈 fdsfgj! *-+&5%#.,'.,。、", { + fontFamily: 'Arial', + fontSize: 15, + fontWeight: 'bold' + }); + expect(measure).toHaveBeenCalled(); + expect(measureTextWidth).toEqual({ + fontBoundingBoxAscent: 14, + fontBoundingBoxDescent: 3, + width: 432, + height: 15 + }); + } finally { + measure.mockRestore(); + setCustomAlphabetCharSet(''); + } }); // test('listTable API getAllCells', () => { // expect(JSON.parse(JSON.stringify(listTable.getCellInfo(5, 5)))).toEqual({ diff --git a/packages/vtable/__tests__/select-range-components.test.ts b/packages/vtable/__tests__/select-range-components.test.ts new file mode 100644 index 0000000000..18478dd2ca --- /dev/null +++ b/packages/vtable/__tests__/select-range-components.test.ts @@ -0,0 +1,41 @@ +import { createGroup, createRect } from '../src/vrender'; +import { moveSelectingRangeComponentsToSelectedRangeComponents } from '../src/scenegraph/select/move-select-border'; +import type { Scenegraph } from '../src/scenegraph/scenegraph'; + +describe('selection range graphics', () => { + test('replacing the same range removes old graphics and keeps the newly selected range', () => { + const group = createGroup({}); + const previous = { + rect: createRect({ x: 0, y: 0, width: 100, height: 25 }), + fillhandle: createRect({ x: 97, y: 22, width: 6, height: 6 }) + }; + const current = { + rect: createRect({ x: 100, y: 0, width: 100, height: 25 }), + fillhandle: createRect({ x: 197, y: 22, width: 6, height: 6 }) + }; + const untouched = { rect: createRect({ x: 0, y: 25, width: 100, height: 25 }) }; + for (const graphic of [previous.rect, previous.fillhandle, current.rect, current.fillhandle, untouched.rect]) { + group.add(graphic); + } + const scene = { + selectedRangeComponents: new Map([ + ['same-range', previous], + ['other-range', untouched] + ]), + selectingRangeComponents: new Map([['same-range', current]]), + updateNextFrame: jest.fn() + }; + + // 同一 key 替换后,画布只能保留新选框/选择柄和未参与替换的选区。 + moveSelectingRangeComponentsToSelectedRangeComponents(scene as unknown as Scenegraph); + const children = group.getChildren(); + expect(children.length).toBe(3); + expect(children.includes(previous.rect)).toBe(false); + expect(children.includes(previous.fillhandle)).toBe(false); + expect(children.includes(current.rect)).toBe(true); + expect(children.includes(current.fillhandle)).toBe(true); + expect(children.includes(untouched.rect)).toBe(true); + expect(scene.selectedRangeComponents.get('same-range')).toBe(current); + expect(scene.selectingRangeComponents.size).toBe(0); + }); +}); diff --git a/packages/vtable/__tests__/visual/README.md b/packages/vtable/__tests__/visual/README.md index 7bd5901701..1bb50f9f0f 100644 --- a/packages/vtable/__tests__/visual/README.md +++ b/packages/vtable/__tests__/visual/README.md @@ -4,7 +4,7 @@ ## 准备 -在仓库根目录使用 Node.js 22,安装 Rush 依赖和锁文件对应的 Chromium: +在仓库根目录使用 Node.js 22。VTable 与 VChart 统一固定 Playwright 1.63.0,安装 Rush 依赖和锁文件对应的 Chromium;两仓继续使用各自的执行配置与基线: ```sh node common/scripts/install-run-rush.js install --ignore-hooks @@ -32,6 +32,72 @@ 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 +``` + +## 冻结候选与指定只读基准 + +同一用例还可以验证冻结构建并与平台提供的 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} + ] +} +``` + +此示例只声明一个首次基准项。请求目标 `identity.baseRef` 必须为 `develop`;`inputArtifacts` 必须包含该 artifactId、清单**原始文件字节**的 SHA-256、`mediaType=application/json` 和 `visibility=public|restricted`。可见性元信息不赋予执行器服务权限。环境 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 前运行默认全量比较并检查差异。新功能或缺陷修复可以加独立用例。不得用放宽截图阈值、跳过语义断言或接受差异代替修复不稳定用例。工具自检: @@ -39,3 +105,12 @@ node packages/vtable/scripts/visual-test.mjs --baseline <完整的40位commit-sh ```sh node --test packages/vtable/scripts/visual-test.test.mjs ``` + + +## 本地比较的机器输出 + +`node packages/vtable/scripts/visual-test.mjs --json` 保留默认全量和官方 develop 比较;也可附带现有 `--case`、`--dir`、`--baseline` 或 `--self-compare`。此选项仅用于本地比较,不与列举、单独预检、套件导出或冻结候选模式混用。 + +stdout 只有一个 `schemaVersion=1`、`kind=local_visual_result` 对象,进度写入 stderr。对象保留真实 `exitCode`、`status`、`complete`、精确 `selection`、结果 `counts`、源码 `source.before/after`、实际 `baseline`、环境和三份报告绝对路径。退出码仍为通过 `0`、差异 `1`、错误 `2`;准备失败时未取得的范围、源码或基线为 null,不推断成功。源码摘要覆盖 HEAD、未提交和未跟踪源码,检查完成并清理后再次核对;发生变化记为 `WORKSPACE_CHANGED`、退出 `2`。目录或单例完成仅表示所选用例完成,`--self-compare` 只表示本地稳定性,均不代表官方全量、CI 或合入资格。 + +依赖及浏览器继续由原生预检负责,不自动安装或修复。报告只位于本次返回的运行目录;取消或清理失败保留错误证据。机器契约专项:`node --test packages/vtable/scripts/visual-json.test.mjs`。可安装的公共 `visactor-flow check` 复用此入口,不另行实现截图比较。 diff --git a/packages/vtable/__tests__/visual/cases/README.md b/packages/vtable/__tests__/visual/cases/README.md index 1b2d86d10f..9ca8f0530b 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/cases/pivot-chart/brush-multiple-selection.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs index 6e075d03a9..e9f27cd1c2 100644 --- a/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs @@ -14,7 +14,7 @@ export default { const chartSpec = (id, field) => ({ type: 'bar', direction: 'vertical', data: { id }, xField: 'category', yField: field, - brush: { visible: true, brushType: 'x', brushMode: 'multiple', + brush: { visible: true, brushType: 'x', brushMode: 'multiple', delayTime: 0, inBrush: { colorAlpha: 1 }, outOfBrush: { colorAlpha: 0.2 } }, animation: false }); @@ -35,11 +35,17 @@ export default { defaultHeaderRowHeight: 30, defaultHeaderColWidth: 80, chartDimensionLinkage: {} }); - table.onVChartEvent('brushEnd', () => { window.__brushEndCount++; }); + table.onVChartEvent('brushEnd', event => { + window.__brushEndCount++; + window.__brushSelection = { + inside: event.value.inBrushData.map(row => row.category), + outside: event.value.outOfBrushData.map(row => row.category) + }; + }); return table; }, async exercise(page) { - // 在首个可见图表单元格的绘图区水平拖动创建框选。 + // 根据 B 柱的实际位置框选,避免固定坐标把所有类别一起选中。 const segment = await page.evaluate(() => { const table = window.__visualTable; for (let row = 0; row < table.rowCount; row++) @@ -47,25 +53,50 @@ export default { if (table.getCellType(col, row) === 'chart') { const b = table.getCellRect(col, row).bounds; const host = document.getElementById('table').getBoundingClientRect(); - return { start: { x: host.x + b.x1 + 100, - y: host.y + (b.y1 + b.y2) / 2 }, - end: { x: host.x + b.x1 + 360, - y: host.y + (b.y1 + b.y2) / 2 } }; + const position = table.getChartDatumPosition( + { group: 'All', bucket: 'One', category: 'B', amount: 16 }, + table.getCellHeaderPaths(col, row)); + if (!position) throw new Error('无法定位 B 柱'); + const y = host.y + (b.y1 + b.y2) / 2; + return { start: { x: host.x + position.x - 20, y }, + end: { x: host.x + position.x + 20, y } }; } throw new Error('框选图表单元格缺失'); }); await page.mouse.move(segment.start.x - 15, segment.start.y); await page.mouse.move(segment.start.x, segment.start.y, { steps: 5 }); - await page.waitForTimeout(150); + // 激活图表后先完成一次表格绘制,避免首次 viewBox 同步在框选后清掉选框。 + await page.evaluate(() => new Promise(resolve => { + const table = window.__visualTable; + const listener = table.on('after_render', () => { + table.off(listener); + resolve(); + }); + table.renderAsync(); + })); await page.mouse.down(); await page.mouse.move(segment.end.x, segment.end.y, { steps: 12 }); await page.mouse.up(); - await page.waitForTimeout(150); + await page.waitForFunction(() => window.__brushEndCount > 0); + // 框选状态会刷新图表缓存;等待表格实际绘制,截图不依赖异步帧的先后顺序。 + await page.evaluate(() => new Promise(resolve => { + const table = window.__visualTable; + const listener = table.on('after_render', () => { + table.off(listener); + resolve(); + }); + table.renderAsync(); + })); + // 活动图表和表格共用画布;表格完成后绘制已存在的选框,不修改选中状态。 + await page.evaluate(() => window.__visualTable.scenegraph.brushingChartInstance.getStage().render()); }, async verify(page) { - // 至少一个图表收到 brushEnd,证明录制的拖动进入了 VChart 框选路径。 + // 结束事件与真实选中/未选中数据同时成立,跳过拖动不能通过。 await page.evaluate(() => { if (window.__brushEndCount < 1) throw new Error('透视图未触发框选结束事件'); + const selected = window.__brushSelection; + if (!selected.inside.includes('B') || !selected.outside.includes('C')) + throw new Error(`透视图框选数据状态错误:${JSON.stringify(selected)}`); }); } }; diff --git a/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs b/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs index f39e1a3f42..2b93c9d4ec 100644 --- a/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs +++ b/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs @@ -44,12 +44,35 @@ export default { await page.mouse.move(points.end.x, points.end.y, { steps: 10 }); await page.mouse.up(); await page.locator('.vtable__menu-element__item').filter({ hasText: '复制填充' }).click(); + await page.waitForFunction(() => window.__visualTable.getActiveSheet().tableInstance.getCellValue(4, 4) === 11); + // 填充结束后点击目标格,截图固定为用户可达的单格选中态,排除拖动选择柄的瞬态。 + const target = await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + const bounds = table.scenegraph.getCell(4, 4).globalAABBBounds; + const canvas = table.canvas.getBoundingClientRect(); + return { x: canvas.x + (bounds.x1 + bounds.x2) / 2, + y: canvas.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.click(target.x, target.y); + // 等待表格实际绘制完成;renderAsync 的零延迟 Promise 不代表渲染已结束。 + await page.evaluate(() => new Promise(resolve => { + const table = window.__visualTable.getActiveSheet().tableInstance; + const listener = table.on('after_render', () => { + table.off(listener); + resolve(); + }); + table.renderAsync(); + })); }, async verify(page) { // 当前复制填充行为将源格显示值 11 写到相邻单元格,源格仍为 11。 await page.evaluate(() => { const sheet = window.__visualTable; const table = sheet.getActiveSheet().tableInstance; + const ranges = table.getSelectedCellRanges(); + if (ranges.length !== 1 || ranges[0].start.col !== 4 || ranges[0].start.row !== 4 || + ranges[0].end.col !== 4 || ranges[0].end.row !== 4) + throw new Error(`填充后的目标格未选中:${JSON.stringify(ranges)}`); if (window.__fillSourceValue !== 11 || table.getCellValue(3, 4) !== 11 || table.getCellValue(4, 4) !== 11) throw new Error(`公式自动填充结果错误:${table.getCellValue(3, 4)}, ${table.getCellValue(4, 4)}`); diff --git a/packages/vtable/__tests__/visual/playwright.config.mjs b/packages/vtable/__tests__/visual/playwright.config.mjs index 17caa4b118..ab90ca2a5d 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 b485945b20..f404f7d7a1 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('')}