fix: 补齐候选诊断环境与证据保护(09-06-release-gate-integrity)
这个提交包含在:
@@ -798,6 +798,117 @@ drop_owned_database_or_fail
|
||||
|
||||
## Scenario: Repository Local Candidate Gate
|
||||
|
||||
The independent all-spec owner is `bash scripts/qa/run-web-gates.sh full-local`,
|
||||
not this route-manifest subset. Full-local prepares temporary PWA A/B production
|
||||
builds before Playwright collection using the shared PWA fixture helper; all
|
||||
non-manual spec/test files under the current test directory remain assigned to
|
||||
full-local, including dedicated-input specs and filenames containing `live`.
|
||||
The existing local/live runtime mode restrictions remain intact; ownership is
|
||||
not authorization to use live accounts or contact production.
|
||||
|
||||
`verify-full-local-discovery.mjs` compares the actual collection with source
|
||||
files rather than trusting the report or `acceptanceSpecs` as its expected set.
|
||||
Missing/unknown files, collection errors or files without cases fail before DB
|
||||
preparation. `FULL_LOCAL_DISCOVERY_ONLY` proves enumeration, not execution or
|
||||
release eligibility. A later fail-fast stage must leave browser cases marked
|
||||
not run, never passed.
|
||||
|
||||
`bash scripts/qa/run-web-gates.sh full-local-diagnostic` is a separately
|
||||
approved local inventory, never a release gate. Require non-empty
|
||||
`QA_DIAGNOSTIC_REASON`, `QA_DIAGNOSTIC_OPERATOR` and
|
||||
`QA_DIAGNOSTIC_APPROVAL_REF`; preserve them in its dedicated, non-overwritten
|
||||
report directory. It always finishes PARTIAL with exit 2, even if every
|
||||
executed test passes. Input/runner errors and interruption remain nonzero.
|
||||
The normal full-local entry retains fail-fast behavior; it never falls back
|
||||
to diagnostics. No boolean environment marker grants release eligibility.
|
||||
|
||||
Both full-local entries own the Linux `isolated-online-v1` environment through
|
||||
`full-local-environment.mjs`: a fresh network namespace, an up internal veth
|
||||
pair without an external/default route, and an owned offline PostgreSQL container
|
||||
connected only through its Unix socket and loopback proxy. No host fallback,
|
||||
image pull, arbitrary outer wrapper or boolean online assertion is accepted.
|
||||
Unsupported capabilities are environment failures, not business test failures.
|
||||
Reports separate inherited, runner-created and browser namespace fingerprints;
|
||||
live ownership/topology and current policy digests are revalidated between stages
|
||||
and at completion. The native blank-page probe must observe actual online=true.
|
||||
Before business browser tests, a fresh unmocked context opens the real candidate
|
||||
login page: listeners precede navigation, cookies/storage are empty, the form
|
||||
appears, no refresh or login request occurs, and native online samples are true.
|
||||
Failed/401 refresh attempts still count. Form visibility alone cannot prove the
|
||||
guest branch: terminal refresh errors also render it. Samples remain independent
|
||||
observations, never an internal AuthContext measurement; missing and false differ.
|
||||
Legitimate per-context offline scenarios remain untouched. Environment failure
|
||||
stops the diagnostic's remaining branches; successful diagnostics remain PARTIAL/2.
|
||||
The requested report base contains `environment/` and `results/`; preserve both,
|
||||
including traces and source snapshots. Local receipts are auditable evidence,
|
||||
not cryptographic authenticity guarantees; third-party reproduction stays required.
|
||||
R1 consumption/activation remains separately authorized, not enabled by this helper.
|
||||
|
||||
Candidate snapshots retain two independent scopes: the existing selected-source
|
||||
digest and a repository snapshot containing complete porcelain-v1 NUL-delimited
|
||||
status, readable status, every tracked worktree file's kind/mode/content digest,
|
||||
and index stage entries/flags/digest. Symlink content is the link target string,
|
||||
never the target file; unsupported directory/ancestor-link coverage is unqualified.
|
||||
Capture the final snapshot on failure/cleanup paths as well as success.
|
||||
`sourceUnchanged` does not imply `repositoryUnchanged`; unchanged does not imply
|
||||
`cleanAtStart` or `cleanAtEnd`. A dirty-to-dirty overwrite must remain detectable.
|
||||
Untracked paths are retained in status; their contents outside the selected
|
||||
source set are not covered by the tracked-file digest. Do not call a dirty
|
||||
diagnostic a byte-for-byte freeze of all untracked materials. Future clean
|
||||
release candidates cannot contain these untracked paths in the first place.
|
||||
Environment qualification stays separate from candidate integrity: a modified
|
||||
tracked screenshot does not retroactively falsify native-online observations,
|
||||
but it cannot qualify a release. Ordinary runs fail on candidate mutation;
|
||||
diagnostics retain PARTIAL/2 and explicit CHANGED/UNKNOWN evidence. R1 activation
|
||||
and its required clean candidate remain separately authorized.
|
||||
|
||||
Discovery and full-browser commands supply `QA_VISUAL_OUTPUT_DIR` beneath the
|
||||
run's report directory. Screenshot-only specs use unique per-spec subdirectories,
|
||||
including standalone defaults; they must not write into registered historical
|
||||
evidence paths. `.gitignore` never exempts already-tracked output from these checks.
|
||||
|
||||
Diagnostic stages run in separate strict shell processes. Failed assertions
|
||||
do not block independent suites; failed preparation blocks dependent work
|
||||
with explicit NOT_RUN reasons. Every write-suite and the browser receive
|
||||
a newly created, uniquely named local database and separate storage/server;
|
||||
only owned resources may be removed, with cleanup failures recorded.
|
||||
Keep PWA inputs shared for this run, preserve workers and zero retries, and
|
||||
retain original per-case outcomes (including serial skips and missing results).
|
||||
The diagnostic browser stage must explicitly pass `--trace=on`; the shared
|
||||
browser helper forwards only its caller-supplied internal arguments. Zero
|
||||
retries combined with `on-first-retry` yields no trace, so a retry-dependent
|
||||
default is not diagnostic evidence coverage. Preserve trace archives in the
|
||||
existing per-run output directory together with screenshots and result JSON.
|
||||
This does not add a public scope/worker override or change ordinary full-local's
|
||||
trace policy. A future ordinary-gate `retain-on-failure` change requires its own
|
||||
review. Captured synthetic traces are local debugging artifacts, not files to
|
||||
publish or commit without a separate sensitive-content review.
|
||||
The nested regression-all invocation retains its default fail-fast: do not
|
||||
pass its `--json-report` option, which continues mutating suites on the same
|
||||
database after failure. Its repeated workflow-actions is a second source of
|
||||
the same finding, not a second defect; subsequent suites remain NOT_RUN.
|
||||
A stage PASS is not proof every subcase ran, including disabled modules.
|
||||
Use neutral temporary-root names that do not trigger production-identity
|
||||
guards in synthetic fixtures; verify with the actual guard, never relax it.
|
||||
Never disable an inner suite's fail-fast to claim complete coverage. The
|
||||
diagnostic duration is not a measurement of the normal all-green release path.
|
||||
|
||||
Full-local explicitly passes `--retries=0` regardless of CI's configured retry
|
||||
default. This is a release coverage requirement independent of PWA. Keep the
|
||||
configured file-level worker count; PWA owns an isolated origin/context and
|
||||
declares serial execution internally. No silent exclusion or global serial
|
||||
fallback is permitted. If a future split needs exceptions, each requires a
|
||||
reason, mandatory runner, owner and evidence obligation before approval.
|
||||
|
||||
`QA_FULL_REPORT_DIR` holds persistent build logs, collection JSON and source
|
||||
coverage, then full browser JSON/HTML/results when that stage is reached.
|
||||
Cleanup only reclaims the current run's temporary PWA builds and owned DB/server;
|
||||
it must preserve these reports and not overwrite `client/dist` for A/B fixtures.
|
||||
Validate the shared standalone/full-local paths with
|
||||
`node --test scripts/qa/tests/pwa-full-local-inputs.test.mjs scripts/qa/tests/verify-full-local-discovery.test.mjs`,
|
||||
including CI zero retries, no worker override, failed/missing A/B inputs,
|
||||
omitted PWA collection, browser failure and cleanup boundaries.
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: declaring a checkout ready for internal deployment planning without
|
||||
|
||||
@@ -16,6 +16,56 @@ Start from `docs/文档索引.md` and `docs/文档事实源状态矩阵.md`.
|
||||
raw payloads, full account identifiers, invoice originals or confidential
|
||||
business content.
|
||||
|
||||
## Scenario: Registered Local Evidence Integrity
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
Protect the 14 historical screenshots registered in the archived 07-18
|
||||
small-team internal-readiness evidence table. Not being an automated snapshot
|
||||
baseline does not make human-reviewed, digest-registered evidence disposable.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
`node scripts/qa/check-trellis-governance.mjs` calls `validateRegisteredEvidence`.
|
||||
The code-owned registry supplies document, artifact directory and expected count;
|
||||
the document's `截图 | SHA-256` table supplies filenames and approved hashes.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
Validate regular local files without following symlink artifacts or ancestor
|
||||
redirects. Preserve original documents/hashes. Before restoring an accidentally
|
||||
overwritten artifact, preserve the new runtime copy and its digest separately.
|
||||
Do not remove registered evidence from the index merely because tests never read it.
|
||||
Historical private restore-runner candidate hashes without a public repository
|
||||
file mapping are not local-file checks; do not fetch private/production inputs
|
||||
or claim universal verification of every SHA literal in Trellis documents.
|
||||
|
||||
### 4. Validation / Error Matrix
|
||||
|
||||
Missing document/table/file, changed digest, invalid row/path, duplicate entry,
|
||||
symlink or reduced registered count is a governance error. A matching table
|
||||
reports the exact number verified; no silent table-shrink acceptance is allowed.
|
||||
|
||||
### 5. Good / Base / Bad
|
||||
|
||||
Good: verify 14/14 archived hashes while new screenshots go to isolated run roots.
|
||||
Base: local governance verifies registered public artifacts, with scope explicit.
|
||||
Bad: ignore all output paths, silently replace archived hashes, or call unverifiable
|
||||
private historical candidates locally verified.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
`node --test scripts/qa/tests/registered-evidence.test.mjs` covers intact evidence,
|
||||
overwrites, missing files/tables/rows, duplicate rows, unsafe paths and symlinks.
|
||||
Visual path and full-repository snapshot tests separately prevent the producer
|
||||
from overwriting history and detect tracked mutations even when already dirty.
|
||||
|
||||
### 7. Wrong / Correct
|
||||
|
||||
Wrong: selected source digest unchanged, therefore the worktree is unchanged.
|
||||
Correct: report selected-source and repository/index facts separately; tracked
|
||||
output remains subject to the same whole-repository integrity requirements.
|
||||
|
||||
## Scenario: Finding Lifecycle And Audit Closeout
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -262,9 +262,12 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Runner: `bash scripts/qa/run-pwa-cross-version-upgrade.sh`.
|
||||
- Standalone runner: `bash scripts/qa/run-pwa-cross-version-upgrade.sh`.
|
||||
- Full-suite owner: `bash scripts/qa/run-web-gates.sh full-local`; both runners use `scripts/qa/pwa-cross-version-fixture.sh` to prepare the same temporary A/B inputs. Missing inputs remain a collection error, never a skip or implicit exclusion.
|
||||
- Build inputs: two distinct 12-character synthetic `VITE_BUILD_SHA` values, written to temporary outDirs rather than `client/dist`.
|
||||
- Browser contract: `serviceWorkers: "allow"`, one loopback HTTP origin, one BrowserContext, single worker and zero retries.
|
||||
- Screenshot artifacts must not overwrite registered historical evidence. The internal-readiness and finance-wave4 specs use `QA_VISUAL_OUTPUT_DIR` as a root and create unique per-spec directories with `mkdtempSync`; standalone defaults also use fresh ignored directories, never the historical 07-18 directory. These are output-path changes, not authentication or assertion changes. Both discovery and full-browser runners supply the current report's visual root.
|
||||
- Full-local's global environment is runner-owned and natively online without public egress. Its separate unmocked fresh-context login probe requires a visible form, zero refresh requests and independently recorded native online samples. A visible form after refresh 401 is not equivalent evidence. Do not instrument AuthContext or override navigator.onLine for this check; explicit offline business contexts remain legitimate. Environment probes are not additional business cases, and invalid environment observations cannot be promoted into a product failure inventory.
|
||||
- Browser contract: `serviceWorkers: "allow"`, one dedicated random-port loopback HTTP origin and one BrowserContext; the spec declares serial execution. The standalone runner keeps one worker; full-local retains its configured file-level concurrency and explicitly passes zero retries even under CI. Do not serialize the whole suite without evidence of interference and separate approval.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -293,7 +296,7 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Run `bash scripts/qa/run-pwa-cross-version-upgrade.sh`; it must use temporary A/B production builds, loopback-only networking, single worker and zero retries.
|
||||
- Run `bash scripts/qa/run-pwa-cross-version-upgrade.sh`; it must use temporary A/B production builds, loopback-only networking, single worker and zero retries. Full-local must supply the same inputs before collection and include this spec in its all-non-manual suite; its file-level worker count is not reduced for PWA.
|
||||
- A ledger assertions: document SHA A, active controller, fetched A worker fingerprint match, Workbox precache and cached A-only asset count greater than zero.
|
||||
- B ledger assertions: controller change, active B controller, fetched B worker fingerprint match, document SHA B, B-only loaded asset count greater than zero and A-only cache count zero.
|
||||
- Negative assertions: zero login fallback, static 404, page/chunk/preload error and prohibited B-stage test actions; route and synthetic refresh/session remain valid.
|
||||
|
||||
@@ -934,6 +934,9 @@ extra = {
|
||||
- Manual item amount `<= 0` -> `400 VALID_001 / amount=must_be_positive`.
|
||||
- Submit with zero items -> `400 VALID_001 / items=at_least_one_required`.
|
||||
- Submit with any item amount `<= 0` -> `400 VALID_001 / items=positive_amount_required` plus affected item IDs.
|
||||
- Test this rejection in the real isolated-DB `items_positive_amount_required` workflow regression: persist a negative item, verify its amount and the item sum, require the precise diagnosis and exact item IDs, and prove no workflow instance is created. Do not inject only the stored header total or accept an arbitrary 400.
|
||||
- `ensureNonNegativeAmount` is still called after item validation and recomputation. With the current stable, same-set, finite positive items, its negative-total rejection branch is unreachable on this submit path; this is not an outstanding end-to-end coverage debt. Keep the defense for future paths or inconsistent inputs, and require new write paths to preserve it rather than bypass earlier guards to make a test reach it.
|
||||
- Direct unit coverage through the existing route `__testables` export must prove negative totals -> `total_amount=negative_not_allowed`, non-finite totals -> `total_amount=invalid`, both `400 VALID_001` with their exact reason codes; finite nonnegative totals remain accepted. The real-DB item test and this defensive-function test prove different layers and must not share a misleading total-guard scenario name.
|
||||
- Expense date older than policy or in another calendar year -> precheck `decision=warn`; submission remains possible after confirmation.
|
||||
- Asset candidate without asset permission -> show manual next step only, never the asset-module link or asset data.
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# 部署门禁完整性设计草案
|
||||
|
||||
## 11. 归档截图保护与全仓回执增量(已批准)
|
||||
|
||||
- 先以独立副本和 SHA 保全四张本次运行截图,再仅恢复这四个 worktree 文件;保留全部 14 个已跟踪历史证据及其原登记摘要,不改 index。
|
||||
- 两个 spec 只变更 fs 导入及截图目录定义。QA_VISUAL_OUTPUT_DIR 是输出根;使用 spec 前缀和 mkdtemp 创建独立目录,未传变量时也只写新的 ignored output 子目录。受管发现/浏览器命令传本轮报告内 visual 根,不依赖外层调用者自觉,不改业务断言/认证。
|
||||
- sourceSnapshot 保留限定目录 digest,另记录 repository 的完整 porcelain-v1-z、人类可读 status、全仓 tracked 文件内容/类型/模式(symlink 不跟随)、完整 stage entries/flags 及 indexDigest。删除、内容变化、同为 dirty=true 时的二次改写和 index-only 变化均可见。忽略运行产物不是为已跟踪 output 路径豁免。
|
||||
- sourceUnchanged 与 repositoryUnchanged、cleanAtStart/cleanAtEnd 分列;诊断前后及失败收尾均采样。环境有效与候选被改写不混淆;普通模式遇候选改写非零,诊断固定 PARTIAL/2。尚未启用的 R1 以后必须消费干净状态,不能用 unchanged 代替 clean。
|
||||
- 治理校验显式登记归档 evidence.md 的截图表/根目录/14 条预期数量;解析表格并验证普通文件及摘要,表格丢失、缺行、重复、缺文件、路径逃逸、symlink 或摘要变化均 error。恢复演练文档记录的是历史私有候选值,不具备仓库路径到摘要的可复核表;本轮不取私有数据、不冒充 17 项全验。
|
||||
- 针对性测试覆盖路径默认/覆盖/重复加载隔离,已跟踪 PNG 改写、既有 dirty 再改、index-only 变化,以及证据篡改/缺失/表格缩水。只运行这些验证和 typecheck/治理,不重跑 67 分钟诊断。
|
||||
|
||||
最新授权(2026-09-07):用户批准 §10 实施及一次诊断,bootstrapObservation 改为外部黑盒观测(表单可见、零 refresh、独立原生在线采样),不修改 AuthContext、不安装 spec route mock。旧 207 清单的决策资格撤销,原件保留;环境失败不能按业务断言失败继续。R1、阶段 B、31+2 迁移不自动启动。
|
||||
|
||||
## 1. 复用范围与状态含义
|
||||
|
||||
本文入口强制消费为获批生效后的目标,不是当前已启用事实。阶段 0 先运行原始 full-local 摸清红项;结果交用户决定强制点生效时机。机制交付、R1 启用、冻结候选重新生成回执、首次真实部署是不同步骤;后三者不属于本 task 自动完成动作。启用须全套验证已绿且明确获批,不设计可日常置位的 disable/skip 总开关。具体切换补丁边界在基线审阅后确定,当前不修改任何真实入口。
|
||||
|
||||
复用 `run-release-gate.sh` 中已经存在的 static → full-local → deploy 顺序,不照抄成另一套规则。把发布前两阶段整理为同一条可单独调用、绝不部署的验证路径,供 ci-release 与真实 bootstrap 路径共用。
|
||||
|
||||
| 入口/profile | 证明范围 | 可直接作为普通发布资格 |
|
||||
| --- | --- | --- |
|
||||
| release-predeploy | 原 ci-release static + full-local 均真实完成,且受管结果完整、来源一致 | 是,仅发布前资格,不代表已部署 |
|
||||
| local-candidate full | 保留完整基础层、witness/acceptance 判定及 LOCAL_CANDIDATE_PASS | 否,不能冒充 release-predeploy |
|
||||
| local-candidate focused | 保留 FOCUSED_BROWSER_PASS,不含 full 基础层 | 否 |
|
||||
| local-candidate list | 仅枚举 LIST_ONLY | 否 |
|
||||
| 直接 Playwright 的筛选执行 | 仅所选用例的诊断结果 | 否 |
|
||||
| project-wide 获批跳过 | 明确 PARTIAL、未覆盖阶段与批准引用 | 否 |
|
||||
|
||||
当前 full-local 的 Playwright 会发现配置下的全部非 manual 测试;同一次报告可供受管集合验证使用,避免重复执行完整浏览器。不能把对子集的验证称为 run-local-candidate full 已执行,也不能只抽取通过的结果后忽略全套的失败退出码。
|
||||
|
||||
## 2. 入口收敛,避免递归
|
||||
|
||||
拟新增 `run-release-gate.sh verify-candidate` 作为验证专用模式,内部复用原 stage_static / stage_full_local;此名称是新子命令,不是新门禁实现。
|
||||
|
||||
```text
|
||||
ci-release / all
|
||||
-> 同一发布前验证阶段
|
||||
-> 获得并校验 release-predeploy 回执
|
||||
-> 既有 production-identity-bootstrap
|
||||
-> deploy-existing -> deploy.sh
|
||||
|
||||
production-identity-bootstrap / deploy-existing 直接调用
|
||||
-> 无回执时只调用 verify-candidate,不反调 ci-release
|
||||
-> 同一回执验证
|
||||
-> 保留既有身份、来源、迁移、回滚窗口及部署前后置检查
|
||||
-> deploy.sh
|
||||
|
||||
run-release-gate deploy / 底层 deploy.sh 直接调用
|
||||
-> 必须消费同一验证器确认的回执
|
||||
-> 无效则在发布写入前拒绝,不以内部调用标记免检
|
||||
```
|
||||
|
||||
- ci-release/all 的发布分支使用保留生产前置的入口,不回退成绕过 deploy-existing 的裸部署;直接底层调用至少不能绕过新增的候选验证。
|
||||
- 高层成功验证后传递的是回执引用,不是 `GATE_PASSED=1` 等可直接置位的信任标记。下层仍验证来源、profile、覆盖、结果和适用输入,不重复跑整套测试。
|
||||
- `PRODUCTION_READ_ONLY_PREFLIGHT=1` 仍只做原有只读检查并明确退出,不生成 release-predeploy 回执,也不赋予后续写入资格;不能被当作“跳过测试但继续发布”。
|
||||
- 普通发布指向 existing 部署;initialize 的独立批准和数据模式约束保持。即使直接调用底层初始化路径,也不能通过缺少回执逃掉候选检查。本 task 不运行 initialize。
|
||||
- 门禁不合格时允许必要的只读诊断,但远端 rsync、build/up、init/migrate/seed 等写动作必须为 0。既有 `--no-deps`、健康等待及禁止自愈不变。
|
||||
|
||||
## 3. 最小共享回执模块
|
||||
|
||||
拟增加一个共享候选回执模块及其单测,供发布 runner、local-candidate runner 和底层发布入口使用;不增加数据库表、密钥、外部服务或第三套执行器。
|
||||
|
||||
| 字段组 | 内容与约束 |
|
||||
| --- | --- |
|
||||
| 身份 | schemaVersion、runId、profile、候选完整 commit/tree、工作树前后状态 |
|
||||
| 校验输入 | 门禁策略、manifest、Playwright 配置、相关锁文件摘要和实际 Node/Playwright 版本;绑定当前候选重新核验 |
|
||||
| 时间 | startedAt、finishedAt 与各阶段实际观测时间;未结束必须是 INCOMPLETE,不能补造成功时间 |
|
||||
| 选择 | 实际命令类别及安全参数:模式、grep、grep-invert、project、shard、only/手工模式、retries 等;不保存含凭据的原始命令行 |
|
||||
| 清单 | expectedSpecs/observedSpecs,以及带项目身份的 expectedTestIds/observedTestIds;仓库相对路径规范化、去重并拒绝越界来源 |
|
||||
| 结果 | 每阶段退出码、每用例预期/实际状态及 attempts;成功、失败、skip、错误和缺失分别表示,不仅保存汇总数 |
|
||||
| 例外 | 受影响阶段、非空理由、操作人别名、明确批准引用、真实可知的批准时间与观测时间;未知批准时间留空,不用当前时间补造 |
|
||||
| 资格 | PASS/FAIL/PARTIAL/LIST_ONLY/INCOMPLETE、deploymentEligible 与稳定拒绝原因;必须由验证器重新推导,不能直接信任布尔字段 |
|
||||
|
||||
清单采集遵循以下顺序:
|
||||
|
||||
1. 冻结候选来源与所用工具链;开发脏树允许产出诊断记录,但 deploymentEligible 永远为 false。
|
||||
2. 在同一候选、同一配置与规范执行参数下枚举预期清单;枚举不是行为通过。标准发布无 grep/shard/only 等缩减范围,禁止 focused-only 声明。
|
||||
3. 实际执行 static/full-local;接收真实 Playwright 结构化结果,记录实际 spec 与用例,并检查报告完整性、全套命令退出码和 runner 错误。
|
||||
4. 受管 witness 与 acceptance 必须完整发现、实际执行并通过;集合必须从当前候选定义生成,不从待验证回执自报的 expected 字段取信任根。同一 spec 中遗漏合同用例也必须被拦。
|
||||
5. 继续复用既有 witness 的来源绑定、重复/未知标签等判定。full-local 全套报告不能只剩绿色子集;全套命令失败仍失败。受管子集零 failed/skipped,其他测试的状态仍完整记录,不通过删结果让报告变绿。
|
||||
6. 标准发布明确禁用重试至绿,使用零重试和 forbid-only;required 测试不能靠 expected-failure 获得通过资格。历史诊断若有多次 attempts,如实保留,不能改写为一次成功。
|
||||
7. 结束后和写入前重新验证 commit/tree、干净工作树与校验输入;不匹配、缺少结束标志或回执损坏均拒绝。
|
||||
|
||||
local-candidate 原验证器对其选定集合的严格行为保持;共享解析或复用辅助函数不能把它改为接受失败/跳过。本轮不削减 acceptanceSpecs、不删除旧门禁,只恢复错误用例和增补证明链。
|
||||
|
||||
## 4. 持久化和故障处理
|
||||
|
||||
- 每次运行有独立目录,先写 INCOMPLETE,再原子完成脱敏汇总;成功与失败都留存,写回执失败本身不具备发布资格。
|
||||
- 持久汇总放既有 ignored artifacts 体系;它是本地运行证明,不提交到产品版本。发布日志记录 runId、候选短 SHA、profile、结果及相对证据引用。
|
||||
- cleanup 可以回收本次独占服务和临时文件,但不能删掉唯一结果。SIGINT/阶段错误/进程被强制终止时,有效结果只能是失败或缺失/未结束,不会被默认补成 PASS。
|
||||
- 多次执行、失败后再跑和其他 profile 的记录各自保留,不覆盖、拼接或给旧结果重新命名。相同候选的有效回执可由同一发布链引用,不以时间戳或“上次绿”替代内容匹配。
|
||||
- 不记录完整本地路径、生产连接、原始业务响应或敏感环境;测试结果中的路径归一化,用例标识不嵌入真实人员/合同内容。
|
||||
- 这是防止意外复用、范围误读和无记录绕行的本地审计合同,不是防伪完整性保证。执行端本身有写文件能力,一份内部自洽的手写回执仍可能通过;独立推导集合及重验来源不能消除这一威胁。本轮不引入签名服务或秘密,也不把可审计说成不可绕过。
|
||||
- 保存足够的候选、工具链、环境前提、完整命令及逐项集合,使第三方可重跑复现。首次真实部署前由用户或独立方重跑并与回执逐项比对;原主会话的重复自检不抵扣。重跑差异如实阻断,不拼接两个报告成为 PASS。
|
||||
|
||||
## 5. project-wide skip 的拟定边界
|
||||
|
||||
仅处理现有两个开关,不顺带审计和重写所有业务 flag:
|
||||
|
||||
- PROJECT_WIDE_SKIP_BUSINESS_FLOW
|
||||
- PROJECT_WIDE_SKIP_VISUAL
|
||||
|
||||
开关值须精确合法;缺理由、受影响阶段或批准引用时,在跳过任何阶段前失败。获批诊断必须落地包含上述信息的 PARTIAL 回执,列出未运行 spec/阶段,并以非零结束,不经过原来的完整 PASS 输出。
|
||||
|
||||
普通发布 profile 不接受这两个开关或诊断回执;也不新增一个更高层的万能 skip。批准引用的结构验证只证明记录齐全,不能自动证明用户真的授权;执行端仍须引用真实批准,不补造批准时间。该边界已获用户批准,阶段 0 不实施代码。
|
||||
|
||||
## 6. 合同用例恢复
|
||||
|
||||
- 原用例继续留在 payment-reviewer-permissions.spec.ts,维持 manifest 归属;不改名移动来绕开失败记录。
|
||||
- 扩充本地合成 API fixture 到当前详情 DTO,按实际“查看详情”导航,证明目标详情和生效状态加载后再断言不可新增/编辑/续约/归档。
|
||||
- 管理权限对照使用同一合成生效合同,证明相应动作确实存在,不用终止或归档状态天然隐藏动作来伪造只读权限通过;只在合成测试中操作。
|
||||
- 若当前页面真实暴露不应有的动作,保留原始失败、报告最小影响范围并停止该修复分支,等待权限范围批准;不顺带改权限、不降断言、不重试至绿。等待期间 R1 强制点保持未获启用授权,机制 diff 仍可独立评审;完整候选资格不成立,不自动授权发布。获批修复后须重跑全套验证,不能用定向绿清掉启用前提。恢复该 UI 回归不等于生产真实身份权限验收。
|
||||
|
||||
## 7. 兼容性、回滚与成本
|
||||
|
||||
- 直接 deploy、底层脚本和无记录 skip 将新增失败情形,这是本任务预期的兼容性收紧。提供明确错误、验证专用入口和缺失证据说明,而不是自动放宽。
|
||||
- 保留 existing 数据生命周期及全套生产前后置合同;首次真实使用新工具仍需单独批准和人工观察,不自动发版验证。
|
||||
- 其他全套测试失败会真正阻断发布。本轮不得缩减范围以便发布,也不能把修改后的机制先绕过一次再称已上线。
|
||||
- 回滚应用版本不等于回滚部署工具。没有新的明确批准,不使用旧裸部署命令、删除回执验证或调低判定自救。
|
||||
- 本轮不优化完整测试耗时;优先复用原 ci-release 阶段与一次 full-local 结构化报告。今后若要合并其他门禁或减少覆盖,必须另证等价并审批,不能在这次顺便做。
|
||||
|
||||
## 8. 阶段 0 补充方案:PWA 输入归属(已批准,验证见 implement)
|
||||
|
||||
### 8.1 已批准采用 (a),full-local 负责准备输入
|
||||
|
||||
- 选择 (a),不选“排除后由另一个入口兜底”。当前需要先让完整分母可测,不宜同时引入排除清单、第二执行义务及多份报告合并;这些额外关系会再制造本任务正在消除的漏跑风险。
|
||||
- 复用现有 run-pwa-cross-version-upgrade.sh 的 A/B 构建逻辑,提取一个小型共享准备/清理 helper;专用 runner 与 full-local 都使用它,不复制两套构建规则,也不把专用 runner 的一张 PASS 冒充 full-local 已执行该用例。
|
||||
- 使用本次源码生成两份临时生产构建与不同的 12 位合成构建标记,四项 PWA_BUILD_* 输入只在本次测试进程中传递;它们是测试版本标签,不是候选提交身份。真实候选 SHA 另行记录,不能混淆。
|
||||
- 两份 outDir 与 client/dist 分离;不搬用历史产物,不用假路径、同一构建冒充 A/B,不写共享 env、用户 shell 配置或生产配置。已有专用入口继续可用,PWA spec 顶层缺输入仍明确失败,不改成 skip 或静默 fallback。
|
||||
- 在 full-local 的发现阶段之前准备输入;先完整 --list 留下当前候选的发现集合,再沿用原服务端/前端/真实 DB 回归及全套浏览器执行顺序。列出集合只证明分母可枚举,绝不等于这些用例已经执行。
|
||||
- 全套仍无 spec/grep/shard 筛选,不改 playwright.config 的忽略集合、不改 route-manifest 的 witness/acceptance 归属。传入 PWA 输入后,同一套全量执行结果必须实际包含 PWA 用例,不能将它拆出去然后漏算。
|
||||
|
||||
### 8.2 覆盖归属与运行代价
|
||||
|
||||
- 按当前候选的 testDir/testMatch 和 manual 目录边界独立推导所有非 manual spec;选择 (a) 时它们全部归 full-local,本轮排除集合为空。不能用当前 route-manifest 作为完整套件的全集,也不照搬 local-candidate 的文件名含 live 过滤来缩减 full-local。
|
||||
- 对照全量发现结果核对文件归属,再保存用例集合;PWA 是“full-local 内需要额外输入”的成员,不是例外。未来要拆出其他专用 spec,必须先明确登记原因、必跑入口、责任归属及如何兑现结果,不能默默过滤。
|
||||
- 用户已修正并批准:保留默认文件间并发,不加全局 --workers=1。PWA spec 内部已声明 serial,并使用独占随机 loopback 端口和本测试的 BrowserContext;构建只读,无证据要求与其他文件互斥,当前不新增 project。若实测出现隔离问题,先报告证据,不自行全局串行。
|
||||
- --retries=0 作为 R2 独立要求显式传给发现/执行,覆盖 CI 默认一次重试,不随 PWA 方案改变而删除。成本为额外两次 Vite 生产构建,浏览器及服务端默认并发不变;不承诺固定增量分钟数。
|
||||
- 输入准备失败或收集失败立即非零,保存具体错误、阶段和未运行范围;清理仅处理本轮自有构建/服务/数据库,不删除唯一结果。后续业务阶段若再 fail-fast,仍如实记录未到达浏览器,不能宣称完整红项已齐。
|
||||
|
||||
### 8.3 拟验证的最小行为
|
||||
|
||||
- 共享 helper 的隔离命令捕获:A/B 各构建一次、输入不同、输出目录独立、任一构建失败即非零、四项输入仅传给测试进程、清理不碰其他目录。fake 工具只放测试临时目录,不写共享 PATH。
|
||||
- 全量发现必须包含 PWA,且与当前非 manual 文件归属相符;遗漏输入、误把 PWA 排除、只保留 manifest 子集都不能得到“分母完整”。不能只 grep 命令中出现 --list 或 PWA_BUILD_* 就认为已验证输入与归属。
|
||||
- 原 PWA spec 内容与断言不变;A/B 不收敛或其他浏览器失败只留原始结果,不 reload、不清缓存、不重试至绿、不修 PWA 产品代码。
|
||||
- 结构化输出区分发现集合与实际执行、failed/skipped/not-run,绑定当前源码及真实参数;这只是阶段 0 诊断资产,不提前实现或启用 R1 发布准入。
|
||||
|
||||
### 8.4 负金额原断言冲突与分层纠正依据
|
||||
|
||||
只读源码与现有规约确认:
|
||||
|
||||
1. reimbursements.js:3931 起先检查每条明细;任何 amount <= 0 立即返回 400 VALID_001,details.items=positive_amount_required,并附受影响明细 ID。
|
||||
2. 通过以上检查后才到 :4021 的明细求和和 :4026 的总额非负守卫。对同一组稳定、有限、全为正的明细,不可能求出负总额。
|
||||
3. reimbursement-invoice-ocr.md:936 明确规定上述明细诊断;reimbursements.expense-standards.test.js:325 已有非正明细前置守卫单测,fixture 是零金额。上轮 1680 单测通过不能因此冒称“真实 DB 负金额总额守卫已经覆盖”。
|
||||
4. 原 regression-workflow-actions.sh:999 强制断言 details.total_amount=negative_not_allowed。仅改成负明细会在更早处被合法拒绝,不能同时保持该原诊断断言为绿;这不是产品漏拦,也不应为了测试绕过正明细检查、引入竞态或伪造聚合结果来冒充真实 DB 覆盖。
|
||||
|
||||
因此上一轮“改明细、不改断言”要求不能在这条稳定真实 DB 路径上同时满足,当时保留了原失败并报告。用户现已批准分层纠正:真实 DB 场景正式改名为 items_positive_amount_required,构造负明细并核验入库值/合计,仍要求 HTTP 400、VALID_001、无 workflow instance,精确诊断为现有规约规定的 items=positive_amount_required,并核对目标明细 ID;不是把 400 改为 200,也不是删诊断或放宽为任意 400。
|
||||
|
||||
总额函数沿用既有 __testables 入口直接单测,不修改函数逻辑、不伪造数据库聚合。负数和非有限值的两个拒绝分支由直接单测证明;在当前稳定正明细提交链上,负总额拒绝分支不可达,不再挂成待补同一路径 E2E。函数本身仍被正常调用,不能把“拒绝分支不可达”误写成整个函数没有执行。负金额场景之外复用表头 setter 的其他场景不顺手修改;后续新红项照原范围留痕。
|
||||
|
||||
### 8.5 审定与实施边界
|
||||
|
||||
用户已审定 PWA (a) 及修正、金额分层方案,当前执行范围不再重复索取批准。三项前置处理的目的只是让分母可测,不是全套变绿。合同用例、其他新红项、任何产品/权限修复、阶段 A 和 R1 强制点仍不在本轮动手范围,提交/push/部署也未获授权。
|
||||
|
||||
2026-09-06 最新批准覆盖本节此前待审状态:PWA 按 (a) 实施、零重试独立于 PWA、禁止默认全局串行;负金额不沿用旧场景名改诊断,而是改名为 items_positive_amount_required 的真实 DB 黑盒场景,验证负明细、精确诊断与 itemIds、无 workflow。另通过已有 __testables 导出原 ensureNonNegativeAmount,直接单测负数/非有限值/有效值,不改函数逻辑或提交路径。规约记录稳定正明细提交路径使总额负数分支不可达,纵深防御由函数单测证明,不长期挂成同一路径的欠验收项。其他新红项仍只留痕。
|
||||
|
||||
## 9. full-local 诊断分支增量设计(方向采纳,实施待审)
|
||||
|
||||
目标是让相互独立的测试阶段不因先前断言失败而全部失去执行机会,不是替换普通发布门禁,也不承诺一次获得每个有内部依赖的子用例结果。复用当前 run-web-gates.sh 的输入、集合、构建及本地环境 helper;拟新增显式模式 full-local-diagnostic,不复制第三套发布执行链。
|
||||
|
||||
### 9.1 两种入口的不可混淆合同
|
||||
|
||||
- full-local 保持原 fail-fast:任一阶段失败即非零,后续不运行。本轮不把普通入口自动切到诊断、不改部署调用链、不启用 R1。
|
||||
- full-local-diagnostic 只用于获批本地合成环境;记录理由、操作者别名和明确批准引用,缺项拒绝,不自动补造。即使所有已执行测试为绿,聚合状态也固定 PARTIAL、退出 2,不生成发布 PASS 或发布回执。输入/运行错误及中断同样非零,保留具体阶段状态。
|
||||
- 诊断报告类型、执行模式及实际命令与正式验收不同;未来候选验证器必须按真实 profile/阶段结果拒收诊断,不信任可置位的 qualified/GATE_PASSED 等布尔字段。R1 未实现前不宣称现有发布入口已经具备这项验证能力。
|
||||
- 继续固定零重试、保留并发、不增加排除,不使用 grep/spec/shard 子集或 test.fail/only。完整发现继续先行;65/275 是当前快照,不硬编码成以后候选的预期集合。
|
||||
|
||||
### 9.2 继续的是测试阶段,不是失败的前置条件
|
||||
|
||||
- 服务端单测、类型检查、独立真实 DB 回归套件的断言失败可记 FAIL 后继续尝试其他具备前置条件的阶段。逐阶段记录命令、起止、耗时、退出码、结果及失败/未覆盖范围。
|
||||
- PWA 输入、集合发现、数据库创建/迁移、必要构建、服务启动/readiness、隔离确认属于依赖前提;失败则只阻断其依赖分支并标 NOT_RUN/BLOCKED 及原因。不能在没有可用构建或数据库时盲跑并把环境错误计作产品失败。
|
||||
- 各写库套件之间及浏览器前建立新的本轮独占临时库与服务,重用同一候选的构建与种子过程,不修改种子。不能仅凭失败套件的 cleanup exit 0 就认定数据已恢复;原库和新库都登记所有权、生命周期及清理结果,禁止接入生产或清理非本轮资源。
|
||||
- 阶段命令在独立严格 shell 进程中执行,由外层捕获退出码,不全局 set +e、不把 shell 函数放入条件上下文导致内部 errexit 失效、不用 || true 吞掉失败。
|
||||
- server regression-all 保持原内部 fail-fast,不传 --json-report:首次诊断证实该选项会在失败后复用同库继续,不满足本节的隔离硬约束。后续内层套件如实 NOT_RUN,只有外层独立新库分支继续;保留日志中的启用/跳过事实,不能因名称含 all 就声称全部模块运行。它内部再次包含 workflow-actions,重复执行须单独列为阶段来源,不冒充两个不同红项。首轮原始聚合回执保留,资产/IM 后续失败附同库污染风险,不倒写净库结论。
|
||||
- 不改 workflow-actions 内部状态相关的 23 组来强行继续。第 8 组仍失败时,该套件第 9–23 组仍 NOT_RUN;诊断模式使后续独立套件和浏览器可以运行,不使所有服务端分母自动完整。Playwright serial 失败连带未执行的用例亦保留框架原始状态及依赖原因。
|
||||
|
||||
### 9.3 证据、成本与最小验收
|
||||
|
||||
- 诊断浏览器阶段显式 --trace=on,不能继承与 retries=0 组合后永不产物的 on-first-retry。共享浏览器 helper 只转发内部调用参数,普通 full-local 不新增 trace/worker/筛选参数;本次不改 client/playwright.config.ts。用真实命令捕获验证该参数到达 Playwright,删掉诊断调用处参数的隔离源码变异必须被捕获。trace 只留在本轮本地证据目录,不自动公开或提交。
|
||||
- 保留候选 SHA、dirty/tree/diff 与包含新增文件的运行前后摘要、工具配置、完整发现、实际用例结果、阶段与清理状态。未到达浏览器或报告中断时,缺失用例保持未运行/未知,不能计为通过;本地诊断文件不提供防伪或签名保证。
|
||||
- 用命令捕获 fixture 分别证明:普通模式失败后没有后续阶段调用;诊断模式遇测试失败仍到达具备前提的浏览器阶段;准备或清理失败不会假绿;独占库隔离;全部命令返回 0 时聚合仍 PARTIAL/2;缺批准记录拒绝;集合、并发及零重试不退化。移除真实的模式/退出保护须让相应反例变红。
|
||||
- 实施获批后只运行一次隔离诊断,不修新红项、不重试至绿;交执行失败、未覆盖、环境阻断三类清单及逐阶段耗时,不能承诺必然取得全部子用例结果。
|
||||
- R1 启用需要另外一轮普通 full-local 全绿的完整总耗时与逐阶段耗时;诊断增加隔离重建且容忍阶段间继续,其耗时不能冒充普通发布绿路径成本。红项清空、全绿实测与启用批准仍各自留痕。
|
||||
|
||||
本节拟改动仅为 run-web-gates.sh、必要的共享阶段/结果 helper、对应 QA 测试和现有部署规约;不改审批解析、规则/种子、场景 8/9、合同/R102/PWA 产品或用例断言、发布入口。方向采纳不替代本增量设计审阅,其他阶段 A 内容仍未开工。
|
||||
|
||||
2026-09-06 后续授权覆盖本节待审状态:用户已明确批准 §9,允许只实施诊断子项并立即进行首次诊断,不等待场景 8。阶段内部继续边界、独占新库、PARTIAL/2 和非发布资格均不变;其他阶段 A 和 R1 仍未获启用授权。经理解析只读盘点可作为既有盘点延伸,不能据“没有经理”直接推断实际选中规则必含经理节点或合法兜底一定不可用。
|
||||
|
||||
## 10. 环境启动合同(R2/R6,2026-09-07 已批准实施及一次诊断)
|
||||
|
||||
### 10.1 Scope / Trigger
|
||||
|
||||
环境是完整候选验收的组成部分,与来源和覆盖同级,不能交给仓库外包装临时决定。本节纳入 full-local 普通与诊断浏览器链的受管环境创建、测量、失败处理和回执消费;不开展 31 文件认证迁移、不修合同或其他业务红项、不启用 R1、不授权生产或全套重跑。
|
||||
|
||||
旧包装有本地留痕,具体见 diagnostic-run-20260906-01/run-isolated.sh:43 和 online-20260907-01 的对照证据;缺口是它没有作为受管环境合同被共同审查。207 撤销的是结果清单的决策资格,不是删除原件或宣称 207 条全部同根因。新清单及正常绿路径耗时仍未知。
|
||||
|
||||
### 10.2 Signatures / 受管入口与字段
|
||||
|
||||
- 由 run-web-gates.sh 的公共入口调用受管环境 helper,普通与 full-local-diagnostic 共用;子阶段只能在该 runner 建立并核验的上下文内工作。无临时 artifacts 执行依赖,无调用者可置位的 ENVIRONMENT_PASSED=1 放行合同。
|
||||
- 首版建议明确限定现有本地 Linux 通道的 isolated-online-v1:runner 创建独占网络命名空间及内部 up 链路,服务/浏览器同处核验过的环境,无外连默认路由;本地独占库、socket、PWA 服务的归属及清理由 runner 管理。平台/能力不支持就报告 ENVIRONMENT_INVALID,不自动降级到宿主或开放外网。其他平台 profile 要另审,不能靠空字段冒充兼容。
|
||||
- 回执增加 environment.schemaVersion、profile、policyDigest、runnerDigest、probeDigest、platform、namespace、nativeOnlineProbe、bootstrapObservation、status、cleanupStatus;摘要绑定当前候选及包含新增文件的策略输入,不由回执自报 expected 字段定义合格环境。
|
||||
- namespace 至少记录 inheritedRunner、ownedChild、browser 所处 namespace 的脱敏指纹及来源、runnerCreated、browserMatchesOwnedChild、topologyPolicyMatched。Linux 进程本来就处于某个网络 namespace;不得用单个“是否 namespace=true”混淆继承环境与本轮创建的隔离。
|
||||
- bootstrapObservation 明确 scope=environment-probe、observationPoint=guest-entry-black-box、freshContext、initialSessionHint、loginFormVisible、refreshRequestCount、listenerBeforeNavigation、nativeOnlineSamples、窗口及 observed/unknown 状态。它是当前候选登录页的实际黑盒行为,不宣称读取 AuthContext 分支瞬间 online,也不代表全部业务用例。
|
||||
|
||||
### 10.3 Contracts / 执行与观测
|
||||
|
||||
1. 入口冻结候选及环境策略,记录继承环境,然后由 runner 创建受管子环境。先验证 namespace 关系、内部链路/无外连路由及空白页原生 online=true;失败立即退出,不进行昂贵业务验收。不得改写 navigator.onLine 或把浏览器强制在线的配置当成内核环境事实。
|
||||
2. 复用既有构建和独占库准备,在同一受管环境启动真实候选服务。运行一次专门环境探针,只打开登录页,不登录、不提交业务、不读真实数据;必须取得第 3 条黑盒事实及独立原生在线采样,再启动业务 spec 集合。不声称取得 AuthContext 分支瞬间的内部值。此探针独立登记为必需环境阶段,不混入原业务用例数或假称完整覆盖。
|
||||
3. 不改 AuthContext。用全新 context(无 cookie/localStorage)及 serviceWorkers=block,导航前安装请求监听,直接访问真实候选 /login,不安装 mockSyntheticApis 或任何 route mock。必须表单可见且整个观察窗口内 refresh 请求数为 0;401/失败/重试请求也计数。独立读取原生 online,记录采样点,不冒充 bootstrap 当时的内部值。表单可见但发生 refresh 的反例必须被拒绝。
|
||||
4. bootstrap 探针和浏览器进程 namespace 事实两者都要核对;不能仅信回执某个布尔值。运行期间及结束时重新核验 runner 所拥有的 namespace/拓扑关系;关系变化、观测缺失、权限不足一律 unknown/invalid,不当作“无隔离”或 PASS。
|
||||
父 runner 的 namespace 由父进程通过本轮独占 Unix socket 即时采样返回,响应绑定本轮 ID、PID 与当前策略;不是旧文件自报值。子 user namespace 无权跨层 readlink 父 namespace 时,不放宽为成功:改为由具有权限的被观测父进程直接测量,IPC 不可达或事实不符仍拒绝。子进程自身及浏览器 namespace 仍直接读取,祖先关系仍独立核对。
|
||||
5. 无法从进程自身可靠证明全部祖先包装都不存在。本合同保证的是 runner 重建并实测了获准的内部环境,不是“证明没有人从外层调用 unshare”。不同机器 namespace 指纹可不同;复现比较受管 profile、策略和关系事实,而不是硬比 inode 字面值。
|
||||
6. 一旦全局前提不合格,立即停止后续分支。普通 full-local 返回非零 ENVIRONMENT_INVALID;诊断内部记录环境失败并结束为 PARTIAL/2,不像业务断言红项那样继续展开浏览器。此前完成的原始阶段保留,但整轮不得给完整候选背书;未运行项如实 NOT_RUN,不制造几十个 skipped/failed 结果。
|
||||
7. 环境在线只约束全局启动及受管拓扑;mobile-approvals-offline、r89、r108 等在独立 context 内主动 setOffline 的行为合法保留。不得增加全程“所有 context 必须在线”的断言,更不能禁止/跳过离线场景。全局 kernel 环境不因某一个 context 的离线仿真而被判坏。
|
||||
8. 环境事实与候选、策略、实际集合一起由 R2 验证器消费;R1 强制消费启用时一并成为必需项,普通发布不设环境豁免。在 R1 仍未启用期间,诊断自身仍须执行环境前置,否则无法产出新清单。
|
||||
9. 回执不是防伪证明,外层有文件写权限的执行者仍可伪造。第三方按当前候选和受管命令独立重跑;原始/派生记录和脚本摘要并存,保留原失败,不能用新成功覆盖旧错误环境运行。
|
||||
|
||||
### 10.4 Validation / Error Matrix
|
||||
|
||||
| 输入或异常 | 必需结果 |
|
||||
| --- | --- |
|
||||
| namespace 创建失败、权限/工具缺失、原生 online=false | 环境失败;后续业务浏览器启动数为 0,无降级或豁免 |
|
||||
| 空白页在线,但实际候选 bootstrap 缺失/false | 停止业务集合;观测缺失与离线分别标码,不假填 true |
|
||||
| 调用者只置 ONLINE=1 / ENVIRONMENT_PASSED=1 | 不改变测量或验证结果,不能获得资格 |
|
||||
| 回执缺环境、unknown、策略摘要过期、browser 不在 runner 拥有的 namespace | 非零拒绝;资格消费阶段的远端发布写动作数为 0 |
|
||||
| 候选/探针/拓扑规则在运行后改变 | 旧回执不能给新候选背书 |
|
||||
| 一个 context 离线,其他新 context 在线 | 合法场景,不污染全局环境结果、不跳过离线用例 |
|
||||
| 环境有效,但合同仍卡“更多” | 真实业务用例红项保留,不能因环境已修就判整套通过 |
|
||||
| 诊断所有已运行测试绿 | 仍 PARTIAL/2;诊断耗时不充当普通 full-local 全绿耗时 |
|
||||
|
||||
### 10.5 Good / Base / Bad
|
||||
|
||||
- Good:受管入口在继承的 loopback-only 条件内重建并核验 isolated-online-v1,真实候选 bootstrap 在线,原 spec 不改,回执记录当前环境策略与观测作用域。
|
||||
- Base:环境有意保持无公网,但原生浏览器在线;业务使用本地服务,显式 context 离线由各自场景负责。在线标记不授予 live/公网访问权。
|
||||
- Bad:在 artifacts 包一层 unshare,运行后才解释 207 个超时;或强改 navigator 为 true、把 document-start 写成 bootstrap、把 namespace unknown 写成未隔离,再用 exit 0 当完整验收。
|
||||
|
||||
### 10.6 Tests Required / 范围与实施顺序
|
||||
|
||||
- 拟涉及受管面:run-web-gates.sh、full-local-diagnostic.mjs、full-local-diagnostic-stage.sh;新增 scripts/qa 中的环境创建/探针/回执辅助模块及对应 tests;R2 共享回执模块在其实现时消费同一合同,不复制策略。独立探针不通过重写 31 个 spec 的 import 实现。
|
||||
- 产品源码写入面为零,业务 spec 写入面为零。新增受管环境与黑盒探针及测试;不使用修改源码后的实验副本作为候选。具体文件清单按 implement.md 冻结,新增产品语义或业务测试迁移需另批。
|
||||
- 先用 fake 工具/命令捕获验证:先创建及探针后业务调用、失败启动数 0、无 fallback、PARTIAL/2、环境回执拒绝矩阵、按归属清理。变异删除在线/归属/消费任一保护必须使对应回归失败。
|
||||
- 在获准本地通道运行独立原生探针和无 mock 候选黑盒探针;保留原断言,不重试至绿。不修改产品、online 值或认证控制流;显式 context 离线与全局拓扑分离。此前 context 隔离实验只作其已观测范围的证据。
|
||||
- 环境实现独立复核通过后,按明确运行授权仅重跑一次完整诊断,绑定最终交审源码,不修清单中新红项;服务端场景 8 后续 NOT_RUN 的边界仍存在,不能声称一次诊断必定覆盖服务端全部分母。阶段 B 随后只处理原合同详情及续约/归档验收,产品权限缺口仍停下报告。
|
||||
- 最新用户明确批准本节实施和落地后一次完整诊断;主会话针对性验证不冒充独立复核,交审中须单列待独立复核状态。R1 仍等新的真实清单、普通 full-local 全绿实测耗时及单独批准。
|
||||
|
||||
### 10.7 Wrong / Correct
|
||||
|
||||
Wrong:caller 声明 online=true → 未核验的包装启动浏览器 → 无 bootstrap 证据也汇总 207 为待修清单。
|
||||
|
||||
Correct:当前候选策略定义环境 → 受管 runner 创建并实测 → 真实 bootstrap 在线才启动业务集合 → 环境、覆盖与来源由验证器共同判定 → 错误环境回执留存但撤销决策资格。
|
||||
@@ -0,0 +1,415 @@
|
||||
# 执行计划与阶段 0 基线记录
|
||||
|
||||
## 2026-09-08 独立复核与提交授权
|
||||
|
||||
- 用户已独立复核通过归档恢复、输出隔离、全仓回执与摘要校验的 33 文件包;本轮只补记复核结论,不更改获批实现。
|
||||
- 用户本轮授权将门禁包与 AP 修复分别提交,并连同已有两个 Trellis 提交一起推送。此授权不包含部署、归档、R1 启用、合同阶段 B 或继续修复 58 条失败。
|
||||
- 门禁任务只收口已完成子项,完整入口收敛与 R1 等未完成要求保持挂起;提交不表示整个 task 已完成。
|
||||
- 两个其他未跟踪 task(业务观察准备、认证前置)不纳入本次提交;不把业务消息或交接记为完成。
|
||||
|
||||
## 本轮后续增量:归档截图与全仓状态
|
||||
|
||||
- [x] 已保全新截图到 managed-diagnostic-20260907-04/preserved-tracked-screenshots,并仅恢复四张历史截图,登记摘要 14/14 匹配;未改 index。
|
||||
- [x] 两个 spec 的截图输出独立隔离,受管入口传入本轮目录,认证/断言不变。
|
||||
- [x] 全仓状态/已跟踪内容/index 快照及独立比较;所有退出路径保留后快照。
|
||||
- [x] 治理校验 14 张可定位归档证据及反例测试,恢复演练历史私有候选不冒充可本地验证。
|
||||
- [x] 针对性验证、全仓差异交审;独立复核待用户。不重跑全套、不推进 B/场景 8/经理缺口/R1,不提交/push/部署。
|
||||
|
||||
本轮写入范围:两个 spec 的输出声明;run-web-gates.sh、full-local-diagnostic.mjs、full-local-environment.mjs、check-trellis-governance.mjs;对应 QA 测试及当前 task/既有 spec。旧“27 文件”仅是上一轮计划内交付范围,不是全仓状态;新增污染四张 PNG 已单列保全与恢复证据,旧运行 sourceUnchanged 不再被解释为全仓未变。
|
||||
|
||||
### 本轮结果与回执
|
||||
|
||||
- 新截图保全:`artifacts/qa-flow/release-gate-integrity/managed-diagnostic-20260907-04/preserved-tracked-screenshots/`,包含四张新 PNG、原/HEAD 摘要及恢复核验。先复制并逐字节摘要核对,再执行仅 worktree 的四路径恢复;未移除 index 条目。最后治理校验仍为 14/14,Git 中 output 的改动为 0。
|
||||
- 增量验证/交审:`artifacts/qa-flow/release-gate-integrity/archive-output-protection-20260907-01/`。两个 spec 除 fs 导入和输出目录声明外,认证/业务断言正文与 HEAD 逐字节一致,见 `spec-body-boundary.json`;默认和显式输出根均经真实目录创建 fixture 核验,重复加载/spec 之间不共用输出目录。
|
||||
- 新回执保留完整 Git 状态和 2906 个 tracked 文件的摘要、类型、模式,以及 index stage entries/flags。验证前后 sourceUnchanged=true、repositoryComparable=true、repositoryUnchanged=true,**cleanAtStart=false / cleanAtEnd=false**:只说明本轮测试未再污染已记录状态,不宣称当前树干净。未跟踪文件的状态保留,限定源码之外的未跟踪内容不冒充全量字节冻结。
|
||||
- 故障回归证明:tracked output 即使命中 ignore 仍被检测;dirty→dirty 同路径二次覆盖可见;index-only/assume-unchanged flags 可见;删除、symlink 及缺少快照不会假 PASS。诊断遇模拟 tracked PNG 覆盖时同时保留 environmentQualified=true 与 candidateIntegrityStatus=CHANGED,固定 PARTIAL/2,不将环境和工作树混为一谈。
|
||||
- 最终 QA:下列 node:test 命令 64/64 PASS,0 failed/skipped;另 `cd client && npm run typecheck` PASS,`cd server && npm test -- tests/unit/scripts/check-trellis-governance.test.js` 为 3/3 PASS。语法、git diff --check、治理门禁通过,证据校验 14/14。docs registry 仍为 0 errors / 1 warning(既有 22 个未注册 docs),未扩大修复。
|
||||
|
||||
```bash
|
||||
node --test scripts/qa/tests/{browser-environment-probe,full-local-environment,pwa-full-local-inputs,full-local-diagnostic-results,verify-full-local-discovery,registered-evidence,visual-output-isolation}.test.mjs
|
||||
```
|
||||
|
||||
- 治理覆盖不是“17 条全部验证”:只对归档截图表中 14 个明确仓库文件做机械摘要核对。恢复演练文档的历史私有候选 SHA 缺少可公开定位的仓库文件映射,本轮未取私有参数/产物、未连接生产,不把历史摘要文字计作本地 PASS。
|
||||
- 未重跑完整诊断或两个业务 spec;新路径与回执字段由针对性测试/typecheck验证。旧 58 条环境有效观测保留其原候选/原始回执,不回填新版全仓字段、不从 58 扣数;合同续约/归档仍未执行。后续仍按只读归因→阶段 B 原范围推进,R1/场景 8/经理缺口不变。整体 task 未完成,当前增量待独立复核。
|
||||
|
||||
## 1. 授权与独立范围
|
||||
|
||||
2026-09-07 最新授权覆盖此前“仅规划”状态:§10 实施及一次完整诊断获批;黑盒探针必须无 route mock、零 refresh、表单可见、原生在线独立采样。产品/业务 spec 不写,R1、阶段 B、31+2 不启动。
|
||||
|
||||
本轮冻结写入面:run-web-gates.sh、full-local-diagnostic.mjs;新增 full-local-environment.mjs、full-local-environment-inner.sh、local-postgres-proxy.py、browser-environment-probe.mjs;对应两个新增 QA 测试及 pwa-full-local-inputs.test.mjs 夹具接线;当前任务文档及现有部署/前端验收规约。不得扩到应用源码、业务 spec、发布入口或数据库业务规则。
|
||||
|
||||
- [x] 实施受管环境创建/归属/前置与后置校验,内部 PostgreSQL/socket/代理生命周期归 runner。
|
||||
- [x] 实施无 mock 黑盒探针及环境回执消费,拒绝伪标记、缺项、环境漂移和表单可见但 refresh 已发生。
|
||||
- [x] 针对性单测、命令捕获及源码变异通过;原集合/并发/零重试/诊断 PARTIAL 语义不变。
|
||||
- [x] 最终来源冻结后,完成一次获批完整诊断;此前三次环境前置拒绝单列,新红项只记不修,清理按归属核验。
|
||||
- [x] 更新事实与回执并交审;独立复核仍待用户,不以主会话自检抵扣。不提交、push、归档、部署或启用 R1。
|
||||
|
||||
### §10 本轮实施实测流水(已取得环境有效的新观测,待独立复核)
|
||||
|
||||
- 受管实现和验证证据:`artifacts/qa-flow/release-gate-integrity/environment-implementation-20260907-01/`。最终对交审版本完整运行下列命令:51/51 passed、0 failed/skipped,见 `final-targeted-51.tap`。此前 48 项、13 项 IPC 等过程回执各自保留,不累加成新的用例总数。语法检查、git diff --check、Trellis governance 通过;docs registry 为 0 errors / 1 warning(既有 22 个未注册 docs),未顺手修复。
|
||||
|
||||
```bash
|
||||
node --test scripts/qa/tests/{browser-environment-probe,full-local-environment,pwa-full-local-inputs,full-local-diagnostic-results,verify-full-local-discovery}.test.mjs
|
||||
```
|
||||
|
||||
- 交审包 `review-files.json` / `full-review.diff` 共 27 文件(原获准 21 文件中 9 份发生增量变化,另加 6 个 QA 环境/测试文件)。来源层面与实际诊断冻结稿一致;仅任务文档在运行结束后补入结果,不把独立复核状态写成已通过。
|
||||
- 三次环境前置尝试(managed-diagnostic-20260907-01/02/03)均在 initialize 停止,原生/browser/业务阶段均未启动,固定非零;独占容器 owner 匹配、已删除,scratch 已删除。原件保留,不冒充三次完整诊断,也不计业务失败。
|
||||
- 实测错误为子 user namespace 读取父 namespace 的 `EACCES`。修复不是忽略权限:由父 runner 经本轮独占 Unix socket 即时直接采样自己的 namespace,子进程核对本轮 ID/PID/当前策略、真实祖先关系及自身/浏览器 namespace;IPC 缺失/错轮/错 PID/错策略拒绝。新增 IPC 反例通过,fake Docker 命令捕获证明 namespace 失败无降级,owner 不符不删除容器。
|
||||
- 第四次启动已完成完整诊断:`artifacts/qa-flow/release-gate-integrity/managed-diagnostic-20260907-04/`。来源冻结于 `source-before-diagnostic-04.json`,1058 来源文件;诊断及外层环境回执均确认前后 commit/digest 未变。与在线实验的来源比对,646 份应用源码/业务 spec 文件均未变化;新增源只在 QA 环境合同范围。
|
||||
- 外层环境 PASS,诊断 environmentQualified=true,整个诊断仍固定 PARTIAL / exit 2 / 无发布资格。原生空白页 online=true;候选探针 freshContext=true、initialSessionHint=false、导航前监听、无 route mock、表单可见、refresh=0、login=0,beforeNavigation/documentStart/afterForm 三个独立原生样本均 true。没有产品内部 bootstrap 观测字段或 AuthContext 改动。
|
||||
|
||||
### §10 新诊断清单与证据边界
|
||||
|
||||
| 阶段 | 实际结果 | 阶段耗时 |
|
||||
| --- | --- | --- |
|
||||
| PWA A/B 与完整发现 | PASS,65 文件 / 275 用例 | 99.742 秒 |
|
||||
| Prisma 工具链 | PASS | 16.880 秒 |
|
||||
| 服务端单测 | PASS,193 文件 / 1692 项 | 370.733 秒 |
|
||||
| 前端 typecheck | PASS | 60.971 秒 |
|
||||
| 候选构建 | PASS | 103.444 秒 |
|
||||
| workflow-actions | FAIL,场景 8 | 73.540 秒 |
|
||||
| server-regressions | FAIL,内部 workflow-actions 同一场景 8 | 113.271 秒 |
|
||||
| 浏览器 | 199 passed / 54 failed / 4 timedOut / 18 skipped | 3179.951 秒 |
|
||||
|
||||
- 浏览器 275 条均有实际结果,NOT_RUN=0;集合对拍 issues=[],执行全局错误数 0,4 workers、retries=0、flaky=0。58 条失败/超时来自 36 个文件,是本次环境有效的失败观测,不是 58 个独立产品缺陷。**旧 207 不恢复决策资格,也不能用 207−58 声称修复了某个确定数量。**
|
||||
- 18 skipped 保留原字段,其中 expectedStatus/reportedStatus 为 skipped/skipped 的 14 条、passed/skipped 的 4 条。没有开启 live/公网补覆盖,没有把跳过计作通过,也不把这 4 条未归类跳过全部预判成同一原因。
|
||||
- 关键样本:payment-reviewer-permissions 为 16 passed / 1 timedOut;唯一超时回到 :397 的旧“更多”点击,:399/:400 的续约/归档仍未执行。R102 为 6/6 passed;contract-detail-workspace 为 3/3 passed;login-portal-ux 为 16/16 passed。这些通过不证明全部条件性风险候选已完成迁移评估。
|
||||
- PWA 已越过旧 build 校验和 /portal URL,实际在 :519 等待 `navigator.serviceWorker.ready` 超时;新 build 升级意图尚未被完整验证。本轮只记录,不修、不推断其根因。该失败有 error-context,但未附带 trace;虽然全套命令显式 trace=on,仍不能宣称每个自建 context 都有 trace。58 条失败中 57 条有可读 trace 附件,PWA 是唯一例外;全体实际结果中有 258 条带 trace,探针 trace 另列,不混入用例数。
|
||||
- 两个服务端失败来源均停在 8/23(409 / no_manager_found),不得计成两个独立缺陷;各自 9–23 组仍 NOT_RUN,regression-all 中未到达的后续套件也不补造结果。场景 8 保持未修改。
|
||||
- 聚合诊断耗时 4023.446 秒(67 分 03 秒);浏览器阶段含独占库/服务准备与清理为 3179.951 秒,Playwright 原生计时为 3129.611 秒。三者分开记录,**均不是普通 full-local 全绿耗时,不用于 R1 成本判定**。
|
||||
- 原始 Playwright JSON/HTML、截图、视频、trace 保留在 `results/full-local-report/`。派生 `results/browser-cases.json` 保留全部 275 条,`results/browser-failure-inventory.json` 提供 58 条逐项失败与 65 文件分组、源码/策略摘要及原件哈希,`results/browser-artifact-coverage.json` 单列 trace 和错误行号;派生件没有覆盖原件。
|
||||
- `environment/result.json` 与 `cleanup-verification.json` 确认四次启动的独占容器均按 owner 核对后删除、scratch 均删除;完整诊断各阶段 cleanupExit=0。源码前后不变,末尾环境复验通过。没有提交、push、归档、部署、生产配置或数据操作。
|
||||
- 本次交付只完成 §10 增量及获批新诊断;独立复核待用户。31+2 阶段 1 仍暂缓,合同阶段 B 仍保留原范围但未执行,经理缺口/场景 8/R1 继续挂起。R1 仍需清单清空、普通全绿实测和单独授权。
|
||||
|
||||
- 2026-09-07 最新处置:207 清单撤销真实浏览器红项/工作量/R1 决策资格,原件保留。以下历史运行数字仍是历史观测,不能继续作为当前待修清单。§10 已批准实施与一次诊断,替代此前仅规划状态。
|
||||
- 顺序:环境合同审定 → 获批后实施及针对性反例验证 → 独立复核后按明确运行授权重跑一次诊断 → 按真实清单处理;阶段 B 仅恢复原合同详情及续约/归档范围,不扩成 31 文件迁移。场景 8、经理缺口、R1 保持挂起。
|
||||
- 外层包装在本地 artifacts 有脚本、摘要和运行记录,问题是未进入受管执行合同,不是完全没有留痕。撤销旧清单资格不删除这些调查证据,也不宣称全部失败同根因或已修复。
|
||||
|
||||
- 用户已批准方案和两个边界,附先摸清 full-local 红项的前置条件。已 task start;初始阶段 0 及补充准备已完成,当前只交付另获批准的阶段 A §9 诊断子项,R1、其他阶段 A 与真实发布未开始。
|
||||
- HEAD=66f25b1、本地 origin/main=492920c;原 `.trellis/tasks/09-06-payment-observation-readonly-preparation/` 全量保留,本轮不修改或归档其文件。
|
||||
- 当前 inline,implement/check manifest 保持合法空文件;实施由主会话直接完成,不派 implement/check sub-agent。
|
||||
- 不调查更多历史候选;已经独立复核的只读追溯是输入,不重新把“未找到回执”升级为“从未执行”。
|
||||
|
||||
## 2. 影响范围与复用入口
|
||||
|
||||
| 责任 | 拟涉及文件 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| 验证阶段与 skip | scripts/qa/run-release-gate.sh | 复用原 static/full-local,不复制第三套执行器 |
|
||||
| 全套结果采集 | scripts/qa/run-web-gates.sh | 保留本地隔离 DB、服务端全量回归及 E2E,不误接生产环境 |
|
||||
| 候选结果与持久化 | scripts/qa/run-local-candidate.sh、verify-local-candidate-results.mjs、一个共享回执模块 | full/focused/list 原有含义不变;不缩减 manifest |
|
||||
| 写入前准入 | deploy/trialrun/production-identity-bootstrap.mjs、deploy-existing.sh、deploy.sh | 同一验证器、无循环、无纯布尔放行;生产前后置合同不退化 |
|
||||
| 原用例恢复 | client/tests/e2e/payment-reviewer-permissions.spec.ts | 只改合成 fixture 和当前详情验收,不扩大到权限产品代码 |
|
||||
| 回归 | scripts/qa/tests/ 与 server/tests/unit/scripts/ 相关用例 | 按真实调用事件及集合判定;旧 fixture 只能补真实依赖,不能加 bypass |
|
||||
| 稳定合同 | 现有 deployment-trialrun、frontend-react-vite-design spec 及必要的现有操作手册 | 独立复核规则新增与兼容性收紧,不顺带改 Trellis workflow/hooks |
|
||||
|
||||
最终文件清单随已批准方案内的最小实现核对;若需改变业务权限、状态机、schema、migration、seed、flag、通知 routing 或真实数据,停止并另行请求批准。
|
||||
|
||||
## 3. 实施顺序
|
||||
|
||||
### 阶段 0:原样基线,完成后停下交审
|
||||
|
||||
- [x] 读取现有 runner、环境、Prisma/seed、外联及清理路径,确认 full-local 会写本地合成测试数据,不能冒称 SQL 层只读。
|
||||
- [x] 使用独占 network=none 临时 PostgreSQL、无外网网络命名空间和仅 loopback 的本地服务;清空继承环境,仅保留明确测试参数。既有生产/业务容器不操作,数据库不借用共享业务库。
|
||||
- [x] 原样执行 bash scripts/qa/run-web-gates.sh full-local 一次;Playwright 配置零重试,无 grep/shard/only 参数,额外 reporter 仅采集结果,不替换 runner 或改变选择。结果 exit 1,见第 7 节。
|
||||
- [x] 保存发现错误、实际执行及未运行阶段、退出码、原始日志和脱敏红项清单;未形成完整浏览器集合,分母仍未知,不绕过前置冒充 full-local。
|
||||
- [x] 清理本次独占资源,确认已跟踪源码和旧业务包不变;本轮交用户审阅,等待新增阻塞处理范围及 R1 启用时机决策,不自动进入阶段 A。
|
||||
|
||||
### 阶段 A:先建立入口与回执故障检测
|
||||
|
||||
- 前置更新:用户已批准阶段 0 补充三项范围,但 PWA 选择及负金额诊断冲突仍按第 8 节交审,尚未进入阶段 A。
|
||||
- [ ] 审阅批准后加载 trellis-before-dev;核对当前源文件仍与规划事实一致,冻结模式/覆盖/跳过合同。
|
||||
- [ ] 先写纯验证器和隔离命令捕获反例;证明现有无回执部署与仅计数记录不能满足新合同,不先修合同用例掩盖缺口。
|
||||
- [ ] 实现共享回执采集/验证与原子持久化,绑定真实候选、预期/实际 spec 和用例、筛选条件及逐项结果。
|
||||
- [ ] 复用既有 ci-release 发布前阶段为无部署副作用的 verify-candidate 路径;保持真实发布身份/迁移链,无重复构建/部署或调用循环。
|
||||
- [ ] 为所有普通写入入口建立同一验证器的集成与远端写动作零计数测试;真实强制点的接入/启用交付边界在阶段 0 审阅后单独批准,不能用本 task 完成自动开启,也不引入永久 bypass。纯只读 preflight 保持独立。
|
||||
- [ ] 收紧两个 project-wide skip,缺记录拒绝,有明确批准引用也只得 PARTIAL/非零;不得生成发布资格。
|
||||
- [ ] 保留 local-candidate full/focused/list 判定并持久化各自回执;不以一张定向回执替另一 profile。
|
||||
|
||||
### 阶段 B:再恢复合同只读详情用例
|
||||
|
||||
- [ ] 用当前详情入口和完整合成 fixture 替换旧菜单操作;先断言详情与生效状态加载。
|
||||
- [ ] 真正执行新增/编辑/续约/归档负向断言,并以同一业务状态下的管理身份作对照。
|
||||
- [ ] 在隔离副本中分别暴露续约和归档动作,证明对应断言非零失败;还原后回归通过。不得把试探改动留在工作区。
|
||||
- [ ] 若发现真实产品权限缺口,停止该分支、保留失败并请求额外范围批准;R1 不启用、普通发布资格不成立,不放宽测试或权限。机制交付评审与新权限修复决定分开。
|
||||
|
||||
### 阶段 C:整体验证、只读复核与交付
|
||||
|
||||
- [ ] 核对原部署安全、来源和 49 项 db 生命周期测试仍有效;变更 fixture 不减覆盖、不削弱 fake 工具目录与身份约束。
|
||||
- [ ] 运行全部回执/入口/skip 变异;移除真实源码中的一处保护必须使相应反例变红,不以 grep 某段源码存在性验收。
|
||||
- [ ] 在本地隔离前提具备时执行完整非部署验证,核对真实结构化回执;任何其它失败继续阻断,逐项报告,不修未获批的旁支。
|
||||
- [ ] 更新相关现有 spec/docs;涉及治理/发布控制的变化交只读独立 review,reviewer 不改代码。不得用本轮主会话自检冒充独立复核,不派 implement/check 代理。
|
||||
- [ ] 运行 task validate、治理、敏感扫描、git diff --check;提交当前 task 的完整 diff 给用户审阅,不自动 commit/push/部署/归档。
|
||||
|
||||
## 4. 必需反例矩阵
|
||||
|
||||
| 类别 | 注入/输入 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| 入口绕行 | ci-release/all/deploy、bootstrap、deploy-existing、底层 deploy 直接运行,无有效回执 | 发布写动作 0;不依赖调用者名称相信“已检查” |
|
||||
| 正常链 | 同一冻结候选的完整有效回执 | 原验收/部署顺序正确;发布只触发一次;已有生产前后置仍被调用 |
|
||||
| 模式冒充 | focused/list/直接 grep/project-wide 回执改名,或置 GATE_PASSED=1 | 拒绝发布 |
|
||||
| 来源漂移 | 换 commit、dirty tracked/untracked、策略/config/依赖输入变化 | 拒绝旧回执 |
|
||||
| 范围漂移 | 缺一个 spec、同 spec 少合同用例、错源/重复/未知用例、shard/grep/only | 拒绝;计数相同也不能通过 |
|
||||
| 结果漂移 | exit=0 但受管用例 failed/skipped/timedOut/interrupted、重试绿或 expected-failure | 拒绝 |
|
||||
| 留痕失败 | 回执缺失、截断、写入失败、没有结束标记、运行中断后残留 | INCOMPLETE/FAIL,不能发布;不删除唯一证据 |
|
||||
| skip 纪律 | 两个开关分别与组合,缺理由/批准引用/阶段范围 | 非零,禁止无记录跳过 |
|
||||
| 获批诊断 | 真实引用的局部诊断,记录齐全 | PARTIAL/非零、明确未覆盖,不给普通发布资格 |
|
||||
| 只读前置 | PRODUCTION_READ_ONLY_PREFLIGHT=1 | 仍只读退出,无发布回执、无后续写动作 |
|
||||
| 合同边界 | 生效详情加载,reader 与 manager 对照;分别暴露续约/归档 | reader 反例红;正常 fixture 绿,断言确实到达 |
|
||||
| 部署数据安全 | 原 existing/initialize、健康漂移和无 --no-deps 等反例 | 原有隔离门禁仍红;本轮不实际 initialize |
|
||||
|
||||
变异覆盖入口、profile、来源、集合、skip 五类保护;每类验证真实执行行为,而不是确认脚本出现某个字符串。测试只修改隔离副本或受控 fixture,清理自身临时进程,不读/改真实 Docker、SSH 或生产环境。
|
||||
|
||||
## 5. 拟执行验证命令与条件
|
||||
|
||||
以下命令用于实施阶段,**本轮没有执行**;新测试文件名以批准后新增的实际资产为准。
|
||||
|
||||
```bash
|
||||
node --test scripts/qa/tests/verify-local-candidate-results.test.mjs
|
||||
node --test scripts/qa/tests/check-flow-coverage.test.mjs scripts/qa/tests/check-minimal-profile.test.mjs
|
||||
cd server && npm test -- tests/unit/scripts/deployment-db-lifecycle.test.js tests/unit/scripts/deployment-safety-contract.test.js tests/unit/scripts/check-source-provenance.test.js
|
||||
```
|
||||
|
||||
新增回执/入口单测按仓库现有 Node test 方式运行;触及 Shell/Node 文件各做 bash -n/node --check。合同精确 Playwright 用例使用本地 Vite、合成 API mock、--retries=0,不读取生产合同。
|
||||
|
||||
完整验证只调用新验证专用模式,**不能为了测试门禁执行带真实 deploy 阶段的 ci-release/all/deploy**。发布入口集成与变异一律使用隔离命令捕获 fixture。真实 full-local 涉及本地数据库准备/回归,必须先满足原隔离目标与无外联要求,不能向生产 env 借配置。
|
||||
|
||||
开发中 dirty tree 的测试可作为诊断,不作为后续提交的发布凭证。只有另获提交授权并冻结干净候选后,才生成该 SHA 的可发布回执;如果当时仍有其它全量失败,保持阻塞,不能先部署再补证明。
|
||||
|
||||
## 6. 规划验收与当前状态
|
||||
|
||||
- [x] 用户批准立项;创建独立 planning task,未启动实施、未更改原业务包。
|
||||
- [x] 按已有调用链与用户确认事实收敛 PRD;不重复历史取证。
|
||||
- [x] 复杂任务配齐 PRD/design/implement;默认 inline,不伪造未来 sub-agent 上下文。
|
||||
- [x] 用户审阅通过普通发布无豁免、skip 获批诊断及回执集合合同;附阶段 A 开工前先提交 full-local 红项清单的条件。
|
||||
- [ ] 阶段 0 完整运行结果及未覆盖边界交审;R1 强制点生效与首次部署独立重跑要求见 PRD 后续授权关口,不计入 task 完成标准。
|
||||
- [ ] 产品实现、独立复核、提交和真实部署:均未开始。
|
||||
|
||||
## 7. 阶段 0 原始 full-local 基线(2026-09-06)
|
||||
|
||||
### 7.1 运行事实与来源
|
||||
|
||||
- 实际命令:`bash scripts/qa/run-web-gates.sh full-local`,原始入口完整运行一次,没有编辑 runner、测试或产品源码。
|
||||
- 时间:2026-09-06 17:06:55–17:16:53,Asia/Shanghai;耗时 598 秒,full-local 与隔离包装器均 exit 1。
|
||||
- 候选完整 SHA:66f25b1fa81d4acd27929c2900d8d5b531277268;本地 origin/main=492920c。两个未跟踪 task 目录仍在,因此这是 dirty 本地诊断,绝非该 SHA 的可发布回执。
|
||||
- 持久证据:`artifacts/qa-flow/release-gate-integrity/baseline-20260906-02/` 下的 baseline-summary.json、full-local.log、discovery.json、discovery.log、起止/退出标记、源码摘要和清理复核。baseline-summary.json 是本轮诊断汇总,不是阶段 A 的回执模块实现。
|
||||
- 前一次准备记录单独留在 baseline-20260906-01:预枚举因 PWA 构建输入缺失退出,**没有进入 full-local**。第二次包装器保留同样的预枚举错误后继续原始 full-local;所以是两次只枚举、一次完整入口运行,不是失败后重试至绿。没有覆盖第一份失败记录。
|
||||
|
||||
### 7.2 隔离与输出边界
|
||||
|
||||
- PostgreSQL 使用本轮独占的 postgres:15-alpine 容器,network=none、临时 PGDATA,不借用已有业务库或操作其容器。应用、Prisma、回归及枚举运行于独立网络命名空间,仅 loopback,无外网路由;TCP 代理只连接本轮 PostgreSQL Unix socket。
|
||||
- 清空继承环境,仅传入必要工具路径和明确本地测试配置;真实服务凭据不透传。存储指向本轮临时目录;复用已有隔离 Python 的 xlwt/xlrd 依赖,未安装或修改依赖。
|
||||
- 原始 full-local 包含建库、迁移、seed 和业务写回归,因此本轮“只读”指产品与生产不改,并非测试数据库只读。所有业务请求只进入本地合成库。
|
||||
- 额外 PW_TEST_REPORTER 使用已安装 Playwright 的 JSON reporter,只增加结果输出。E2E_MODE=local、CI 未设置、retries=0、workers=4,manual 目录仍按原配置排除;未加 spec、grep、shard 筛选,未使用两个 project-wide skip。
|
||||
- 原有 client/test-results 与 client/playwright-report 先保全,结束后已恢复;本轮报告单独保存。临时数据库剩余计数=0;自建容器、代理、服务、网络命名空间与临时存储均已清理。
|
||||
- 所有已跟踪文件前后 SHA-256 校验通过;原业务准备包全部文件摘要未变。工作区仍只有原先两个未跟踪 task 目录;未提交、push、归档、部署或碰生产。
|
||||
|
||||
### 7.3 实际结果和未覆盖
|
||||
|
||||
| 阶段 | 本轮结果 | 范围限制 |
|
||||
| --- | --- | --- |
|
||||
| Prisma generate/check-generated、临时建库/migrate/seed、Prisma validate | PASS | 只证明本地准备完成 |
|
||||
| server npm test | 192 文件 / 1680 tests PASS | 不替代后续真实 DB 回归与浏览器 |
|
||||
| client typecheck 与 build | PASS | 不替代页面验收 |
|
||||
| 本地 server readiness | PASS | 不是生产 /ready |
|
||||
| regression-workflow-actions | 第 6/23 场景组 FAIL;预期 HTTP 400,实际 200 | 1–5 组完成;6 组未完成;7–23 组未运行,场景组不冒称单条用例 |
|
||||
| server regression-all | NOT_RUN | 原入口 fail-fast,未到达 |
|
||||
| E2E 后台 job 模式切换及 Playwright full | NOT_RUN | 实际浏览器用例执行 0;总用例分母未知 |
|
||||
| 独立 Playwright --list 预枚举 | exit 1:PWA_BUILD_A_DIR_missing | 发现流程未完成,报告 suites=0 不代表仓库没有测试 |
|
||||
|
||||
**结论:取得了原始入口的失败基线,尚未取得“整套当前红几项”的完整分母。** 一个 full-local 实际停止点和一个独立发现阻塞,不能相加写成“全套只红两项”,更不能以 1680 项单元测试绿声称候选合格。
|
||||
|
||||
### 7.4 新增阻塞及源码解释
|
||||
|
||||
1. **workflow-actions 的负金额夹具与提交重算不一致。** `server/scripts/regression-workflow-actions.sh:991` 的 negative_not_allowed 场景绑定合成发票后,仅通过 `:266` 的 setter 把报销表头 total_amount 改成负数;`:997` 要求提交返回 400,本轮实际 200,因此 error code/detail 与无 workflow instance 的后续断言均未到达。当前 `server/src/routes/reimbursements.js:4021` 在提交时先按明细重算,再 `:4026` 调用负金额校验;`:1299` 的重算会覆盖表头。源码支持“表头注入被重算覆盖”的夹具漂移解释,**不能据此说真实负金额被批准或付款**。本轮不改夹具、不放宽断言、不绕过重算;新的有效负金额测试及应覆盖哪个入口仍待最小修复范围批准。
|
||||
2. **无参数 Playwright 全套连发现阶段都缺 PWA 双构建前提。** `client/tests/e2e/pwa-cross-version-upgrade.spec.ts:40` 在模块加载时就要求 PWA_BUILD_A_DIR,随后还要求 B 目录和两份 SHA;缺失会在 `:66` 抛错。现有 `scripts/qa/run-pwa-cross-version-upgrade.sh:51`/`:52` 先准备两份构建,并在 `:56` 起传给该专用测试;当前 full-local 不准备或传递这些输入。本轮两次 --list 均独立复现首个缺失错误。这不是合同用例超时,也不能通过排除 PWA、填假路径或声称只需修合同来消掉;本轮未修。
|
||||
|
||||
以上只补当前源码解释,不追查这些阻塞持续了多久,也不调查更多历史候选。
|
||||
|
||||
### 7.5 保持未知的两项与下一步
|
||||
|
||||
- 合同只读用例:旧入口漂移已有上轮独立复现,但本轮 Playwright 没运行,不能记成“本轮又失败 1 项”;续约/归档边界仍未获得本轮验证。
|
||||
- R102 主链路:本轮未运行,历史“编辑”按钮问题在当前候选是否仍存在仍未知,不靠源码猜测或单元测试绿抵扣。
|
||||
- 不进入阶段 A,不修产品、不调整权限、不排除红项,不启用 R1。先请用户审阅新增两个阻塞,明确最小测试前提/夹具修复与补测范围;完整浏览器分母补足前,不建议决定 R1 随本 task 强制生效。
|
||||
- 普通发布无豁免、获批 skip 仅 PARTIAL/非零、R1 独立启用、首次真实部署独立重跑的批准边界不变。本轮只有主会话自检,没有冒充独立复核;业务包仍可独立交付,未代发真实消息。
|
||||
|
||||
## 8. 阶段 0 补充范围与决策待审(2026-09-06)
|
||||
|
||||
最新批准:PWA (a) 已通过,保留文件间并发,--retries=0 属于 R2;负金额按用户指定的“场景改名归位 + 原函数直接单测 + 不可达性规约”三件事实施,不沿用本节旧的待审替代方案。执行顺序:PWA helper/归属回归 → 金额分层测试 → full-local。阶段 A、合同用例、其他新红项、R1 启用及提交/push/部署仍未授权。
|
||||
|
||||
- [x] 用户批准 PWA 输入归属决策及实现、负金额夹具恢复意图、重跑 full-local 三项前置范围;明确 PWA 方案先审。不修合同或其他清单红项,不放宽断言、不缩集合、不进入 A、不启用 R1。
|
||||
- [x] 只读核对 PWA 专用供给方、顶层输入读取、Playwright 全量/候选子集区别和现有单 worker 规约;建议 (a),具体拆分与成本见 design.md 第 8 节。
|
||||
- [x] 将“每个非 manual spec 都须有覆盖归属”的要求补进 R2;(a) 的排除集合为空,不为 PWA 创建豁免。
|
||||
- [x] 只读发现负金额的前置正明细守卫,现有 spec 与单测均支持。指出“负明细 + 原 total_amount 诊断断言不变”的冲突,不自行将更早的正确拒绝定性成产品缺陷。
|
||||
- [x] 用户审定 PWA (a),只增加双构建;明确拒绝默认全局单 worker,保留文件间并发,固定零重试属于 R2。
|
||||
- [x] 用户批准金额分层:黑盒场景改名归位、精确明细诊断与 itemIds/无 workflow;原总额函数直接单测及不可达性规约。
|
||||
- [x] 实施共享 helper/两个入口/源码归属验证,17 项 Node fixture 回归通过;PWA spec、route-manifest 和 Playwright 全局配置未动。
|
||||
- [x] 金额代码及针对性验证:原函数仅新增既有 __testables 导出,12 条直接单测通过;连同前置明细/提交副作用测试共 43 条通过。随后 full-local 第 6/23 组真实 DB 黑盒验证通过,进入第 7/23 和第 8/23 组;不是以单测抵扣黑盒。
|
||||
- [x] 独立本地环境 full-local 补跑一次,保存发现/执行/未运行结果:第 8/23 组新增失败后按原 fail-fast 退出,未修该项或跳过前置。275 条浏览器用例仅枚举、执行为 0,完整红项执行分布未取得。
|
||||
|
||||
本节最初提案预计涉及两个 runner、共享 PWA fixture helper、相关 QA 测试和负金额回归场景;当时只更新 task 规划,未改已跟踪文件或跑业务测试。后续获批范围补入总额原函数单测及规约;实际实施、验证与未覆盖边界以第 9 节为准。
|
||||
|
||||
证据口径补充:默认 full-local 缺 PWA 输入时确实无法收集,但不据此推断过去每个浏览器用例都未通过其他入口执行;不继续逐次查历史。1680 单测 PASS 保留既有隔离 BIFF8 Python 的环境前提,不声明任意全新环境都已自动具备依赖,也不抵扣真实 DB 或浏览器覆盖。
|
||||
|
||||
## 9. 本轮实施验证记录
|
||||
|
||||
- 证据目录:artifacts/qa-flow/release-gate-integrity/full-local-after-input-fixes-20260906-01/;独占本地 PostgreSQL、无外网命名空间、合成账号与数据,隔离前提沿用第 7 节。代码为 dirty 候选,保存 HEAD、diff、包含新增代码的逐文件摘要,不具备发布资格。
|
||||
- `node --test scripts/qa/tests/verify-full-local-discovery.test.mjs scripts/qa/tests/pwa-full-local-inputs.test.mjs`:17/17 PASS,含真实入口调用捕获、CI/本地零重试与并发边界、双构建/输入/收集失败和省略 PWA 的隔离源码变异。
|
||||
- `cd server && npm test -- tests/unit/routes/reimbursements.amount-guard.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/routes/reimbursement.submit-side-effects.test.js`:3 文件 / 43 tests PASS,其中原总额函数直接单测 12 项。
|
||||
- Shell/Node 相关语法检查 PASS。未修改合同用例、PWA spec、Playwright 全局配置、route-manifest 或任何部署入口。
|
||||
- full-local 双构建 PASS,完整发现及源码归属校验 PASS:65 文件 / 275 条用例;实际配置 workers=4、retries=0、收集 errors=0。发现清单不是执行通过回执。
|
||||
- 本轮主会话自检,独立复核待用户进行;不派 implement/check 代理、不提交/push/归档/部署,不修执行中新冒出的红项。
|
||||
|
||||
### 9.1 补跑结果与新增红项
|
||||
|
||||
实际命令仍为 `bash scripts/qa/run-web-gates.sh full-local`,2026-09-06 19:26:33–19:38:24(Asia/Shanghai),711 秒,exit 1。本轮没有重试该入口;原始第 7 节的失败基线也未覆盖。
|
||||
|
||||
| 阶段 | 实际结果 | 解释 |
|
||||
| --- | --- | --- |
|
||||
| PWA A/B 构建、非 manual 发现与归属 | PASS,65 文件 / 275 用例 | PWA 纳入同一集合,没有排除 |
|
||||
| Prisma/本地库迁移种子 | PASS | 仅本轮独占合成库 |
|
||||
| 服务端单测 | 193 文件 / 1692 tests PASS | 比首次基线多 12 条原总额函数单测 |
|
||||
| 前端 typecheck/build、本地 readiness | PASS | 不代表公网或浏览器业务通过 |
|
||||
| workflow-actions 第 1–7 场景组 | 完成 | 第 6 组负明细实写、合计核验、精确 400/VALID_001、reasonCode、itemIds 与无 workflow 断言均通过;第 7 组是后续场景准备 |
|
||||
| workflow-actions 第 8 组 manager skip | FAIL:预期提交 200,实际 409 | VALID_001;workflow=no_actionable_approver;reason=no_manager_found;stepId=approval_1_manager |
|
||||
| workflow-actions 第 9–23 组 | NOT_RUN | 原 fail-fast 未到达 |
|
||||
| server regression-all、E2E 模式切换 | NOT_RUN | 未绕过前置失败继续冒充 full-local |
|
||||
| Playwright full E2E | 0 executed / 275 discovered | 所有用例标 NOT_RUN,不是 skipped/pass;合同、R102、PWA 升级行为本轮均未实际重验 |
|
||||
|
||||
新增红项定位:`server/scripts/regression-workflow-actions.sh:1052` 的 manager skip 场景设置经理缺失后,期望自动跳过经理并产生财务任务;在 `:1055` 的提交动作就收到上述 409,财务任务/跳过历史等后续断言未到达。**只记录实际响应和原期望,不在本 task 修测试、改审批规则,亦不先定性为产品缺陷。** 原总额 setter 在其他场景的使用未顺手变更。
|
||||
|
||||
精确区分:分母现在可枚举为 275,但浏览器实际失败清单还没有形成。不能写成“只红经理缺失这一项”,也不能把合同或 R102 的历史失败计入本次执行数。R1 生效继续挂起,整个门禁完整性 task 未完成。
|
||||
|
||||
### 9.2 留痕、资源与边界
|
||||
|
||||
- full-local-result.json 汇总真实阶段与 275 条逐用例 NOT_RUN 状态;它标明 FULL_LOCAL_DIAGNOSTIC_NOT_RELEASE_RECEIPT。原始 full-local.log、发现 JSON、source ownership、构建日志与起止/退出标记均保留。
|
||||
- amount-guard-mutations.json 记录仅在内存中编译的两类反例:移除负数拒绝或非有限值拒绝都会被对应断言发现。没有写回源码、没有数据库操作,不冒充真实 DB 回归或独立复核。
|
||||
- 自有临时库剩余计数=0;容器、服务与代理均已停止,PWA 构建目录与本轮临时存储已移除。报告目录保留,原有 Playwright 目录不被 full-local 删除或覆盖。
|
||||
- 运行前后候选代码(包括新增文件)SHA-256 校验一致;原业务准备包摘要未变。HEAD=66f25b1,本地 origin/main=492920c;只有本任务实现、规约和 task 文件的工作区改动,未提交、push、归档、部署或触碰生产。
|
||||
- task validate、Trellis 治理(0 errors / 0 warnings)、敏感扫描(0 findings)、错误原因码 strict 与差异空白检查通过。运行代码除 QA/回归工具外,应用侧仅增加已有 __testables 中的函数导出,金额函数和业务守卫逻辑未改。
|
||||
|
||||
## 10. 用户复核通过及第 8 组只读归因(2026-09-06)
|
||||
|
||||
- 用户独立复核并批准上轮 18 文件 diff;本轮只读核对前逐文件对照 review-files.json,18/18 摘要一致。原 review.diff/review-files.json 保留不覆盖,业务包未动。
|
||||
- 从既有 full-local.log 的第 857 行只提取错误码和指定 details 字段:VALID_001,workflow=no_actionable_approver,reason=no_manager_found,stepId=approval_1_manager。details 键集合仅有 workflow/reason/stepId,响应不存在 skipped 数组;不能将“没有字段”当作空数组或仅有 manager 的数组。
|
||||
- reimbursements.js:4192 显式传 blockOnUnresolvedAssignee: true;utils/workflow.js:5 把 no_manager_found 列入阻断原因,:512 的严格分支先抛 409,:525 的 skipped.push 尚未执行。故本次并非 manager 已跳过、finance 无法接续,而是提交被经理解析失败直接阻断,finance 未到达。
|
||||
- reimbursements.js:4256 的 step_id/reason/depth_checked 是成功创建 workflow instance 后写入 workflowHistory 的 payload,不是 409 响应。无 actionable 的路由分支 :4196 仅返回 workflow 字段;本次带 reason/stepId 的错误来自 resolver 内部严格分支。
|
||||
- 用 node stdin 调用当前导出的原 resolveActionableStep,合成 owner 无经理、steps 明确包含 manager + finance;tx 仅为内存只读 fake,finance 的 assignee_rule getter 记录访问次数。严格参数 true:抛出与日志相同的 409/details,finance 访问 0 次;仅对照参数 false:finance 访问 1 次并成为 actionable,skipped 为 manager/no_manager_found/depth 1。两个断言组 PASS;未改函数、未连数据库,false 只用于内存对照,不改真实提交开关。
|
||||
- 既有 workflow.test.js:226、:355、:382 已分别规定无兜底时缺经理阻断、strict-submit 阻断和非 strict 兼容跳过。源码追溯显示严格分支及提交调用参数来自 8da16f5(2026-05-10),不是本轮引入;此处未重新跑整套单测,也不将阅读旧单测冒充本轮执行。
|
||||
- 已可确认场景 8 的“严格提交缺经理仍应 200”预期与当前严格提交合同冲突;没有依据归成 finance 节点缺失、finance 身份失败或续接回归。未恢复已清理临时库的完整规则快照,不声称生产配置或财务身份已验证。场景 9 的造数意图不能推翻实际 strict 分支;任何场景改名/断言或规则变更仍另待范围批准。
|
||||
|
||||
## 11. 诊断模式决策与下一步
|
||||
|
||||
采纳用户提出的阶段级诊断方向,不另建 task;设计第 9 节是增量交审稿,当前未改 runner 或启动阶段 A 实现。它将失败套件与后续浏览器解耦,但不承诺穿透每个套件内部的 fail-fast。R1 除红项清单外,新增普通 full-local 完整绿路径实测耗时作为启用决策输入,不计入本 task 完成标准。
|
||||
|
||||
- [x] 提取既有 409 的指定字段并核对严格分支,不造 skipped 响应、不读生产。
|
||||
- [x] 原函数双节点内存对照,明确 finance 未访问及非 strict 对照结果。
|
||||
- [x] 更新 PRD/设计/当前记录,保存原获批 diff;只采纳诊断方向,不倒写实现授权。
|
||||
- [x] 用户审阅并批准 §9:继续边界、隔离方式及 PARTIAL/2 合同;只启动此诊断子项,不等待或修复场景 8。
|
||||
- [x] 获批后仅实现该子项与命令捕获/模式反例回归;普通 gate fail-fast 不变。
|
||||
- [x] 一次隔离诊断,交真实失败、未覆盖、环境阻断及耗时;不修新红项,不启用 R1。原始结果与执行器收尾定向验证分开,见 §12.3–12.4;独立复核仍待用户完成。
|
||||
|
||||
## 12. §9 获批实施(2026-09-06)
|
||||
|
||||
- 当前执行顺序:复用 runner helpers 实现诊断阶段与持久结果 → 命令捕获/模式变异 → 一次隔离诊断。其间只读核对经理解析盘点的既有通道,不修改生产组织数据、账号或审批配置。
|
||||
- 场景 8 保持原失败;经理数据没有答案前不改,盘点本身也不自动批准测试或产品修复。场景 8 后续若另获批准修复,再单独补一次诊断,不能把首次结果冒充 9–23 组已覆盖。
|
||||
- 本轮明确授权引用:用户本轮“§9 诊断设计:批准”;不替代 R1、其他阶段 A、提交/push/部署的批准。
|
||||
|
||||
### 12.1 实施与首次诊断过程留痕(已结束)
|
||||
|
||||
以下保留运行中的发现和当时的收尾计划;最终结果见 §12.3,修正验证见 §12.4,不将后来定向通过倒写为首次整套通过。
|
||||
|
||||
- 复用普通 full-local 的输入准备与浏览器命令,新诊断协调器只捕获独立严格 shell 阶段;普通 fail-fast 入口未放宽。32 项 QA 验证通过,含全绿也 PARTIAL/2、缺批准记录拒绝、原普通失败短路、独立新库、准备失败依赖阻断、清理失败、浏览器结果及非零保护源码变异。
|
||||
- 首次诊断证据:artifacts/qa-flow/release-gate-integrity/diagnostic-run-20260906-01/。候选代码冻结后只改 task 文档或忽略证据,不修改运行代码;诊断已实际越过 workflow-actions 失败并进入浏览器,尚未完成时不预报通过数。
|
||||
- 发现本轮执行器自身的环境冲突:传给子进程的 TMPDIR 含 `hyb-full-local-diagnostic-`,导致 deployment-safety-contract.test.js 的合成 remoteDir 含禁止的 local token。服务端 6 项由此在实际 SSH fixture 前被 REMOTE_DIR_NON_PRODUCTION_IDENTITY 拒绝,原始结果为 1686 passed / 6 failed,不将它们归成业务回归。当前候选冻结收集完毕后只调整执行器临时根命名,定向重跑原部署身份测试;不放宽生产身份守卫或测试断言,不覆盖首次失败回执。
|
||||
- 原 validateExactProductionIdentity 的纯内存路径对照已确认:旧前缀拒绝,中性 hyb-qa-inventory 前缀通过;无网络或数据操作。报告在 diagnostic-implementation-20260906-01/temp-path-guard-probe.json。
|
||||
- 进一步自检发现隔离边界:首次版本仅在外层 workflow-actions、regression-all、browser 之间换库,regression-all 的 --json-report 会在内部共享库上越过 workflow-actions 失败。后面的资产状态和 IM secretConfigured 断言虽实际失败,但不作为净库独立产品结论。收集结束后移除该聚合器的继续参数,保留其原内部 fail-fast;外层诊断仍继续到新库浏览器,不绕过失败后的数据前置、不扩写新的子套件执行框架。原聚合输出及潜在污染边界保留,不倒写成从未执行;新版内层首个失败后的其他子套件如实 NOT_RUN。
|
||||
|
||||
### 12.2 经理只读盘点
|
||||
|
||||
- 正式快照:2026-09-06 20:49:02(Asia/Shanghai)。通过唯一非 root 数字 UID 归属身份,核对冻结 systemd 合同、env/runtime origin、运行中 server/db、应用数据库 DNS 指向与查询库名摘要一致后,只读执行计数。生产 build 仍为 f223bfb72fb2。
|
||||
- 连接 default_transaction_read_only=on;事务 REPEATABLE READ READ ONLY,实际 read_only=on,statement timeout 10 秒、lock timeout 1 秒,最后 ROLLBACK。仅输出计数和角色布尔分组,不读人员姓名/编号、卡号、合同或业务正文,不登录或修改生产业务账号。
|
||||
- 范围为未归档、登录 status=active 且 employment_status 为 probation/active/offboarding 的记录:7 条中 4 条 direct_manager_id 为空,0 自环;另外 3 条返回经理 ID,所引用经理不存在/停用/归档/inactive 的计数均为 0。
|
||||
- 无经理的 4 条中,3 条不含 CEO 角色(其中 1 条含 manager 角色),1 条含 CEO 角色。没有拆 smoke 与真人,也未读取/匹配六条路径的实际规则快照,不能说成“4 位真人六路全部锁死”。buildApprovalWorkflowSnapshot 对配置了 owner_self_fallback_role 的 CEO 可能作显式兜底;manager 缺失是已证实的组织缺口,实际路径影响仍需核对适用规则与身份。
|
||||
- 该业务风险单独优先交负责人,不因此改经理关系、规则或场景 8;场景 8 继续保留失败。身份与事务证据、SQL、可复验只读探针位于 artifacts/qa-flow/release-gate-integrity/manager-readonly-20260906-01/。
|
||||
- 前两次探针未形成可信计数结论,保留 attempt-1/2;第二次明确定位到本地按行解析多行 JSON 汇总失败。修正的是本地 JSON 流解析,不改变 SQL 范围或只读保护;第三次完整解析结果单独留痕,不补造前两次通过。
|
||||
|
||||
### 12.3 首次完整诊断结果
|
||||
|
||||
- 时间:2026-09-06 20:41:03–23:58:26(Asia/Shanghai),11843.619 秒,约 197 分 24 秒。浏览器框架自身 10827.400 秒,外层 browser 阶段含独占建库/服务/清理为 10873.879 秒;两种计时口径分列。这是包含失败等待的 PARTIAL 诊断成本,不是普通门禁全绿耗时。
|
||||
- 完整发现及结果对齐 65 文件 / 275 用例,4 workers、0 重试、0 flaky、0 缺失结果、0 未知/重复用例;结果为 50 passed、76 failed、131 timedOut、18 skipped。共 257 条非 skipped 结果;“275 条都有结果”不等于全部实际执行。54 文件含失败,6 文件全通过,另 5 文件全部 skipped。
|
||||
- 207 条失败仅是框架结果数,不是独立产品缺陷数。原始错误粗分有 128 条 locator.fill 等待、69 条 expect、10 条其他错误;这只是错误操作分组,不是根因判定。完整逐文件及逐用例清单均留在证据目录,不在 task 中复制原始 payload。
|
||||
- 合同/付款权限文件 17 条均 timedOut;合同只读场景本轮先停在 login-identifier 的登录 helper(:343),没有重新执行到旧“更多”及续约/归档断言。R102 6 条均 timedOut,报销日期场景本轮先停在登录 beforeEach/helper(:355/:348),不能据此确认或否定原按钮名漂移。PWA 1 条 timedOut,停在旧 build 首次页面 URL 断言(:519),尚未完成 A→B 升级链;输入与发现 PASS 不抵扣行为验收。
|
||||
- 18 个 skipped 中 14 个 expectedStatus=skipped,4 个 expectedStatus=passed。保留原 expectedStatus、reportedStatus、annotation 类型及逐次结果;不把全部跳过归成 serial,也不把条件跳过计为通过。即使清完失败,14 个当前条件跳过仍需独立确定如何满足原完整集合合同,不能静默排除、更不能未经授权开启 live/公网测试来凑绿。
|
||||
|
||||
| 阶段 | 首轮结果 | 秒(四舍五入) | 解释与未覆盖 |
|
||||
| --- | --- | ---: | --- |
|
||||
| inputs | PASS | 99 | 临时 PWA 双构建与独立文件枚举;不等于行为通过 |
|
||||
| tools | PASS | 17 | Prisma generate/check-generated/validate |
|
||||
| server-unit | FAIL | 376 | 193 文件,1686 passed / 6 failed;6 条 TMPDIR 污染见 §12.4 |
|
||||
| typecheck | PASS | 61 | 未改前端源码 |
|
||||
| client-build | PASS | 103 | 与 PWA 临时 A/B 构建分开 |
|
||||
| workflow-actions | FAIL | 77 | 第 8/23 组 200→409;9–23 组 NOT_RUN |
|
||||
| server-regressions | FAIL | 236 | 原聚合回执 5 passed / 3 failed / 7 skipped;workflow 重复来源,资产/IM 附同库继续污染风险 |
|
||||
| browser | FAIL | 10874 | 原始 50 通过 / 207 失败 / 18 跳过 |
|
||||
|
||||
- 诊断与外层 harness 均 exit 2,状态始终 PARTIAL。所有阶段 cleanupExit=0;库剩余计数 0、独占容器/临时根/PWA scratch 已消失,前后源码摘要一致,旧业务包逐文件 SHA 校验不变。没有重试本轮浏览器、没有在运行中改候选、没有生产写入。
|
||||
- 原始证据:artifacts/qa-flow/release-gate-integrity/diagnostic-run-20260906-01/ 下的 diagnostic-report/diagnostic-results.json、full-local-report 中的发现与 Playwright JSON;派生 inventory-summary.md/json、browser-cases.json、browser-failures.json、browser-not-executed.json 及 inventory-sha256.json。原始文件保留,不用派生清单覆盖它们。
|
||||
|
||||
### 12.4 运行结束后的两项执行器修正与验证
|
||||
|
||||
- 中性 scratch:createDiagnosticScratch 使用 hyb-qa-inventory 前缀,避免 TMPDIR 把 local token 带进原部署身份测试的合成 remoteDir。新增测试直接调用实际创建函数和原 validateExactProductionIdentity,同时保留旧前缀被拒的对照;生产身份规则与既有测试断言均未改。
|
||||
- 内层 fail-fast:移除诊断 server-regressions 对原聚合器的 --json-report 参数。新增回归运行原聚合器和合成子套件,证明普通调用在 workflow 失败后不执行后续套件,而恢复继续参数会跑到后续写库步骤;不是仅搜索 flag 字符串。外层独立新库浏览器仍可继续。
|
||||
- QA:node --test scripts/qa/tests/full-local-diagnostic-results.test.mjs scripts/qa/tests/pwa-full-local-inputs.test.mjs scripts/qa/tests/verify-full-local-discovery.test.mjs → 34/34 PASS(51.727 秒)。保留首次冻结版 32/32 回执,不覆盖为 34。
|
||||
- 原身份/金额定向:在无外网命名空间、中性 TMPDIR 执行 cd server && npm test -- tests/unit/scripts/deployment-safety-contract.test.js tests/unit/routes/reimbursements.amount-guard.test.js → 2 文件 / 36 PASS,其中身份 24、金额 12,5.27 秒。它证明六条执行器假红的定向修正,不冒充首轮服务端 1692 全绿。
|
||||
- 真实净库 helper 定向:独占 network=none PostgreSQL、原迁移/种子/服务、新库执行私有 server-regressions 阶段;Smoke、Dedup 通过,workflow-actions 第 8 组仍返回 409/no_manager_found/approval_1_manager,阶段 exit 1。聚合器只到 3/15,4–15 没有执行;cleanupExit=0、库剩余 0,测试 harness exit 0 表示预期失败与清理检查成立,不是聚合器 PASS。没有第二次运行全套浏览器。
|
||||
- 收尾相对首次冻结源码只变 5 文件:两个诊断执行文件、两份 QA 测试及 deployment-trialrun 规约。运行原始结果绑定旧摘要,交审代码绑定收尾后摘要;金额、PWA helper、合同/R102/场景 8 及业务逻辑未再改。
|
||||
- 定向证据:artifacts/qa-flow/release-gate-integrity/diagnostic-implementation-20260906-01/qa-tests-postrun.log 及 postrun/。只由主会话验证,用户独立复核尚未发生。
|
||||
|
||||
### 12.5 当前交付边界
|
||||
|
||||
- 本轮整体候选 21 文件(前轮 18 文件 + 3 个诊断文件),不含旧业务准备包或忽略 artifacts。交审 diff 与文件摘要单独生成,不覆盖之前已审快照。
|
||||
- 最终本地核验:Shell/Node 语法、task validate(inline 两份清单各 0 entries)、Trellis governance(0 errors / 0 warnings,1276 文件)、敏感扫描(0 findings)及 tracked/untracked whitespace 检查通过。回执在 diagnostic-implementation-20260906-01;完整交审包在 artifacts/qa-flow/release-gate-integrity/diagnostic-review-20260906-01/review.diff 与 review-files.json,检查不冒充用户独立复核。
|
||||
- 经理缺口先交负责人核对真实身份与适用规则;场景 8 不因本次盘点自动获得修改授权。业务风险、过时用例、浏览器失败三条事实分开,不预设因果。
|
||||
- R1 继续等待红项及未覆盖清单处置、普通 full-local 全绿及实测总/分阶段耗时、单独启用批准。其他阶段 A、合同阶段 B、本 task 整体 AC 均未完成;没有 commit、push、归档、部署或任何生产数据/配置/账号写入。
|
||||
|
||||
## 13. 获批带 trace 对照实验与诊断补强(2026-09-07)
|
||||
|
||||
- 用户已独立复核通过 §12 的 21 文件。随后只读核对:当前三张 rejected 的 CEO owner 有 finance 显式兜底;通过集中至少五条确实执行浏览器登录;代表性合同失败截图已显示工作台而非登录页。上述事实不等于提交全链成功,也不能把 207 个原始失败归为同一个根因。只读证据仍保留在 readonly-followup-01 及两份 rejected-owner 查询目录。
|
||||
- 本轮新增授权仅为两次本地合成浏览器实验(单条 1 worker / 全套 4 workers,均零重试且 trace=on),然后为 §9 诊断入口强制 trace。普通门禁的零重试和 worker 配置不变;普通模式 trace 策略本轮仅评估、不顺手修改;不修浏览器用例、场景 8、产品或数据。
|
||||
- 对照使用获准收尾后的运行代码与相同本轮构建;按现有诊断 helper 的本地数据库、种子、应用环境和服务清理方式分别新建隔离库/存储。实验 wrapper 只在忽略证据目录提取现有 runner 定义、调整导入根路径及浏览器选择/trace 参数,不修改候选脚本;记录转换与实际命令,不冒称普通 full-local 完整验收。
|
||||
- 单条先完成并清理,再启动全套,避免两实验并发改变负载。两轮均保留原超时、不重试、不改断言;全套仍含全部非 manual 用例和 PWA 输入,不缩集合或开启 live。原 197 分钟不用于绿路径成本估算。
|
||||
- 合同旧“更多”断言是已知红点,因此比较登录前置的导航、认证请求、填写状态及真正失败位置,不能要求单条整个用例必须绿才证明它完成了登录;单次不同结果只说明条件相关,不能凭一对实验断言已证明稳定性或排除了所有竞态。
|
||||
- [x] 冻结候选与工具/构建条件,执行单条实验并读取 trace。
|
||||
- [x] 同候选启动全套 4 workers;目标用例失败并落盘完整 trace,完成针对该用例的比较。全套在后续 263 条结果行处以 143 中断,不能将此勾解释为整套完成。
|
||||
- [ ] 全套完整终态:本次未取得,保留中断证据,不重试至绿,也不伪造 Playwright JSON。
|
||||
- [x] 实验停止且孤立资源清理后,只补诊断 trace 参数、命令捕获回归和规约;35 项针对性验证通过,零重试、默认并发、PARTIAL/2 和普通门禁行为不变。
|
||||
- [x] 交代表性实验结论、中断边界和增量 diff;去重归因清单仍待证据,R1/提交/push/部署保持未授权。用户独立复核待进行。
|
||||
|
||||
### 13.1 对照现场与结论
|
||||
|
||||
证据根:artifacts/qa-flow/release-gate-integrity/trace-comparison-20260907-01/。source-before.json 绑定获准收尾后的候选、完整源文件集合/摘要及 wrapper 转换;运行前后源码一致,没有在实验中修改测试或应用。inputs/tools/client-build 均完成且 cleanupExit=0,两实验共用该次构建,各自新建数据库与服务。浏览器执行前准备未重跑服务端单测或 workflow-actions,不冒充完整诊断流水线。
|
||||
|
||||
| 对照 | 实际选择与条件 | 代表性合同用例结果 |
|
||||
| --- | --- | --- |
|
||||
| single | 单文件精确 grep 合同只读标题,1 worker,trace=on,retries=0 | timedOut;215143ms;停在 :343 的 login-identifier.fill,未到旧“更多” |
|
||||
| full | 发现 65 文件 / 275 用例,无 spec/grep/shard 筛选,4 workers,trace=on,retries=0 | 同一用例在 :343 失败,完整 trace.zip 包含 180000ms 超时事件;该结果先于整套后续中断落盘 |
|
||||
|
||||
- 两份当前 trace 中都没有 POST /api/auth/login。single 在 goto 起点后约 1129ms 发出 refresh,约 1221ms 开始 fill;full 对应约 2180ms 和 2295ms。refresh 均在没有 has_session 请求标记的情况下得到 200 和非空令牌,随后 /api/me 得到 200 和身份。两次最终截图均为已渲染工作台。时间是 trace 起点偏移,响应耗时另列,不把请求起始时间冒充响应完成时刻。
|
||||
- 结论仅为:本用例的登录前置失配在单 worker 也可复现,不支持“只有全套并发才失败”的分支;不是隔离绿、满载红。单次配对不能证明绝对确定性、排除所有竞态或保证门禁可复现,也不能把 207 个原始失败合并为一个已确认根因。
|
||||
- 已保存旧 trace 的实际时间为 2026-09-06 12:33:18(Asia/Shanghai),不是用户提及的 09-04 留痕,不能混记。旧 trace 先完成标识/密码填写和提交,进入合同页,才等待“更多”;当时登录前没有 refresh,请求 refresh 发生在登录后。
|
||||
- 旧 trace 实际请求 /@vite/client、/@react-refresh、/src/main.tsx;当前两次实际请求 /assets/ 构建产物。旧与当前 trace 内嵌的本用例源码均与冻结文件逐字节一致。这证明运行条件不止并发有差异,不证明“开发/构建模式”已经被单变量实验确认为根因。其余网络/会话初始化条件尚未逐项隔离,未修任何下游红项。
|
||||
- 两份当前 trace 的资产请求集合一致;其中可从 archive 取到响应体的两个资源 SHA-256 一致。不将两个可核对响应体冒称所有 bundle 内容都在 trace 内逐字节核验。其余同次构建依据是 wrapper 只构建一次以及候选冻结,证据口径分列。
|
||||
|
||||
### 13.2 全套中断与清理
|
||||
|
||||
- 单条完整报告存在;全套目标用例完整 trace 存在且可读。然而整套在已输出 263 条结果后工具会话返回 143,没有 full/exit、full/outcome.json 或全套 Playwright JSON。终止信号来源未确认;不是已知断言正常收尾,也不能编造剩余 12 条的状态或整套失败/通过统计。
|
||||
- 终止后核对本轮进程已不存在,独占容器和一个本轮数据库仍在。依据冻结容器 ID、owner label、network=none 与 socket 挂载归属,只清理本轮孤立库/容器/临时根;库剩余由 1 降为 0,容器与临时根均消失。interruption-observation.json 与 interruption-cleanup.json 留痕;自动 cleanup 标为未完成,人工补清理不倒写自动通过。
|
||||
- 本轮结果类型为 CONTROLLED_TRACE_COMPARISON_NOT_RELEASE_RECEIPT / PARTIAL_INTERRUPTED。代表性比较可用,全套终态未取得,没有第二次全套重跑;未运行 finalize.mjs 的完整成功归集分支。results.json、receipts-sha256.json、single/trace-summary.json、full/trace-summary.json、full/representative-status.json 为独立派生索引,原始 trace 与日志均保留。
|
||||
|
||||
### 13.3 trace 策略增量
|
||||
|
||||
- 运行时代码只改两处:qa_run_full_browser 转发其内部调用参数;诊断 stage 调用时明确传 --trace=on。普通 full-local 调用不带额外参数,client/playwright.config.ts 未改;固定零重试、默认 worker、完整集合、PARTIAL/2 和无发布资格保持不变。
|
||||
- QA 命令:node --test scripts/qa/tests/full-local-diagnostic-results.test.mjs scripts/qa/tests/pwa-full-local-inputs.test.mjs scripts/qa/tests/verify-full-local-discovery.test.mjs → 35/35 PASS,51.429 秒。断言检查实际捕获命令:诊断恰有 --trace=on,普通模式不被顺带改;移除诊断 stage 参数的源码变异可被捕获。原 34 项回执未覆盖。
|
||||
- 实验实际 argv 已显式带 trace,并成功产生真实目标 trace;实验后正式诊断入口的参数传递由上述捕获回归验证。没有声称正式诊断新源码完成过额外整套运行。新策略与实验冻结源码的两处差异单列,后续运行必须重新绑定届时获准候选。
|
||||
- 普通门禁 retain-on-failure 值得独立审阅,但用户本轮只明确要求诊断 trace;不将“考虑”扩成修改普通门禁策略的授权。任何真实发布或 trace 对外分享仍各自授权。
|
||||
- 本轮交审包:artifacts/qa-flow/release-gate-integrity/trace-policy-review-20260907-01/。review.diff 为当前完整 21 文件;incremental.diff 相对获准 diagnostic-review-20260906-01 冻结快照仅变 8 文件,13 文件未变,无增减文件。旧快照在独立临时目录由 HEAD 与旧 diff 重建并逐项核对旧 SHA-256,不向真实工作树应用补丁。review-files.json 保存当前摘要、范围及两份 diff 摘要;未跟踪文件另做 whitespace 核对。实验原始摘要及中断回执不覆盖;快速 task/governance/sensitive/syntax/whitespace 复验放在新交审目录,35 项测试沿用未再改动源码对应的原日志。本轮只有主会话验证,不冒充独立复核。
|
||||
@@ -0,0 +1,139 @@
|
||||
# 部署门禁完整性与候选验收回执
|
||||
|
||||
2026-09-07 后续最小增量已批准:保全本轮四张截图、恢复已归档证据;两个 spec 仅调整输出路径,默认和受管调用均不得覆盖归档目录;回执增加完整 Git 状态、全仓已跟踪内容与 index 语义状态,限定源码不变与全仓不变分开判断。并入可定位的 14 张归档截图摘要校验,不读取恢复演练私有候选或生产文件,不宣称任意历史 SHA 均可本地重验。58 条环境有效观测保留,不重跑全套诊断,不启动合同阶段 B、认证迁移或 R1。
|
||||
|
||||
## Goal
|
||||
|
||||
让真实发布入口只能使用当前候选的完整、可核对验收结果,不再发生“已登记验收报红、定向回归通过后仍被当成完整候选发布”的脱节。复用既有 ci-release 的验收顺序,不另造一套发布流程;先完成入口与回执合同,再恢复旧合同只读用例。
|
||||
|
||||
## Background
|
||||
|
||||
- 用户于 2026-09-06 批准方案及两个边界,附阶段 A 前置条件:先原样运行一次 full-local,提交当前真实红项清单,再由用户决定 R1 强制点的生效时机。本轮仅执行阶段 0,不进入阶段 A。基线 HEAD=66f25b1、本地 origin/main=492920c;原业务准备包及门禁只读追溯均已获用户复核通过。
|
||||
- `scripts/qa/run-release-gate.sh` 的 ci-release 已有 static → full-local → deploy;但实际发布走 production-identity-bootstrap → deploy-existing → deploy.sh,未关联该验收结果。来源检查仅证明提交与工作树,不证明测试覆盖。
|
||||
- 两道浏览器门禁都受旧用例影响:`scripts/qa/run-local-candidate.sh:84` 纳入全部 acceptanceSpecs;release 的 full-local 经 `scripts/qa/run-web-gates.sh:585` 调用 `client/package.json:15` 的无 spec 参数 Playwright 全套运行。两者不是同名或等价的门禁,不能互相冒充。
|
||||
- `client/tests/e2e/payment-reviewer-permissions.spec.ts:397` 等待已移除的行内“更多”,导致后续续约/归档断言没有执行;详情入口已在 ContractsRegistryPage 中替代旧菜单。
|
||||
- `scripts/qa/run-release-gate.sh:103` 与 `:112` 的 PROJECT_WIDE_SKIP_BUSINESS_FLOW / PROJECT_WIDE_SKIP_VISUAL 只靠置位跳过,没有必需理由和回执;并入本 task,不单开。
|
||||
- 历史调查到此停止,不逐次追查过去所有候选。已确认的证据与边界见 `.trellis/tasks/09-06-payment-observation-readonly-preparation/implement.md:171`;不改写失败留痕,也不把用户批准发布倒写成明确豁免整道候选门禁。
|
||||
|
||||
## Scope
|
||||
|
||||
2026-09-07 最新授权:用户批准设计 §10 的受管环境实施及落地后一次诊断。bootstrapObservation 使用全新 context、导航前请求监听、无 spec route mock、登录表单可见、观察窗口 refresh=0 和独立原生 online 采样,不修改 AuthContext。环境合同归本 task 的 R2/R6;31+2 迁移、合同阶段 B 和 R1 启用不随此授权启动。
|
||||
|
||||
原始隔离诊断的 207 条浏览器失败观测撤销“真实红项清单”及决策资格,不用于缺陷数量、修复范围/优先级、工时、R1 时机或发布判断。原始 JSON、trace、外层包装和派生清单保留,不删除、不覆盖、不将 207 条倒写成均已证明的假失败。在线对照只证明代表性合同用例的认证前置可在不改 mock 时通过,不能代替新全量诊断。阶段 B 恢复原合同详情/续约/归档验收范围,不含 31 文件认证迁移,也不表示已经证明它是唯一红项。
|
||||
|
||||
修改面限定于 QA 执行与结果模块、既有发布入口、合成合同测试、相关回归和现有模块规约。行为要求由 R1–R5 规定,拟涉及文件及先后顺序见 implement.md;不把本任务扩大成新的发布平台。
|
||||
|
||||
2026-09-06 用户追加批准的前置范围仅为:PWA (a) 输入准备及归属验证;将真实 DB 负明细场景改名归位、总额守卫直接单测和不可达性规约;重跑 full-local 摸清分母。用户已明确批准上述分层纠正,不再沿用旧场景名改诊断。保留默认文件间并发,--retries=0 独立属于 R2。合同用例仍留在阶段 B,其他新红项只记录不修,R1 生效继续挂起。
|
||||
|
||||
上述 18 文件实现已获用户独立复核通过,随后用户明确批准设计 §9 的诊断子项。该子项的实现与首次隔离诊断已完成并交审;不把它倒写成整个阶段 A、既有红项修复或 R1 启用授权。场景 8 未修,经理缺口另列只读事实与业务影响待核项。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 真实发布入口采用既有验收合同
|
||||
|
||||
- 以下是强制点获批生效后的目标合同,不表示本轮批准已启用它。机制实现与本 task 完成不自动授权强制消费;启用前必须验证完整 full-local 全绿,并另获明确授权。不得新增日常发布可置位的豁免或禁用开关来代替分阶段生效。
|
||||
- 普通发布的必需本地阶段采用 ci-release 已有的 static 与 full-local,并核对 full-local 实际结果中的受管 witness/acceptance 集合;不只验证退出码,不以另一套局部检查替换。
|
||||
- ci-release、all、deploy 模式与 production-identity-bootstrap、deploy-existing、底层 deploy.sh 必须收敛到同一份候选回执验证;直接运行底层脚本也不能绕过浏览器验收。
|
||||
- 缺回执、回执无效或必需阶段未通过时,在远端同步、构建、容器操作、init/migrate/seed 等发布写入前失败退出。原有纯只读生产 preflight 可以独立运行,但不得生成发布资格。
|
||||
- 不为了复用 ci-release 而丢掉当前生产身份、迁移、回滚窗口、来源与部署后检查;调用链不得出现 ci-release 与 bootstrap 相互递归或重复触发部署。
|
||||
|
||||
### R2. 回执绑定来源和实际覆盖,不能由定向结果冒充
|
||||
|
||||
- 标准发布的 Playwright 执行必须显式传 --retries=0,覆盖 CI 环境下配置默认重试一次的行为;这是 R2 独立要求,不依附 PWA 是否存在。PWA 不要求全局 --workers=1,保留既有本地 4 / CI 2 workers 默认及文件内 serial。
|
||||
- 预期集合必须解释当前候选下每个非 manual spec 的归属:要么进入完整验收集合,要么进入显式登记且带原因、必跑入口、责任归属和验收证据的例外集合;不得两边都没有,也不能从回执自报 expected 或既有 acceptanceSpecs 子集反推全量。遗漏、未知归属或有登记却没有兑现执行义务均不能成为完整通过。本轮已批准采用 (a),排除集合为空,具体实现见设计第 8 节。
|
||||
- 持久回执至少记录:完整候选提交、来源及校验策略摘要、执行 profile/模式、阶段状态与退出码、真实起止时间、工具链、实际筛选参数、预期和实际 spec 集合、用例集合及逐项结果、异常/跳过记录。
|
||||
- 环境事实属于同级必需输入:受管环境策略/runner/观测器摘要、运行平台、命名空间创建归属及可核验关系、原生在线探针和候选登录入口黑盒观测、观测作用域、环境检查/清理状态。缺失、未知或与当前受管 profile 不一致时不得获得完整验收或发布资格;不能只信调用者的 ONLINE=1、GATE_PASSED=1 或回执自报 environmentPassed=true。具体字段与判据见已批准的设计 §10;本轮实施验证状态见 implement.md。
|
||||
- spec 使用仓库相对路径;集合来源是当前候选下的真实发现与执行报告,不是人工声明“跑了全套”。同一 spec 内的 --grep 仍可能漏测,所以还必须核对用例级覆盖,并记录 grep、project、shard、only 等影响范围的条件。
|
||||
- 普通发布不接受定向、仅枚举、受管用例 skipped/failed/timedOut/interrupted、缺项、重复、未知来源或不完整结果。不得通过重试至绿、test.only、test.fail 或缩减清单恢复资格。
|
||||
- 换提交、工作树脏、策略/配置/锁定依赖等校验输入变化,旧回执失效。测试前后及真正发布写入前都核对来源,不能用开发中脏工作树的诊断回执给后续提交背书。
|
||||
- local-candidate 的 full/focused/list 继续保留各自含义和严格判定,均留痕;其回执不能改名充当 ci-release 的发布前回执。反过来也不把 ci-release 的本地验收称为 LOCAL_CANDIDATE_PASS 或已部署。
|
||||
- 成功、失败、PARTIAL、LIST_ONLY 和中断都应留下可识别状态;没有完整结束证据的运行不得变成 PASS。cleanup 不再删除唯一结果证明。
|
||||
|
||||
### R3. 两个 project-wide skip 开关受控留痕
|
||||
|
||||
- 只有开关而没有非空理由、受影响阶段、操作人别名和明确批准引用时,失败退出;不接受默认理由、自动批准或补造批准时间。
|
||||
- 本版拟定边界:这些开关只支持获批的局部诊断,返回 PARTIAL/非零并列出未覆盖阶段,不能打印完整 PASS,不能生成普通发布资格。
|
||||
- 普通发布的必需本地门禁本轮不提供豁免入口;继承这两个 skip 开关也不得静默忽略后按完整发布处理。若未来确需应急发布例外,应另行明确其批准与验收合同,不复用无记录开关。
|
||||
|
||||
### R4. 按详情入口恢复合同只读权限验收
|
||||
|
||||
- 在入口和回执的故障检测先建立后,再修改旧合同用例:从列表“查看详情”进入,先证明合成生效合同详情已加载,再具体验证新增、编辑、续约、归档不可用。
|
||||
- 增加同一合成业务状态下有管理权限身份的对照,避免因页面未加载或合同已终止而得到假阴性;不读取真实合同、不操作生产身份。
|
||||
- 续约和归档断言必须实际执行。若暴露产品权限缺口,保留失败、单独报告并等待范围批准;不得在本 task 中悄悄放宽权限、改状态机或删断言。
|
||||
|
||||
### R5. 保留部署安全、证据和范围边界
|
||||
|
||||
- 保留 existing 模式禁止 db 生命周期操作、init/server/worker 隔离、120 秒健康检查和禁止自愈合同;不顺手修改 PGDATA、路由、schema、migration、seed、业务 flag 或账号。
|
||||
- 回执只收集最小脱敏数据,不写完整本地路径、凭据、账号、真实合同/票据/银行资料或原始业务响应;审批引用不是可由脚本替人授权的签名。
|
||||
- 本地回执用于防止失误、暴露可核对的跳过及范围差异,不是防伪完整性证明;有本地文件写权限的操作者可以伪造内部自洽材料。回执必须支持第三方按记录的候选、环境与命令重跑复现,不能把执行端自报当成独立证据;首次真实部署前须独立重跑并与回执比对。
|
||||
- 仅用本地隔离 DB、合成角色与严格限定目录的 fake 工具验证;fake PATH 不写入共享配置,不连真实 Docker/SSH 来做变异。
|
||||
- 其他历史失败不静默排除、不抽样、不改为跳过;遇到它们时完整候选仍失败,明确列为阻塞并单独请求修复范围。不能因本 task 改的是门禁而把门禁先绕过部署。
|
||||
- 原业务准备包可以交业务,不依赖本 task。当前没有获得提交、push、归档、生产配置、部署、账号启停、回填或付款授权。
|
||||
|
||||
### R6. 环境启动合同(已批准增量,与 R1/R2 同级)
|
||||
|
||||
- 隔离的创建、校验、记录、资源归属和清理由受管 runner 负责;禁止依赖 artifacts 中临时包装来施加必需条件。建议本地 Linux 普通 full-local 与诊断统一使用受管 isolated-online-v1:独占命名空间、无外连默认路由、原生浏览器在线。缺权限/工具即环境失败,不静默退回另一个环境。
|
||||
- 在线是原生浏览器信号,不是公网可达或企业微信/live 授权。全局启动前提失败即中止整轮后续业务验收,不能让普通环境错误展开成几百条用例超时;失败回执仍保存,诊断仍为 PARTIAL/非零且无发布资格。
|
||||
- bootstrapObservation 为黑盒实际观测,不记录伪造的分支时刻 online:全新无会话 context,导航前监听请求,不安装任何 spec mock,记录登录表单可见、窗口内 refresh 请求数为 0、原生在线独立样本。refresh 后 401 也能显示登录表单,因此仅表单可见不得通过。AuthContext 及所有业务 spec 不修改。
|
||||
- 专门环境探针的 bootstrap 只代表该探针,不冒充全部业务用例;全局网络条件持续由 runner 校验。测试自行对独立 context 的 setOffline 场景保留,不能禁用或跳过 mobile-approvals-offline、r89、r108 来满足全局在线断言。
|
||||
- 本地环境回执防失误、便于复跑审计,不防可写本地文件的操作者伪造;不宣称能够证明所有祖先包装都不存在。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
0014 资料补录与付款、三单运营处置、主体自动赋值/回填、IM routing 配置及 G1–G10 全局技能复核保持各自边界,不随本任务启动。无新的权限模型、状态机、数据结构或真实数据操作;原部署安全合同按 R5 保留。不引入签名服务、凭据或新的 CI 平台。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:所有普通发布入口的命令捕获证明,无有效完整回执时远端发布写动作计数为 0;有效回执只进入一次现有发布链,且保留生产前后置检查。(R1)
|
||||
- [ ] AC2:实际发布前阶段复用 ci-release 的 static/full-local 实现;候选 full、候选 focused、LIST_ONLY、project-wide 和直接定向 Playwright 结果不能互换成发布资格。(R1/R2)
|
||||
- [ ] AC3:成功与失败回执均包含候选提交、实际 spec 和用例集合、筛选条件、逐项结果及真实阶段状态;仅伪造 exit=0/17 passed 或相同 spec 名称不能骗过验证。(R2)
|
||||
- [ ] AC4:旧提交、脏树、输入变化、缺失/截断/中断回执、同文件漏用例、受管用例跳过/重试/expected-failure、伪布尔“已通过”均使发布验证非零退出。(R2)
|
||||
- [ ] AC5:两个 skip 开关各自及组合都验证:缺理由/批准引用失败;获批诊断为有留痕的 PARTIAL/非零;均不能进入普通发布。(R3)
|
||||
- [ ] AC6:真实源码变异移除入口验证、放宽 profile/来源/覆盖判定、恢复无记录 skip 时,对应隔离回归必须变红;断言按执行事件和集合语义,不 grep 某个 flag 字符串。(R1/R2/R3)
|
||||
- [ ] AC7:修复后的合同只读用例实际进入详情,续约/归档负向断言与管理身份对照均执行;人为暴露任一禁止动作时测试失败,不增加 timeout、不删断言。(R4)
|
||||
- [ ] AC8:既有部署安全、来源和 49 项 db 生命周期回归保持有效;夹具升级不得引入测试专用发布绕过口或削弱 fake 工具防泄漏检查。(R5)
|
||||
- [ ] AC9:本 task 的覆盖核对、回执反例及已批准用例修复通过;原样完整本地运行及全部未通过、未覆盖部分如实留痕,dirty 诊断不具备发布资格。额外红项阻断完整候选资格与 R1 生效,不用聚焦绿抵扣;本地机制 diff 可单独交只读独立复核,但不得宣称完整门禁已绿。相关 spec/docs 及治理检查完成,不要求先偷偷提交来取得发布回执。(R1–R5)
|
||||
|
||||
## Post-task Authorization Gates
|
||||
|
||||
以下取代原 AC10,**不计入本 task 完成标准**,也不由方案批准自动取得授权:
|
||||
|
||||
1. 红项及未覆盖清单审阅后,由用户另定 R1 强制点生效时机;完整 full-local 全绿及该次普通门禁完整绿路径的实测总耗时、逐阶段耗时共同作为启用输入,不能用 711 秒前置失败或诊断耗时替代。全绿不是可豁免项。机制可先评审或另批合入,连接真实发布强制点的步骤保持未授权;具体切换交付形态待清单和实测成本后确定,不引入永久 bypass。
|
||||
2. 提交、push 与冻结干净候选分别遵循既有授权。之后为该完整 SHA 重新生成、验证回执,不把 dirty 基线结果移作发布凭证。
|
||||
3. 首次真实部署另行授权;用户或独立复核方按该候选重跑验证并比对回执,确认范围、逐项结果与来源。主会话自检不算独立重跑,部署授权不能倒写成强制点启用或验收豁免。
|
||||
4. 合同恢复若暴露产品权限缺口,保留失败并单独申请修复范围;等待期间不启用 R1、不授权发布、不放宽用例或权限。修复获批后重新完整验证,未全绿不得进入第 1 步的启用。
|
||||
|
||||
## Test Plan
|
||||
|
||||
环境增量验收(已批准,不改变 R1 单独启用条件;独立复核另列):
|
||||
|
||||
- [ ] AC11(R2/R6):环境创建或原生在线前置失败时,后续业务浏览器用例启动数为 0;如在后续阶段才发现漂移,剩余分支停止,既有原始结果保留但整轮无完整验收资格。不存在跳过环境检查的普通发布开关。
|
||||
- [ ] AC12(R2/R6):记录实际环境 profile/策略摘要、namespace 关系及观测范围;缺 bootstrap 观测、unknown、错误 profile/namespace、旧策略回执、伪环境通过字段均不能获得资格。第三方按策略重新建立环境,不要求不同机器的 namespace inode 字面相等。
|
||||
- [ ] AC13(R6):受管启动在原本只有 loopback 的外层条件下仍能建立符合策略的原生在线环境,或在业务用例前明确失败;context 单独置离线不改变其他新 context,原有显式离线场景和默认并发/零重试/完整集合均保留。
|
||||
- [ ] AC14(R2/R6):无 mock 黑盒登录入口观测针对当前候选构建,表单可见、零 refresh 与独立原生采样一起验证,前置失败/缺观测不会变成 PASS;移除 namespace 归属校验、在线断言或环境回执消费中的任一项,对应隔离反例须变红。测试中不产生真实发布写动作,也不新增产品源码观测点。
|
||||
|
||||
先在网络与数据库隔离条件下原样运行一次现有 full-local,不改代码、不修用例、不部署;记录完整阶段、发现集合、实际执行、失败、跳过及未运行部分。若前置阶段失败,必须说明浏览器尚未运行、红项分母仍未知,不能把首个失败称为整套唯一失败。用户审阅基线后再进入阶段 A:纯结果验证、入口捕获和真实源码变异;之后才恢复合同用例。完整矩阵、命令与阶段 0 实测记录在 implement.md。
|
||||
|
||||
## Key Proposed Decisions
|
||||
|
||||
- 以既有 ci-release 发布前阶段为普通发布基线,不新增第三套并行发布框架;在同一次 full-local 报告上增加受管集合完整性核对,不再额外重复跑一遍相同浏览器集合来制造第二张“通过”。local-candidate 的独立合同不删除、不降级,也不被宣称已经执行。
|
||||
- 普通发布无豁免、两个 skip 仅作获批 PARTIAL/非零诊断的边界已经用户批准;阶段 A 仍须先提交完整基线清单,R1 生效另行授权。
|
||||
- 不追查全部历史候选,不混入业务修复或 Trellis/G1–G10 任务;技术设计、兼容性和实施顺序分别见 design.md、implement.md。
|
||||
|
||||
## Risks And Deferred Items
|
||||
|
||||
full-local 包含本地数据库准备、服务端回归和全量浏览器,耗时及其他既有失败可能阻塞下一次发布。此风险需如实呈现,不能以“修测试只需十分钟”承诺完整门禁收口。若发现额外产品问题或需要发布例外,停在明确证据与范围决策,不扩大本轮实现。
|
||||
|
||||
## Planning Status
|
||||
|
||||
以下三段保留分轮历史,不代表当前仍未执行诊断;最新状态见本节末段及 implement.md §12。
|
||||
|
||||
方案条件批准;已 task start,并完成一次原始 full-local 入口运行:exit 1,停在 workflow-actions 第 6/23 场景组;独立预枚举还发现 PWA 双构建输入缺失。浏览器未执行,完整红项分母仍未知,详见 implement.md 第 7 节。任务状态 in_progress 不等于阶段 A 已启动;本轮未修改运行时代码或执行发布命令。先交审新增阻塞处理范围,R1 生效及所有提交、push、部署操作仍待各自授权。
|
||||
|
||||
最新授权:PWA (a) 及两处修正、金额分层三件事均已批准实施;代码、针对性验证及一次隔离 full-local 补跑已完成。完整发现得到 65 个非 manual 文件 / 275 条用例;193 文件/1692 服务端单测、前端检查/构建和第 6/23 组负明细黑盒验证通过。新增第 8/23 组经理缺失场景预期 200、实际 409,full-local exit 1;后续真实 DB 回归和 275 条浏览器用例未运行,完整浏览器红项分布仍未知。新红项不修,R1/阶段 A/合同修复未启动;实现 diff 待用户独立复核,详见 implement.md 第 9 节。
|
||||
|
||||
后续复核与诊断更新:用户已批准第 9 节对应的 18 文件 diff。第 8 组 409 不含 skipped 数组;提交传入严格解析,经理缺失在 skipped.push 之前直接抛错,尚未解析 finance。现有原函数的双节点内存探针和既有严格/兼容模式单测源码与此一致,详见 implement.md 第 10 节。诊断分支仅完成增量设计,尚未实施或运行;浏览器仍为 0 executed / 275 discovered,任务未完成。
|
||||
|
||||
最新交审状态:§9 实施及一次完整隔离诊断完成,固定 PARTIAL/exit 2;65 文件/275 用例有对应结果:50 passed、76 failed、131 timedOut、18 skipped,0 重试、0 缺失回执用例。207 项失败不是 207 个已确认产品缺陷;合同/R102 本次先卡登录前置,不能证明下游原断言已执行。18 个 skipped 中 14 个 expectedStatus=skipped、4 个 expectedStatus=passed,不能把条件跳过删出集合或开启 live 环境凑绿。普通 full-local 全绿成本仍未取得,诊断约 197 分 24 秒不能替代它。执行器两项自有问题已在原始运行结束后最小修正,34 项 QA 与原身份/金额 36 项定向单测通过;聚合器净库定向验证保持内部 fail-fast,场景 8 后面的分母仍未补齐。经理盘点 7 条 active 在职记录中 4 条缺直接经理,实际规则和 smoke/真人未消歧,不宣称六路全部锁死。全 task AC 尚未完成;R1、其他阶段 A、阶段 B、提交/push/归档/部署仍未授权,本增量待用户独立复核。
|
||||
|
||||
后续状态(2026-09-07):上述 21 文件已获用户独立复核通过。追加只读结果与两次带 trace 的条件对照见 implement.md §13:三张 rejected 的 CEO owner 存在 finance 显式兜底,不能因缺经理否定本人重提;至少五条浏览器登录用例通过。当前构建下代表性合同登录在单条 1 worker 与全套 4 workers 中均失败,不能据此宣称“只有并发才失败”或排除一切竞态。全套在后续 263 条结果后以 143 中断,完整终态未取得,孤立残留已补清理;不重跑、不补造通过。新获批诊断 trace=on 已最小实施,35 项 QA 通过,待本增量独立复核;普通门禁 trace 配置未改。207 仍仅原始失败观测,根因分类/去重后的可执行红项清单尚未取得;R1 与场景 8 等其他边界继续挂起。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "release-gate-integrity",
|
||||
"name": "release-gate-integrity",
|
||||
"title": "部署门禁完整性与候选验收回执",
|
||||
"description": "本轮§11归档截图保护与全仓回执增量:保全并恢复四图,两个spec仅改截图输出路径,增加完整Git/tracked/index快照及14张登记证据校验;64项QA与typecheck通过,待独立复核。不重跑全套,不扩认证/业务断言,场景8/经理缺口/R1挂起。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "release-gate",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-06",
|
||||
"completedAt": null,
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "2026-09-07 §10已实施并完成一次环境有效的全套诊断,增量待用户独立复核。原21文件加6个QA环境/测试文件,共27文件;本轮产品源码和业务spec零改动,51项针对性验证通过。前三次启动在环境前置拒绝,资源已核对清理;修复跨user namespace读取父namespace的EACCES,改由父runner经本轮Unix IPC即时实测,不降级。第四次环境PASS、源码前后不变:65文件275项,199 passed/54 failed/4 timedOut/18 skipped,58条观测涉及36文件,不是独立产品缺陷计数;诊断仍PARTIAL/2。服务端193文件1692项通过,workflow场景8两个来源仍失败。R102 6/6通过;合同仍卡旧更多,续约/归档未执行;PWA在serviceWorker.ready超时且该失败无trace附件。诊断67分03秒不是普通全绿成本。旧207撤销决策资格但保留原件;阶段1、阶段B、场景8、经理缺口和R1均未扩大执行。无提交/push/归档/部署/生产操作,整体任务未完成。",
|
||||
"meta": {"archiveOutputProtection": "implemented_pending_independent_review", "registeredLocalEvidenceVerified": 14, "runtimeScreenshotsPreserved": 4, "targetedQaTestsPassed": 64, "fullDiagnosticRerun": false}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/07-18-small-team-internal-readiness");
|
||||
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright"));
|
||||
mkdirSync(visualRoot, { recursive: true });
|
||||
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "internal-readiness-ui-"));
|
||||
const WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
@@ -6,11 +6,10 @@ import { expect, test } from "./helpers/fixtures";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-finance-admin-visual-wave4");
|
||||
const taskOutputDir = path.resolve(
|
||||
process.cwd(),
|
||||
"../output/playwright/07-18-small-team-internal-readiness",
|
||||
);
|
||||
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright"));
|
||||
mkdirSync(visualRoot, { recursive: true });
|
||||
const outputDir = mkdtempSync(path.join(visualRoot, "pc-finance-admin-visual-wave4-"));
|
||||
const taskOutputDir = path.join(outputDir, "internal-readiness");
|
||||
|
||||
const visualUser = {
|
||||
identifier: "[email protected]",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { atomicEnvironmentJson, namespaceFingerprint, verifyManagedEnvironment } from "./full-local-environment.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export function guestProbeFailures(observed) {
|
||||
const failures = [];
|
||||
if (!observed.freshContext || observed.initialSessionHint !== false) failures.push("context_not_fresh");
|
||||
if (!observed.listenerBeforeNavigation || !observed.noRouteMocks) failures.push("observation_setup_invalid");
|
||||
if (!observed.loginFormVisible) failures.push("login_form_not_observed");
|
||||
if (observed.refreshRequestCount !== 0) failures.push("refresh_attempt_observed");
|
||||
const samples = [observed.nativeOnlineSamples?.beforeNavigation, observed.nativeOnlineSamples?.documentStart, observed.nativeOnlineSamples?.afterForm];
|
||||
if (samples.some(value => value !== true && value !== false)) failures.push("native_online_observation_missing");
|
||||
if (samples.includes(false)) failures.push("native_online_observed_false");
|
||||
if (observed.loginRequestCount !== 0) failures.push("unexpected_login_request");
|
||||
return failures;
|
||||
}
|
||||
export async function observeGuestEntry(context, baseURL, timeout = 30000) {
|
||||
const target = new URL("/login", baseURL);
|
||||
if (target.protocol !== "http:" || target.hostname !== "127.0.0.1") throw new Error("environment_probe_requires_loopback");
|
||||
const state = await context.storageState();
|
||||
const observed = {
|
||||
scope: "environment-probe", observationPoint: "guest-entry-black-box",
|
||||
freshContext: state.cookies.length === 0 && state.origins.length === 0,
|
||||
initialSessionHint: state.cookies.some(cookie => cookie.name === "has_session" && cookie.value === "1"),
|
||||
listenerBeforeNavigation: false, noRouteMocks: true, loginFormVisible: false,
|
||||
refreshRequestCount: 0, loginRequestCount: 0, refreshFailedCount: 0,
|
||||
nativeOnlineSamples: { beforeNavigation: null, documentStart: null, afterForm: null },
|
||||
documentPath: "/login", startedAt: new Date().toISOString(), valid: false,
|
||||
};
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
page.on("request", request => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === "/api/auth/refresh") observed.refreshRequestCount += 1;
|
||||
if (pathname === "/api/auth/login") observed.loginRequestCount += 1;
|
||||
});
|
||||
page.on("requestfailed", request => { if (new URL(request.url()).pathname === "/api/auth/refresh") observed.refreshFailedCount += 1; });
|
||||
page.on("console", message => {
|
||||
if (observed.nativeOnlineSamples.documentStart !== null) return;
|
||||
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:true") observed.nativeOnlineSamples.documentStart = true;
|
||||
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:false") observed.nativeOnlineSamples.documentStart = false;
|
||||
});
|
||||
await page.addInitScript(() => { if (window === window.top) console.info(`QA_ENV_DOCUMENT_ONLINE:${navigator.onLine}`); });
|
||||
observed.listenerBeforeNavigation = true;
|
||||
observed.nativeOnlineSamples.beforeNavigation = await page.evaluate(() => navigator.onLine);
|
||||
if (!observed.freshContext || observed.nativeOnlineSamples.beforeNavigation !== true) throw new Error("environment_guest_precondition_invalid");
|
||||
const response = await page.goto(target.href, { waitUntil: "load", timeout });
|
||||
observed.documentStatus = response?.status() ?? null;
|
||||
for (const testId of ["login-identifier", "login-password", "login-submit"]) await page.getByTestId(testId).waitFor({ state: "visible", timeout });
|
||||
observed.loginFormVisible = true;
|
||||
observed.nativeOnlineSamples.afterForm = await page.evaluate(() => navigator.onLine);
|
||||
observed.stayedOnLogin = new URL(page.url()).pathname === "/login";
|
||||
observed.failures = guestProbeFailures(observed);
|
||||
if (observed.documentStatus !== 200 || !observed.stayedOnLogin) observed.failures.push("candidate_login_document_invalid");
|
||||
observed.valid = observed.failures.length === 0;
|
||||
} catch (error) {
|
||||
observed.failures = guestProbeFailures(observed);
|
||||
observed.errorKind = error.name === "TimeoutError" ? "probe_timeout" : "probe_precondition_or_navigation_failed";
|
||||
} finally {
|
||||
observed.endedAt = new Date().toISOString();
|
||||
await page.close();
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
async function main(mode, baseURL) {
|
||||
if (!["native", "guest"].includes(mode)) throw new Error("environment_probe_mode_invalid");
|
||||
const { context: owner } = verifyManagedEnvironment();
|
||||
const require = createRequire(path.join(root, "client/package.json"));
|
||||
const { chromium } = require("@playwright/test");
|
||||
const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
|
||||
let browser;
|
||||
let context;
|
||||
let tracing = false;
|
||||
let result = { valid: false, scope: "environment-probe", unknown: true };
|
||||
const output = path.join(owner.environmentDir, mode === "native" ? "native-online.json" : "guest-entry.json");
|
||||
try {
|
||||
const browserNamespace = namespaceFingerprint(server.process().pid);
|
||||
const browserMatchesOwnedChild = browserNamespace === owner.ownedNamespace;
|
||||
if (!browserMatchesOwnedChild) throw new Error("environment_browser_namespace_mismatch");
|
||||
browser = await chromium.connect(server.wsEndpoint());
|
||||
context = await browser.newContext({ serviceWorkers: "block" });
|
||||
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
||||
tracing = true;
|
||||
if (mode === "native") {
|
||||
const page = await context.newPage();
|
||||
const online = await page.evaluate(() => navigator.onLine);
|
||||
result = { valid: online === true, scope: "blank-page-native-probe", online, errorKind: online === true ? null : online === false ? "native_online_observed_false" : "native_online_observation_missing", browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
|
||||
} else result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
|
||||
verifyManagedEnvironment();
|
||||
} catch (error) {
|
||||
result.valid = false;
|
||||
result.errorKind = error.message.startsWith("environment_") ? error.message : "environment_probe_failed";
|
||||
} finally {
|
||||
if (tracing) { try { await context.tracing.stop({ path: path.join(owner.environmentDir, `${mode}-trace.zip`) }); } catch { result.valid = false; result.traceFailed = true; } }
|
||||
await context?.close().catch(() => {});
|
||||
await browser?.close().catch(() => {});
|
||||
await server.close();
|
||||
atomicEnvironmentJson(output, result);
|
||||
}
|
||||
console.log(`[environment] ${mode} probe ${result.valid ? "PASS" : "FAIL"}`);
|
||||
if (!result.valid) process.exitCode = 1;
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main(process.argv[2], process.argv[3]).catch(error => { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : "environment_probe_failed"}`); process.exitCode = 1; });
|
||||
}
|
||||
@@ -1,11 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const registeredEvidence = [{
|
||||
document: ".trellis/tasks/archive/2026-07/07-18-small-team-internal-readiness/evidence.md",
|
||||
directory: "output/playwright/07-18-small-team-internal-readiness",
|
||||
count: 14,
|
||||
}];
|
||||
|
||||
export function validateRegisteredEvidence(root = repoRoot, registrations = registeredEvidence) {
|
||||
const result = { checked: 0, errors: [] };
|
||||
for (const registration of registrations) {
|
||||
let lines;
|
||||
try { lines = fs.readFileSync(path.join(root, registration.document), "utf8").split(/\r?\n/); }
|
||||
catch { result.errors.push(`evidence_document_missing:${registration.document}`); continue; }
|
||||
const header = lines.findIndex(line => /^\|\s*截图\s*\|\s*SHA-256\s*\|\s*$/i.test(line));
|
||||
if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*$/.test(lines[header + 1] ?? "")) { result.errors.push(`evidence_table_missing:${registration.document}`); continue; }
|
||||
const rows = [];
|
||||
for (let index = header + 2; index < lines.length && lines[index].startsWith("|"); index += 1) rows.push(lines[index]);
|
||||
if (rows.length !== registration.count) result.errors.push(`evidence_count_mismatch:${registration.document}`);
|
||||
const names = new Set();
|
||||
for (const row of rows) {
|
||||
const match = row.match(/^\|\s*`([^`]+)`\s*\|\s*`([a-f0-9]{64})`\s*\|\s*$/i);
|
||||
if (!match || !/^[\w.-]+\.png$/.test(match[1]) || match[1].startsWith(".") || names.has(match[1])) { result.errors.push(`evidence_row_invalid:${registration.document}`); continue; }
|
||||
names.add(match[1]);
|
||||
const relative = path.join(registration.directory, match[1]);
|
||||
const target = path.join(root, relative);
|
||||
try {
|
||||
if (!fs.lstatSync(target).isFile() || fs.realpathSync(target) !== path.join(fs.realpathSync(root), relative)) { result.errors.push(`evidence_not_regular_file:${relative}`); continue; }
|
||||
const observed = createHash("sha256").update(fs.readFileSync(target)).digest("hex");
|
||||
if (observed !== match[2].toLowerCase()) result.errors.push(`evidence_digest_mismatch:${relative}`);
|
||||
else result.checked += 1;
|
||||
} catch { result.errors.push(`evidence_file_missing_or_unreadable:${relative}`); }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const requiredSpecs = [
|
||||
".trellis/spec/hyb-oa/index.md",
|
||||
".trellis/spec/hyb-oa/project-governance.md",
|
||||
@@ -342,6 +378,9 @@ export function runGovernanceChecks() {
|
||||
checkDocsRegistration(result);
|
||||
checkRemovedProjectSkillReferences(result);
|
||||
checkSensitiveData(result);
|
||||
const evidence = validateRegisteredEvidence();
|
||||
for (const error of evidence.errors) fail(result, "evidence.sha256", error);
|
||||
if (!evidence.errors.length) pass(result, "evidence.sha256", `${evidence.checked} registered local evidence files verified`);
|
||||
} catch (error) {
|
||||
fail(result, "exception", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
qa_diagnostic_cleanup() {
|
||||
local command_status=$?
|
||||
local cleanup_status=0
|
||||
local owned_server="$QA_SERVER_OWNED"
|
||||
trap - EXIT
|
||||
qa_cleanup_server || cleanup_status=1
|
||||
if [[ "$owned_server" == "1" ]]; then
|
||||
qa_wait_port_free 15 || cleanup_status=1
|
||||
fi
|
||||
if [[ "$QA_DB_CREATED" == "1" ]]; then
|
||||
qa_dropdb || cleanup_status=1
|
||||
local remaining
|
||||
remaining="$(qa_psql -Atc "SELECT count(*) FROM pg_database WHERE datname = '$QA_DB_NAME'")" || cleanup_status=1
|
||||
[[ "$remaining" == "0" ]] || cleanup_status=1
|
||||
fi
|
||||
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({phase:process.argv[2],commandExit:Number(process.argv[3]),cleanupExit:Number(process.argv[4]),database:process.argv[5]}))' \
|
||||
"$QA_DIAGNOSTIC_STAGE_DIR/outcome.json" "$QA_DIAGNOSTIC_PHASE" "$command_status" "$cleanup_status" "$QA_DB_NAME"
|
||||
if [[ "$command_status" != "0" ]]; then exit "$command_status"; fi
|
||||
exit "$cleanup_status"
|
||||
}
|
||||
|
||||
qa_run_diagnostic_stage() {
|
||||
local stage="$1"
|
||||
[[ -d "${QA_DIAGNOSTIC_STAGE_DIR:-}" && "$QA_DB_NAME" =~ ^hyb_oa_diag_[a-z0-9_]+$ ]] || exit 2
|
||||
[[ "$QA_DB_HOST" == "127.0.0.1" && "$QA_DB_CLIENT" == "host" && "$QA_KEEP_DB" == "0" ]] || exit 2
|
||||
[[ -n "${QA_DIAGNOSTIC_REASON:-}" && -n "${QA_DIAGNOSTIC_OPERATOR:-}" && -n "${QA_DIAGNOSTIC_APPROVAL_REF:-}" ]] || exit 2
|
||||
QA_DIAGNOSTIC_PHASE=prepare
|
||||
trap qa_diagnostic_cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
qa_select_port
|
||||
qa_export_app_env
|
||||
case "$stage" in
|
||||
inputs)
|
||||
qa_prepare_full_inputs
|
||||
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({root:process.argv[2],buildA:process.argv[3],buildB:process.argv[4],shaA:process.argv[5],shaB:process.argv[6]}))' \
|
||||
"$QA_DIAGNOSTIC_STAGE_DIR/pwa-fixture.json" "$PWA_FIXTURE_ROOT" "$PWA_BUILD_A_DIR" "$PWA_BUILD_B_DIR" "$PWA_BUILD_A_SHA" "$PWA_BUILD_B_SHA"
|
||||
;;
|
||||
tools)
|
||||
cd "$SERVER_DIR"
|
||||
npm run prisma:generate
|
||||
npm run prisma:check-generated
|
||||
npm run prisma:validate
|
||||
;;
|
||||
server-unit)
|
||||
cd "$SERVER_DIR"
|
||||
QA_DIAGNOSTIC_PHASE=test
|
||||
npm test
|
||||
;;
|
||||
typecheck)
|
||||
cd "$CLIENT_DIR"
|
||||
QA_DIAGNOSTIC_PHASE=test
|
||||
npx tsc --noEmit --pretty false --extendedDiagnostics
|
||||
;;
|
||||
client-build)
|
||||
qa_build_client
|
||||
;;
|
||||
workflow-actions|server-regressions|browser)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_prepare_db
|
||||
if [[ "$stage" == "browser" ]]; then export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"; else export JOBS_ENABLED=false; fi
|
||||
qa_start_server
|
||||
QA_DIAGNOSTIC_PHASE=test
|
||||
cd "$SERVER_DIR"
|
||||
case "$stage" in
|
||||
workflow-actions) ./scripts/regression-workflow-actions.sh ;;
|
||||
server-regressions) ./scripts/regression-all.sh ;;
|
||||
browser)
|
||||
PWA_FIXTURE_ROOT="$QA_DIAGNOSTIC_PWA_ROOT"
|
||||
qa_run_full_browser --trace=on
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { closeSync, existsSync, lstatSync, readlinkSync, realpathSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const stagePlan = [
|
||||
{ id: "inputs", dependencies: [] },
|
||||
{ id: "tools", dependencies: [] },
|
||||
{ id: "server-unit", dependencies: ["tools"] },
|
||||
{ id: "typecheck", dependencies: [] },
|
||||
{ id: "client-build", dependencies: [] },
|
||||
{ id: "workflow-actions", dependencies: ["tools", "client-build"] },
|
||||
{ id: "server-regressions", dependencies: ["tools", "client-build"] },
|
||||
{ id: "browser", dependencies: ["inputs", "tools", "client-build"] },
|
||||
];
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function atomicJson(file, value) {
|
||||
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(`${file}.tmp`, file);
|
||||
}
|
||||
|
||||
function currentEnvironment(requireGuest = false) {
|
||||
const result = spawnSync("node", [path.join(root, "scripts/qa/full-local-environment.mjs"), requireGuest ? "report-guest" : "report"], { encoding: "utf8", timeout: 30000 });
|
||||
if (result.status !== 0) throw new Error("environment_global_precondition_failed");
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
export function createDiagnosticScratch() {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "hyb-qa-inventory-"));
|
||||
}
|
||||
|
||||
export function sourceSnapshot(directory = root) {
|
||||
directory = realpathSync(directory);
|
||||
const git = (args) => {
|
||||
const result = spawnSync("git", args, { cwd: directory, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
if (result.status !== 0) throw new Error("diagnostic_source_unavailable");
|
||||
return result.stdout;
|
||||
};
|
||||
const hash = value => createHash("sha256").update(value).digest("hex");
|
||||
const snapshotFile = file => {
|
||||
const target = path.join(directory, file);
|
||||
let stat;
|
||||
try { stat = lstatSync(target); } catch (error) { if (error.code === "ENOENT") return { file, kind: "missing", sha256: null }; throw error; }
|
||||
if (realpathSync(path.dirname(target)) !== path.dirname(target)) return { file, kind: "unsupported", sha256: null };
|
||||
const kind = stat.isSymbolicLink() ? "symlink" : stat.isFile() ? "file" : "unsupported";
|
||||
return { file, kind, mode: stat.mode & 0o777, sha256: kind === "symlink" ? hash(readlinkSync(target)) : kind === "file" ? hash(readFileSync(target)) : null };
|
||||
};
|
||||
const files = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "scripts/qa", "server", "client", ".trellis/spec"])
|
||||
.split("\0").filter(Boolean).sort().map(file => ({ file, sha256: snapshotFile(file).sha256 }));
|
||||
const trackedFiles = [...new Set(git(["ls-files", "--cached", "-z"]).split("\0").filter(Boolean))].sort().map(snapshotFile);
|
||||
const porcelainV1Z = git(["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
|
||||
const indexEntriesZ = git(["ls-files", "--stage", "-z"]);
|
||||
const indexFlagsZ = git(["ls-files", "-v", "-z"]);
|
||||
const repository = {
|
||||
porcelainV1Z, statusShort: git(["status", "--short", "--untracked-files=all"]),
|
||||
trackedFiles, trackedDigest: hash(JSON.stringify(trackedFiles)),
|
||||
indexEntriesZ, indexFlagsZ, indexDigest: hash(JSON.stringify([indexEntriesZ, indexFlagsZ])),
|
||||
trackedCoverageComplete: trackedFiles.every(entry => entry.kind !== "unsupported"),
|
||||
};
|
||||
return {
|
||||
commit: git(["rev-parse", "HEAD"]).trim(),
|
||||
tree: git(["rev-parse", "HEAD^{tree}"]).trim(),
|
||||
dirty: porcelainV1Z.length !== 0,
|
||||
repository,
|
||||
files,
|
||||
digest: createHash("sha256").update(JSON.stringify(files)).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function compareSnapshots(before, after) {
|
||||
const identityUnchanged = Boolean(before && after && before.commit === after.commit && before.tree === after.tree);
|
||||
const sourceUnchanged = identityUnchanged && before.digest === after.digest;
|
||||
const repositoryComparable = [before, after].every(snapshot => snapshot?.repository?.trackedCoverageComplete && /^[a-f0-9]{64}$/.test(snapshot.repository.trackedDigest ?? "") && /^[a-f0-9]{64}$/.test(snapshot.repository.indexDigest ?? "") && typeof snapshot.repository.porcelainV1Z === "string");
|
||||
const repositoryUnchanged = Boolean(identityUnchanged && repositoryComparable && before.repository.trackedDigest === after.repository.trackedDigest && before.repository.indexDigest === after.repository.indexDigest && before.repository.porcelainV1Z === after.repository.porcelainV1Z);
|
||||
const candidateIntegrityStatus = !repositoryComparable ? "UNKNOWN" : sourceUnchanged && repositoryUnchanged ? "UNCHANGED" : "CHANGED";
|
||||
return { sourceUnchanged, repositoryComparable, repositoryUnchanged, candidateIntegrityStatus, cleanAtStart: before?.dirty === false, cleanAtEnd: after?.dirty === false };
|
||||
}
|
||||
|
||||
export function browserInventory(discovery, execution) {
|
||||
const actual = new Map();
|
||||
const key = (entry) => JSON.stringify([entry.id, entry.projectName ?? "", entry.repeatEachIndex ?? 0]);
|
||||
const issues = [];
|
||||
function visit(suites) {
|
||||
for (const suite of suites ?? []) {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
const identity = { id: spec.id, projectName: test.projectName, repeatEachIndex: test.repeatEachIndex };
|
||||
if (actual.has(key(identity))) issues.push("duplicate_case");
|
||||
actual.set(key(identity), {
|
||||
reportedStatus: test.status,
|
||||
expectedStatus: test.expectedStatus,
|
||||
results: (test.results ?? []).map(result => ({ status: result.status, retry: result.retry, duration: result.duration })),
|
||||
});
|
||||
}
|
||||
}
|
||||
visit(suite.suites);
|
||||
}
|
||||
}
|
||||
visit(execution?.suites);
|
||||
const cases = (discovery?.cases ?? []).map(entry => {
|
||||
const observed = actual.get(key(entry));
|
||||
actual.delete(key(entry));
|
||||
return { ...entry, status: observed?.results.at(-1)?.status ?? "NOT_RUN", ...observed };
|
||||
});
|
||||
if (actual.size) issues.push("unknown_cases");
|
||||
return { cases, issues, executionErrors: execution?.errors?.length ?? 0, reportPresent: Boolean(execution) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const approval = {};
|
||||
for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
|
||||
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
|
||||
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
|
||||
approval[name.toLowerCase()] = value;
|
||||
}
|
||||
if ((process.env.QA_DB_HOST ?? "127.0.0.1") !== "127.0.0.1" || (process.env.QA_DB_CLIENT ?? "host") !== "host") throw new Error("diagnostic_requires_local_host_database");
|
||||
if (process.env.QA_KEEP_DB === "1" || process.env.QA_SKIP_CLIENT_BUILD === "1") throw new Error("diagnostic_reuse_not_allowed");
|
||||
for (const name of ["PLAYWRIGHT_MANUAL", "R70_TRIALRUN_ASSIST", "R70_TRIALRUN_DEEP_ASSIST", "PLAYWRIGHT_OCR_LIVE_BATCH"]) {
|
||||
if (process.env[name] === "1") throw new Error("diagnostic_scope_override_not_allowed");
|
||||
}
|
||||
const runId = `${Date.now()}_${randomBytes(5).toString("hex")}`;
|
||||
const reportDir = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow/full-local-diagnostic", runId));
|
||||
if (existsSync(reportDir) && readdirSync(reportDir).length) throw new Error("diagnostic_report_directory_not_empty");
|
||||
mkdirSync(reportDir, { recursive: true, mode: 0o700 });
|
||||
const scratch = createDiagnosticScratch();
|
||||
const report = {
|
||||
schemaVersion: 1, kind: "FULL_LOCAL_DIAGNOSTIC_NOT_RELEASE_RECEIPT", profile: "full-local-diagnostic", status: "PARTIAL",
|
||||
runId, approval, startedAt: new Date().toISOString(), endedAt: null,
|
||||
nodeVersion: process.version, ci: process.env.CI ?? null,
|
||||
stages: stagePlan.map(stage => ({ ...stage, status: "NOT_RUN" })),
|
||||
limits: ["No release eligibility, even with zero observed failures.", "Nested fail-fast and serial dependencies remain observable coverage gaps.", "server-regressions includes a separate workflow-actions invocation.", "Diagnostic duration is not normal full-local green-path duration."],
|
||||
};
|
||||
let interrupted = null;
|
||||
let activeChild = null;
|
||||
const save = () => atomicJson(path.join(reportDir, "diagnostic-results.json"), report);
|
||||
const interrupt = (signal) => {
|
||||
interrupted = signal;
|
||||
if (activeChild?.pid) {
|
||||
try { process.kill(-activeChild.pid, signal); } catch { report.signalDeliveryFailed = true; }
|
||||
}
|
||||
};
|
||||
const onInt = () => interrupt("SIGINT");
|
||||
const onTerm = () => interrupt("SIGTERM");
|
||||
process.on("SIGINT", onInt);
|
||||
process.on("SIGTERM", onTerm);
|
||||
try {
|
||||
report.environmentBefore = currentEnvironment();
|
||||
report.sourceBefore = sourceSnapshot();
|
||||
save();
|
||||
for (const stage of report.stages) {
|
||||
const blockedBy = stage.dependencies.filter(id => report.stages.find(item => item.id === id).status !== "PASS");
|
||||
if (interrupted || blockedBy.length) {
|
||||
stage.reason = interrupted ? "interrupted" : "prerequisite_failed";
|
||||
stage.blockedBy = blockedBy;
|
||||
save();
|
||||
continue;
|
||||
}
|
||||
stage.environmentBefore = currentEnvironment();
|
||||
const stageDir = path.join(reportDir, stage.id);
|
||||
const temporary = path.join(scratch, stage.id);
|
||||
mkdirSync(stageDir);
|
||||
mkdirSync(temporary);
|
||||
mkdirSync(path.join(temporary, "storage"));
|
||||
const environment = {
|
||||
...process.env,
|
||||
QA_DB_HOST: "127.0.0.1", QA_DB_CLIENT: "host", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0",
|
||||
QA_DB_NAME: `hyb_oa_diag_${runId}_${stage.id.replaceAll("-", "_")}`,
|
||||
QA_DIAGNOSTIC_STAGE: stage.id, QA_DIAGNOSTIC_STAGE_DIR: stageDir,
|
||||
QA_FULL_REPORT_DIR: path.join(reportDir, "full-local-report"),
|
||||
QA_SERVER_LOG: path.join(stageDir, "server.log"), QA_SERVER_PID_FILE: path.join(stageDir, "server.pid"),
|
||||
TMPDIR: temporary, STORAGE_DIR: path.join(temporary, "storage"), STORAGE_DRIVER: "local",
|
||||
QA_HOST: "127.0.0.1", JOBS_ENABLED: "false", E2E_MODE: "local",
|
||||
};
|
||||
if (stage.id === "browser") {
|
||||
const fixture = readJson(path.join(reportDir, "inputs/pwa-fixture.json"));
|
||||
if (!fixture.root.startsWith(`${path.join(scratch, "inputs")}${path.sep}`)) throw new Error("diagnostic_fixture_ownership_invalid");
|
||||
Object.assign(environment, { QA_DIAGNOSTIC_PWA_ROOT: fixture.root, PWA_BUILD_A_DIR: fixture.buildA, PWA_BUILD_B_DIR: fixture.buildB, PWA_BUILD_A_SHA: fixture.shaA, PWA_BUILD_B_SHA: fixture.shaB });
|
||||
}
|
||||
stage.command = ["bash", "scripts/qa/run-web-gates.sh", "__full-local-diagnostic-stage", stage.id];
|
||||
stage.database = environment.QA_DB_NAME;
|
||||
stage.startedAt = new Date().toISOString();
|
||||
stage.status = "RUNNING";
|
||||
save();
|
||||
console.log(`[diagnostic] ${stage.id} started`);
|
||||
const descriptor = openSync(path.join(stageDir, "stage.log"), "w", 0o600);
|
||||
const outcome = await new Promise(resolve => {
|
||||
activeChild = spawn("bash", stage.command.slice(1), { cwd: root, env: environment, stdio: ["ignore", descriptor, descriptor], detached: true });
|
||||
activeChild.once("error", () => resolve({ code: 1, signal: null, spawnFailed: true }));
|
||||
activeChild.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
activeChild = null;
|
||||
closeSync(descriptor);
|
||||
stage.endedAt = new Date().toISOString();
|
||||
stage.durationMs = Date.parse(stage.endedAt) - Date.parse(stage.startedAt);
|
||||
stage.exitCode = outcome.code;
|
||||
stage.signal = outcome.signal;
|
||||
const completion = path.join(stageDir, "outcome.json");
|
||||
stage.completion = existsSync(completion) ? readJson(completion) : null;
|
||||
stage.status = outcome.code === 0 && stage.completion?.cleanupExit === 0 ? "PASS" : "FAIL";
|
||||
stage.failureKind = stage.status === "PASS" ? null : stage.completion?.cleanupExit ? "cleanup" : stage.completion?.phase === "test" ? "test" : "environment";
|
||||
stage.environmentAfter = currentEnvironment(stage.id === "browser");
|
||||
save();
|
||||
console.log(`[diagnostic] ${stage.id} ${stage.status} exit=${outcome.code}`);
|
||||
}
|
||||
} catch (error) {
|
||||
report.runnerError = error.message;
|
||||
} finally {
|
||||
report.interrupted = interrupted;
|
||||
report.environmentQualified = Boolean(report.stages.find(stage => stage.id === "browser")?.environmentAfter?.bootstrapObservation?.valid) && !report.runnerError;
|
||||
for (const stage of report.stages) {
|
||||
if (stage.status === "NOT_RUN" && !stage.reason) stage.reason = interrupted ? "interrupted" : "runner_error";
|
||||
}
|
||||
const discoveryFile = path.join(reportDir, "full-local-report/discovery-coverage.json");
|
||||
const browserFile = path.join(reportDir, "full-local-report/playwright-results.json");
|
||||
let discovery = null;
|
||||
try {
|
||||
discovery = existsSync(discoveryFile) ? readJson(discoveryFile) : null;
|
||||
report.browser = browserInventory(discovery, existsSync(browserFile) ? readJson(browserFile) : null);
|
||||
} catch {
|
||||
report.browser = browserInventory(discovery, null);
|
||||
report.browser.issues.push("report_unreadable");
|
||||
}
|
||||
try { rmSync(scratch, { recursive: true }); report.scratchCleanup = "PASS"; } catch { report.scratchCleanup = "FAIL"; }
|
||||
try {
|
||||
report.sourceAfter = sourceSnapshot();
|
||||
Object.assign(report, compareSnapshots(report.sourceBefore, report.sourceAfter));
|
||||
} catch { report.repositorySnapshotError = "post_run_snapshot_unavailable"; report.repositoryUnchanged = false; report.candidateIntegrityStatus = "UNKNOWN"; }
|
||||
report.endedAt = new Date().toISOString();
|
||||
report.durationMs = Date.parse(report.endedAt) - Date.parse(report.startedAt);
|
||||
report.exitCode = 2;
|
||||
save();
|
||||
process.off("SIGINT", onInt);
|
||||
process.off("SIGTERM", onTerm);
|
||||
}
|
||||
console.log("[diagnostic] PARTIAL — no release eligibility");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main().catch(error => { console.error(`[diagnostic] ${error.message}`); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
PROXY_PID=""
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if ! node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" complete; then status=1; fi
|
||||
if [[ -n "$PROXY_PID" ]]; then
|
||||
kill "$PROXY_PID" 2>/dev/null || true
|
||||
wait "$PROXY_PID" 2>/dev/null || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
ip link set lo up
|
||||
ip link add qaonline type veth peer name qapeer
|
||||
ip addr add 192.0.2.1/30 dev qaonline
|
||||
ip addr add 192.0.2.2/30 dev qapeer
|
||||
ip link set qaonline up
|
||||
ip link set qapeer up
|
||||
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" initialize
|
||||
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" native
|
||||
mkdir "$QA_MANAGED_SCRATCH/tmp" "$QA_MANAGED_SCRATCH/storage"
|
||||
export TMPDIR="$QA_MANAGED_SCRATCH/tmp" STORAGE_DIR="$QA_MANAGED_SCRATCH/storage"
|
||||
python3 "$ROOT_DIR/scripts/qa/local-postgres-proxy.py" "$QA_MANAGED_SOCKET/.s.PGSQL.5432" &
|
||||
PROXY_PID=$!
|
||||
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
|
||||
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" "$1"
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync, readlinkSync, readdirSync, mkdirSync, mkdtempSync, chmodSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { createServer, createConnection } from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { sourceSnapshot, compareSnapshots } from "./full-local-diagnostic.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"];
|
||||
const sha = value => createHash("sha256").update(value).digest("hex");
|
||||
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
|
||||
export function atomicEnvironmentJson(file, value) {
|
||||
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(`${file}.tmp`, file);
|
||||
}
|
||||
function command(program, args) {
|
||||
const result = spawnSync(program, args, { encoding: "utf8", timeout: 30000 });
|
||||
if (result.status !== 0) throw new Error(`environment_command_failed:${path.basename(program)}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
export function environmentPolicyDigest() {
|
||||
return sha(JSON.stringify(policyFiles.map(file => [file, sha(readFileSync(path.join(root, file)))])));
|
||||
}
|
||||
export function namespaceFingerprint(pid = "self") {
|
||||
try { return sha(readlinkSync(`/proc/${pid}/ns/net`)); }
|
||||
catch (error) { throw new Error(`environment_namespace_unreadable:${pid === "self" ? "self" : "other"}:${error.code ?? error.name}`); }
|
||||
}
|
||||
function parentPid(pid) {
|
||||
const value = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
return Number(value.slice(value.lastIndexOf(")") + 2).split(" ")[1]);
|
||||
}
|
||||
function descendsFrom(pid, ancestor) {
|
||||
const visited = new Set();
|
||||
while (pid > 1 && !visited.has(pid)) {
|
||||
if (pid === ancestor) return true;
|
||||
visited.add(pid);
|
||||
pid = parentPid(pid);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export async function requestSupervisorObservation(context) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(path.join(context.scratch, "supervisor.sock"));
|
||||
let response = "";
|
||||
socket.setTimeout(5000, () => socket.destroy(new Error("environment_supervisor_timeout")));
|
||||
socket.on("error", () => reject(new Error("environment_supervisor_unavailable")));
|
||||
socket.on("data", data => {
|
||||
response += data;
|
||||
if (response.length > 4096) socket.destroy(new Error("environment_supervisor_response_invalid"));
|
||||
});
|
||||
socket.on("end", () => {
|
||||
try {
|
||||
const observation = JSON.parse(response);
|
||||
if (observation.runId !== context.runId || observation.pid !== context.supervisorPid || typeof observation.namespace !== "string" || observation.policyDigest !== context.policyDigest) throw new Error();
|
||||
resolve(observation);
|
||||
} catch { reject(new Error("environment_supervisor_response_invalid")); }
|
||||
});
|
||||
});
|
||||
}
|
||||
export function topologyMatches(addresses, routes, ipv6Routes) {
|
||||
const names = addresses.map(entry => entry.ifname).sort();
|
||||
if (JSON.stringify(names) !== JSON.stringify(["lo", "qaonline", "qapeer"])) return false;
|
||||
for (const [name, address] of [["qaonline", "192.0.2.1"], ["qapeer", "192.0.2.2"]]) {
|
||||
const entry = addresses.find(item => item.ifname === name);
|
||||
if (!entry.flags?.includes("UP") || entry.operstate !== "UP" || !entry.addr_info?.some(item => item.local === address && item.prefixlen === 30)) return false;
|
||||
}
|
||||
return ![...routes, ...ipv6Routes].some(entry => entry.dst === "default" || entry.gateway);
|
||||
}
|
||||
function topology() {
|
||||
return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])));
|
||||
}
|
||||
export function validateEnvironmentFacts(context, facts) {
|
||||
const failures = [];
|
||||
if (context.schemaVersion !== 1 || context.profile !== "isolated-online-v1") failures.push("profile_invalid");
|
||||
if (context.policyDigest !== facts.policyDigest) failures.push("policy_changed");
|
||||
if (!facts.supervisorRecognized || !facts.descendantOfSupervisor) failures.push("supervisor_unverified");
|
||||
if (context.inheritedNamespace !== facts.supervisorNamespace) failures.push("supervisor_namespace_changed");
|
||||
if (!context.ownedNamespace || context.ownedNamespace === context.inheritedNamespace || context.ownedNamespace !== facts.currentNamespace) failures.push("namespace_unowned");
|
||||
if (!facts.topologyMatched) failures.push("topology_invalid");
|
||||
return failures;
|
||||
}
|
||||
export function verifyManagedEnvironment(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, initialize = false) {
|
||||
if (!contextFile || !existsSync(contextFile)) throw new Error("environment_context_required");
|
||||
const context = readJson(contextFile);
|
||||
const actualFile = path.resolve(contextFile);
|
||||
if (actualFile !== path.join(context.environmentDir, "context.private.json")) throw new Error("environment_context_path_invalid");
|
||||
const supervisorArgs = readFileSync(`/proc/${context.supervisorPid}/cmdline`, "utf8").split("\0");
|
||||
const supervisor = JSON.parse(command(process.execPath, [path.join(root, "scripts/qa/full-local-environment.mjs"), "observe-supervisor", actualFile]));
|
||||
const facts = {
|
||||
policyDigest: environmentPolicyDigest(),
|
||||
supervisorRecognized: supervisorArgs.includes(path.join(root, "scripts/qa/full-local-environment.mjs")),
|
||||
descendantOfSupervisor: descendsFrom(process.pid, context.supervisorPid),
|
||||
supervisorNamespace: supervisor.namespace,
|
||||
supervisorObservation: supervisor,
|
||||
currentNamespace: namespaceFingerprint(),
|
||||
topologyMatched: topology(),
|
||||
};
|
||||
if (initialize && !context.ownedNamespace) context.ownedNamespace = facts.currentNamespace;
|
||||
const failures = validateEnvironmentFacts(context, facts);
|
||||
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
|
||||
if (initialize) atomicEnvironmentJson(actualFile, context);
|
||||
return { context, facts };
|
||||
}
|
||||
export function validateEnvironmentReceipt(receipt, digest, requireGuest = true) {
|
||||
const failures = [];
|
||||
if (!receipt || receipt.schemaVersion !== 1 || receipt.profile !== "isolated-online-v1" || receipt.policyDigest !== digest) failures.push("environment_receipt_policy_invalid");
|
||||
const namespace = receipt?.namespace;
|
||||
if (receipt?.supervisorObservation?.source !== "live-supervisor-unix-ipc" || !Number.isFinite(Date.parse(receipt?.supervisorObservation?.sampledAt)) || receipt?.supervisorObservation?.namespace !== namespace?.inheritedRunner) failures.push("supervisor_observation_invalid");
|
||||
if (!namespace?.inheritedRunner || !namespace.ownedChild || namespace.inheritedRunner === namespace.ownedChild || namespace.runnerCreated !== true || namespace.currentMatchesOwnedChild !== true || namespace.topologyMatched !== true) failures.push("environment_receipt_namespace_invalid");
|
||||
if (receipt?.nativeOnlineProbe?.browserNamespace !== namespace?.ownedChild || receipt?.nativeOnlineProbe?.noNavigatorOverride !== true) failures.push("native_probe_namespace_invalid");
|
||||
if (!receipt?.nativeOnlineProbe?.valid || receipt.nativeOnlineProbe.online !== true || !receipt.nativeOnlineProbe.browserMatchesOwnedChild) failures.push("native_probe_invalid");
|
||||
if (requireGuest) {
|
||||
const observed = receipt?.bootstrapObservation;
|
||||
if (observed?.browserNamespace !== namespace?.ownedChild || observed?.noNavigatorOverride !== true || observed?.loginRequestCount !== 0 || observed?.documentStatus !== 200 || observed?.stayedOnLogin !== true) failures.push("guest_probe_evidence_invalid");
|
||||
if (!observed || observed.observationPoint !== "guest-entry-black-box" || !observed.freshContext || observed.initialSessionHint !== false || !observed.listenerBeforeNavigation || !observed.noRouteMocks || !observed.loginFormVisible || observed.refreshRequestCount !== 0 || observed.nativeOnlineSamples?.beforeNavigation !== true || observed.nativeOnlineSamples?.documentStart !== true || observed.nativeOnlineSamples?.afterForm !== true || !observed.browserMatchesOwnedChild || !observed.valid) failures.push("guest_probe_invalid");
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
export function environmentReceipt(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, requireGuest = false) {
|
||||
const { context, facts } = verifyManagedEnvironment(contextFile);
|
||||
const nativeFile = path.join(context.environmentDir, "native-online.json");
|
||||
const guestFile = path.join(context.environmentDir, "guest-entry.json");
|
||||
const receipt = {
|
||||
schemaVersion: 1, profile: context.profile, policyDigest: context.policyDigest,
|
||||
platform: process.platform, runnerDigest: sha(readFileSync(path.join(root, "scripts/qa/full-local-environment.mjs"))), probeDigest: sha(readFileSync(path.join(root, "scripts/qa/browser-environment-probe.mjs"))),
|
||||
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: facts.supervisorObservation.sampledAt, namespace: facts.supervisorNamespace },
|
||||
namespace: { inheritedRunner: context.inheritedNamespace, ownedChild: context.ownedNamespace, currentMatchesOwnedChild: facts.currentNamespace === context.ownedNamespace, topologyMatched: facts.topologyMatched, runnerCreated: true },
|
||||
nativeOnlineProbe: existsSync(nativeFile) ? readJson(nativeFile) : null,
|
||||
bootstrapObservation: existsSync(guestFile) ? readJson(guestFile) : null,
|
||||
};
|
||||
const failures = validateEnvironmentReceipt(receipt, context.policyDigest, requireGuest);
|
||||
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
|
||||
return receipt;
|
||||
}
|
||||
async function launch(mode) {
|
||||
if (!["full-local", "full-local-diagnostic"].includes(mode)) throw new Error("environment_mode_invalid");
|
||||
if (process.platform !== "linux") throw new Error("environment_platform_unsupported");
|
||||
if (mode === "full-local-diagnostic") for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
|
||||
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
|
||||
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
|
||||
}
|
||||
const runId = `${Date.now()}-${randomBytes(5).toString("hex")}`;
|
||||
const base = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow", mode, runId));
|
||||
if (!base.startsWith(`${path.join(root, "artifacts/qa-flow")}${path.sep}`)) throw new Error("environment_report_outside_artifacts");
|
||||
if (existsSync(base) && readdirSync(base).length) throw new Error("environment_report_not_empty");
|
||||
mkdirSync(base, { recursive: true, mode: 0o700 });
|
||||
const environmentDir = path.join(base, "environment");
|
||||
mkdirSync(environmentDir);
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-qa-managed-"));
|
||||
const socket = path.join(scratch, "socket");
|
||||
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777);
|
||||
const owner = `hyb-qa-${runId}`;
|
||||
const record = { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: environmentPolicyDigest(), platform: process.platform, mode, startedAt: new Date().toISOString(), status: "RUNNING", normalReleaseEligible: false };
|
||||
const context = { schemaVersion: 1, profile: record.profile, policyDigest: record.policyDigest, runId, supervisorPid: process.pid, inheritedNamespace: namespaceFingerprint(), ownedNamespace: null, environmentDir, scratch, socket, mode };
|
||||
const contextFile = path.join(environmentDir, "context.private.json");
|
||||
atomicEnvironmentJson(contextFile, context);
|
||||
let containerId = null;
|
||||
let child = null;
|
||||
let exitCode = 1;
|
||||
let interrupted = null;
|
||||
let supervisorServer = null;
|
||||
const forward = signal => { interrupted = signal; if (child?.pid) { try { process.kill(-child.pid, signal); } catch {} } };
|
||||
const onInt = () => forward("SIGINT");
|
||||
const onTerm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", onInt); process.on("SIGTERM", onTerm);
|
||||
try {
|
||||
record.sourceBefore = sourceSnapshot();
|
||||
const image = command("docker", ["image", "inspect", "--format", "{{.Id}}", "postgres:15-alpine"]);
|
||||
containerId = command("docker", ["run", "--detach", "--pull=never", "--name", owner, "--label", `hyb.qa.owner=${owner}`, "--network", "none", "--tmpfs", "/var/lib/postgresql/data:rw,size=512m", "--mount", `type=bind,src=${socket},dst=/var/run/postgresql`, "-e", "POSTGRES_HOST_AUTH_METHOD=trust", image]);
|
||||
if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error("environment_container_id_invalid");
|
||||
record.databaseImageId = image;
|
||||
writeFileSync(path.join(environmentDir, "container.private"), containerId, { mode: 0o600 });
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60 && !interrupted; attempt += 1) {
|
||||
if (spawnSync("docker", ["exec", containerId, "pg_isready", "-U", "postgres"], { stdio: "ignore", timeout: 10000 }).status === 0) { ready = true; break; }
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!ready) throw new Error("environment_database_not_ready");
|
||||
supervisorServer = createServer(connection => {
|
||||
connection.on("error", () => connection.destroy());
|
||||
try {
|
||||
connection.end(JSON.stringify({ runId, pid: process.pid, namespace: namespaceFingerprint(), policyDigest: environmentPolicyDigest(), sampledAt: new Date().toISOString() }));
|
||||
} catch { connection.destroy(); }
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
supervisorServer.once("error", reject);
|
||||
supervisorServer.listen(path.join(scratch, "supervisor.sock"), resolve);
|
||||
});
|
||||
chmodSync(path.join(scratch, "supervisor.sock"), 0o600);
|
||||
const env = {};
|
||||
for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF"]) if (process.env[name] !== undefined) env[name] = process.env[name];
|
||||
Object.assign(env, { QA_ENVIRONMENT_CONTEXT: contextFile, QA_FULL_REPORT_DIR: path.join(base, "results"), QA_DIAGNOSTIC_REPORT_DIR: path.join(base, "results"), QA_MANAGED_SCRATCH: scratch, QA_MANAGED_SOCKET: socket, QA_HOST: "127.0.0.1", QA_PORT: "23000", QA_DB_HOST: "127.0.0.1", QA_DB_PORT: "25432", QA_DB_CLIENT: "host", QA_DB_USER: "postgres", QA_DB_PASSWORD: "local_qa_only", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0", E2E_MODE: "local", WECOM_ENABLED: "false", EMAIL_ENABLED: "false", OIDC_ENABLED: "false", STORAGE_DRIVER: "local", NODE_ENV: "test", npm_config_offline: "true", NO_COLOR: "1" });
|
||||
const outcome = await new Promise(resolve => {
|
||||
child = spawn("unshare", ["-Urn", "bash", path.join(root, "scripts/qa/full-local-environment-inner.sh"), mode], { cwd: root, env, stdio: "inherit", detached: true });
|
||||
child.once("error", () => resolve({ code: 1, signal: null }));
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
child = null;
|
||||
exitCode = outcome.code ?? 1;
|
||||
record.childExit = outcome.code; record.childSignal = outcome.signal;
|
||||
const completionFile = path.join(environmentDir, "completed.json");
|
||||
if (existsSync(completionFile)) {
|
||||
const completion = readJson(completionFile);
|
||||
record.environment = completion;
|
||||
record.status = validateEnvironmentReceipt(completion, environmentPolicyDigest(), true).length ? "ENVIRONMENT_INVALID" : "PASS";
|
||||
} else record.status = "ENVIRONMENT_INVALID";
|
||||
if (record.status !== "PASS") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
|
||||
} catch (error) {
|
||||
record.status = "ENVIRONMENT_INVALID";
|
||||
record.error = error.message.startsWith("environment_") ? error.message : "environment_internal_error";
|
||||
exitCode = mode === "full-local-diagnostic" ? 2 : 1;
|
||||
} finally {
|
||||
if (supervisorServer) await new Promise(resolve => supervisorServer.close(resolve));
|
||||
record.cleanup = { containerOwnerVerified: false, containerRemoved: containerId === null, scratchRemoved: false };
|
||||
if (containerId) {
|
||||
try {
|
||||
const identity = command("docker", ["inspect", "--format", '{{.Id}}|{{index .Config.Labels "hyb.qa.owner"}}|{{.HostConfig.NetworkMode}}', containerId]);
|
||||
if (identity !== `${containerId}|${owner}|none`) throw new Error("environment_cleanup_owner_mismatch");
|
||||
record.cleanup.containerOwnerVerified = true;
|
||||
spawnSync("docker", ["exec", containerId, "chmod", "0777", "/var/run/postgresql"], { stdio: "ignore", timeout: 10000 });
|
||||
command("docker", ["rm", "-f", containerId]);
|
||||
record.cleanup.containerRemoved = true;
|
||||
} catch { record.cleanup.error = "owned_container_cleanup_failed"; }
|
||||
}
|
||||
if (record.cleanup.containerRemoved) {
|
||||
try { rmSync(scratch, { recursive: true }); record.cleanup.scratchRemoved = true; } catch { record.cleanup.error = "owned_scratch_cleanup_failed"; }
|
||||
}
|
||||
if (!record.cleanup.containerRemoved || !record.cleanup.scratchRemoved) { record.status = "ENVIRONMENT_INVALID"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
|
||||
try {
|
||||
record.sourceAfter = sourceSnapshot();
|
||||
Object.assign(record, compareSnapshots(record.sourceBefore, record.sourceAfter));
|
||||
if (record.candidateIntegrityStatus !== "UNCHANGED") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
|
||||
} catch { record.candidateIntegrityStatus = "UNKNOWN"; record.repositorySnapshotError = "post_run_snapshot_unavailable"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
|
||||
record.interrupted = interrupted; record.endedAt = new Date().toISOString(); record.exitCode = exitCode;
|
||||
atomicEnvironmentJson(path.join(environmentDir, "result.json"), record);
|
||||
process.off("SIGINT", onInt); process.off("SIGTERM", onTerm);
|
||||
}
|
||||
console.log(`[environment] ${record.status}; ${mode === "full-local-diagnostic" ? "PARTIAL; " : ""}exit=${exitCode}; no release activation`);
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const action = process.argv[2];
|
||||
if (action === "observe-supervisor") console.log(JSON.stringify(await requestSupervisorObservation(readJson(process.argv[3]))));
|
||||
else if (action === "initialize") verifyManagedEnvironment(undefined, true);
|
||||
else if (action === "verify") environmentReceipt(undefined, false);
|
||||
else if (action === "report" || action === "report-guest") console.log(JSON.stringify(environmentReceipt(undefined, action === "report-guest")));
|
||||
else if (action === "complete") {
|
||||
const receipt = environmentReceipt(undefined, false);
|
||||
const { context } = verifyManagedEnvironment();
|
||||
atomicEnvironmentJson(path.join(context.environmentDir, "completed.json"), receipt);
|
||||
} else await launch(action);
|
||||
} catch (error) { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : `environment_verification_failed:${error.code ?? error.name}`}`); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
|
||||
async def copy_stream(reader, writer):
|
||||
try:
|
||||
while data := await reader.read(65536):
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
|
||||
async def connect_database(reader, writer):
|
||||
try:
|
||||
database_reader, database_writer = await asyncio.open_unix_connection(sys.argv[1])
|
||||
except OSError:
|
||||
writer.close()
|
||||
return
|
||||
await asyncio.gather(copy_stream(reader, database_writer), copy_stream(database_reader, writer))
|
||||
|
||||
|
||||
async def main():
|
||||
server = await asyncio.start_server(connect_database, "127.0.0.1", 25432)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PWA_FIXTURE_ROOT=""
|
||||
|
||||
pwa_build_candidate() {
|
||||
local client_dir="$1"
|
||||
local label="$2"
|
||||
local build_sha="$3"
|
||||
local output_dir="$4"
|
||||
local log_file="$5"
|
||||
local build_status=0
|
||||
|
||||
(
|
||||
cd "$client_dir"
|
||||
VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
|
||||
) > "$log_file" 2>&1 || build_status="$?"
|
||||
|
||||
if [[ "$build_status" -ne 0 ]]; then
|
||||
echo "[pwa-cross-version][ERROR] build=${label} exit=${build_status}" >&2
|
||||
return "$build_status"
|
||||
fi
|
||||
if [[ ! -f "$output_dir/index.html" || ! -f "$output_dir/sw.js" ]]; then
|
||||
echo "[pwa-cross-version][ERROR] build=${label} required_artifact_missing" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[pwa-cross-version] build=${label} status=PASS"
|
||||
}
|
||||
|
||||
pwa_prepare_fixture() {
|
||||
local client_dir="$1"
|
||||
local report_dir="$2"
|
||||
if [[ -n "$PWA_FIXTURE_ROOT" ]]; then
|
||||
echo "[pwa-cross-version][ERROR] fixture_already_owned" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$report_dir" || return "$?"
|
||||
PWA_FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hyb-pwa-cross-version.XXXXXX")" || return "$?"
|
||||
PWA_BUILD_A_DIR="$PWA_FIXTURE_ROOT/build-a"
|
||||
PWA_BUILD_B_DIR="$PWA_FIXTURE_ROOT/build-b"
|
||||
PWA_BUILD_A_SHA="111111111111"
|
||||
PWA_BUILD_B_SHA="222222222222"
|
||||
pwa_build_candidate "$client_dir" A "$PWA_BUILD_A_SHA" "$PWA_BUILD_A_DIR" "$report_dir/pwa-build-a.log" || return "$?"
|
||||
pwa_build_candidate "$client_dir" B "$PWA_BUILD_B_SHA" "$PWA_BUILD_B_DIR" "$report_dir/pwa-build-b.log" || return "$?"
|
||||
}
|
||||
|
||||
pwa_with_fixture() {
|
||||
if [[ -z "$PWA_FIXTURE_ROOT" || ! -f "${PWA_BUILD_A_DIR:-}/sw.js" || ! -f "${PWA_BUILD_B_DIR:-}/sw.js" ]]; then
|
||||
echo "[pwa-cross-version][ERROR] fixture_not_ready" >&2
|
||||
return 1
|
||||
fi
|
||||
PWA_BUILD_A_DIR="$PWA_BUILD_A_DIR" PWA_BUILD_B_DIR="$PWA_BUILD_B_DIR" \
|
||||
PWA_BUILD_A_SHA="$PWA_BUILD_A_SHA" PWA_BUILD_B_SHA="$PWA_BUILD_B_SHA" "$@"
|
||||
}
|
||||
|
||||
pwa_cleanup_fixture() {
|
||||
if [[ -z "$PWA_FIXTURE_ROOT" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$(basename "$PWA_FIXTURE_ROOT")" != hyb-pwa-cross-version.* ]]; then
|
||||
echo "[pwa-cross-version][ERROR] fixture_cleanup_refused" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -d "$PWA_FIXTURE_ROOT" ]]; then
|
||||
find "$PWA_FIXTURE_ROOT" -xdev -depth -delete || return "$?"
|
||||
fi
|
||||
PWA_FIXTURE_ROOT=""
|
||||
}
|
||||
@@ -3,61 +3,22 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hyb-pwa-cross-version.XXXXXX")"
|
||||
BUILD_A_DIR="$TEMP_ROOT/build-a"
|
||||
BUILD_B_DIR="$TEMP_ROOT/build-b"
|
||||
BUILD_A_LOG="$TEMP_ROOT/build-a.log"
|
||||
BUILD_B_LOG="$TEMP_ROOT/build-b.log"
|
||||
BUILD_A_SHA="111111111111"
|
||||
BUILD_B_SHA="222222222222"
|
||||
source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh"
|
||||
REPORT_DIR="${PWA_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/pwa-cross-version/$(date +%Y%m%d%H%M%S)_$$_$RANDOM}"
|
||||
|
||||
cleanup() {
|
||||
local original_status="$?"
|
||||
trap - EXIT
|
||||
if [[ -d "$TEMP_ROOT" ]]; then
|
||||
find "$TEMP_ROOT" -depth -delete
|
||||
fi
|
||||
pwa_cleanup_fixture || { [[ "$original_status" -ne 0 ]] || original_status=1; }
|
||||
exit "$original_status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
build_candidate() {
|
||||
local label="$1"
|
||||
local build_sha="$2"
|
||||
local output_dir="$3"
|
||||
local log_file="$4"
|
||||
local build_status=0
|
||||
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
|
||||
) >"$log_file" 2>&1 || build_status="$?"
|
||||
|
||||
if [[ "$build_status" -ne 0 ]]; then
|
||||
local log_lines=0
|
||||
log_lines="$(wc -l <"$log_file" | tr -d ' ')"
|
||||
echo "[pwa-cross-version][ERROR] build=${label} exit=${build_status} log_lines=${log_lines}" >&2
|
||||
exit "$build_status"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$output_dir/index.html" || ! -f "$output_dir/sw.js" ]]; then
|
||||
echo "[pwa-cross-version][ERROR] build=${label} required_artifact_missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[pwa-cross-version] build=${label} status=PASS"
|
||||
}
|
||||
|
||||
build_candidate "A" "$BUILD_A_SHA" "$BUILD_A_DIR" "$BUILD_A_LOG"
|
||||
build_candidate "B" "$BUILD_B_SHA" "$BUILD_B_DIR" "$BUILD_B_LOG"
|
||||
pwa_prepare_fixture "$CLIENT_DIR" "$REPORT_DIR"
|
||||
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
PWA_BUILD_A_DIR="$BUILD_A_DIR" \
|
||||
PWA_BUILD_B_DIR="$BUILD_B_DIR" \
|
||||
PWA_BUILD_A_SHA="$BUILD_A_SHA" \
|
||||
PWA_BUILD_B_SHA="$BUILD_B_SHA" \
|
||||
npx playwright test tests/e2e/pwa-cross-version-upgrade.spec.ts \
|
||||
pwa_with_fixture npx playwright test tests/e2e/pwa-cross-version-upgrade.spec.ts \
|
||||
--workers=1 \
|
||||
--retries=0 \
|
||||
--reporter=list
|
||||
|
||||
+58
-2
@@ -4,8 +4,17 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh"
|
||||
|
||||
MODE="${1:-}"
|
||||
if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" ]]; then
|
||||
if [[ -z "${QA_ENVIRONMENT_CONTEXT:-}" ]]; then
|
||||
exec node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" "$MODE"
|
||||
fi
|
||||
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
|
||||
elif [[ "$MODE" == "__full-local-diagnostic-stage" ]]; then
|
||||
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
|
||||
fi
|
||||
QA_HOST="${QA_HOST:-127.0.0.1}"
|
||||
QA_PORT_INPUT="${QA_PORT:-}"
|
||||
QA_PORT="${QA_PORT_INPUT:-3000}"
|
||||
@@ -56,6 +65,10 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-lo
|
||||
QA_DB_CLIENT=host|docker
|
||||
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
|
||||
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
||||
QA_FULL_REPORT_DIR full-local 独立发现、构建日志与浏览器结果目录
|
||||
full-local-diagnostic 仅本地诊断,始终 PARTIAL/退出 2,无发布资格
|
||||
QA_DIAGNOSTIC_REASON / QA_DIAGNOSTIC_OPERATOR / QA_DIAGNOSTIC_APPROVAL_REF
|
||||
QA_DIAGNOSTIC_REPORT_DIR 本次诊断专用空目录;禁止覆盖历史报告
|
||||
QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用)
|
||||
QA_ISSUE_ID / VISUAL_MATRIX_REPORT_DIR
|
||||
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
||||
@@ -283,6 +296,9 @@ qa_cleanup() {
|
||||
if ! qa_cleanup_server; then
|
||||
cleanup_status=1
|
||||
fi
|
||||
if ! pwa_cleanup_fixture; then
|
||||
cleanup_status=1
|
||||
fi
|
||||
if [[ "$QA_DB_CREATED" == "1" && "$MODE" != "smoke-live" ]]; then
|
||||
if ! qa_dropdb; then
|
||||
cleanup_status=1
|
||||
@@ -534,10 +550,31 @@ qa_run_smoke_local() {
|
||||
)
|
||||
}
|
||||
|
||||
qa_prepare_full_inputs() {
|
||||
QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}"
|
||||
mkdir -p "$QA_FULL_REPORT_DIR"
|
||||
QA_FULL_REPORT_DIR="$(cd "$QA_FULL_REPORT_DIR" && pwd)"
|
||||
|
||||
echo ">>> 准备 full-local PWA 双构建输入"
|
||||
pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR"
|
||||
echo ">>> 发现并核对全部非 manual Playwright spec"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
pwa_with_fixture env E2E_MODE=local \
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/discovery.json" \
|
||||
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
|
||||
npx playwright test --list --retries=0 --reporter=json \
|
||||
> "$QA_FULL_REPORT_DIR/discovery.stdout.log" 2> "$QA_FULL_REPORT_DIR/discovery.stderr.log"
|
||||
)
|
||||
node "$ROOT_DIR/scripts/qa/verify-full-local-discovery.mjs" "$QA_FULL_REPORT_DIR/discovery.json" \
|
||||
> "$QA_FULL_REPORT_DIR/discovery-coverage.json"
|
||||
}
|
||||
|
||||
qa_run_full_local() {
|
||||
qa_select_port
|
||||
export JOBS_ENABLED="${JOBS_ENABLED:-false}"
|
||||
qa_export_app_env
|
||||
qa_prepare_full_inputs
|
||||
qa_prepare_db
|
||||
|
||||
echo ">>> 执行 server Prisma 校验"
|
||||
@@ -578,11 +615,22 @@ qa_run_full_local() {
|
||||
export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"
|
||||
qa_start_server
|
||||
|
||||
qa_run_full_browser
|
||||
}
|
||||
|
||||
qa_run_full_browser() {
|
||||
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=environment; fi
|
||||
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
|
||||
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"
|
||||
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=test; fi
|
||||
echo ">>> 执行 Playwright full E2E"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
rm -rf test-results playwright-report
|
||||
E2E_MODE=local npm run test:e2e:full
|
||||
pwa_with_fixture env E2E_MODE=local \
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \
|
||||
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
|
||||
PLAYWRIGHT_HTML_OUTPUT_DIR="$QA_FULL_REPORT_DIR/playwright-report" PLAYWRIGHT_HTML_OPEN=never \
|
||||
npm run test:e2e:full -- "$@" --retries=0 --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -759,6 +807,14 @@ case "$MODE" in
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_smoke_local
|
||||
;;
|
||||
full-local-diagnostic)
|
||||
qa_require_cmd node
|
||||
node "$ROOT_DIR/scripts/qa/full-local-diagnostic.mjs"
|
||||
;;
|
||||
__full-local-diagnostic-stage)
|
||||
source "$ROOT_DIR/scripts/qa/full-local-diagnostic-stage.sh"
|
||||
qa_run_diagnostic_stage "${2:-}"
|
||||
;;
|
||||
full-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { observeGuestEntry } from "../browser-environment-probe.mjs";
|
||||
|
||||
function fixture({ refresh = false, failed = false, online = true, documentOnline = true, cookies = [], status = 200 } = {}) {
|
||||
const handlers = {};
|
||||
let navigated = false;
|
||||
const page = {
|
||||
on(event, handler) { handlers[event] = handler; },
|
||||
async addInitScript() {},
|
||||
async evaluate() { return online; },
|
||||
async goto() {
|
||||
assert.ok(handlers.request);
|
||||
navigated = true;
|
||||
handlers.console({ text: () => `QA_ENV_DOCUMENT_ONLINE:${documentOnline}` });
|
||||
handlers.console({ text: () => "QA_ENV_DOCUMENT_ONLINE:true" });
|
||||
if (refresh) {
|
||||
const request = { url: () => "http://127.0.0.1/api/auth/refresh" };
|
||||
handlers.request(request);
|
||||
if (failed) handlers.requestfailed(request);
|
||||
}
|
||||
return { status: () => status };
|
||||
},
|
||||
getByTestId() { return { async waitFor() {} }; },
|
||||
url() { return "http://127.0.0.1/login"; },
|
||||
async close() {},
|
||||
};
|
||||
return { context: { async storageState() { return { cookies, origins: [] }; }, async newPage() { return page; } }, navigated: () => navigated };
|
||||
}
|
||||
|
||||
test("在线新 context、表单及零 refresh 是独立事实", async () => {
|
||||
const owned = fixture();
|
||||
const result = await observeGuestEntry(owned.context, "http://127.0.0.1");
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.refreshRequestCount, 0);
|
||||
assert.equal(result.noRouteMocks, true);
|
||||
});
|
||||
for (const failed of [false, true]) test(`表单可见但 refresh 已尝试必须拒绝,失败请求=${failed}`, async () => {
|
||||
const result = await observeGuestEntry(fixture({ refresh: true, failed }).context, "http://127.0.0.1");
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.refreshRequestCount, 1);
|
||||
assert.equal(result.refreshFailedCount, Number(failed));
|
||||
assert.ok(result.failures.includes("refresh_attempt_observed"));
|
||||
});
|
||||
test("首次 document-start 离线不可被后续 true 覆盖", async () => {
|
||||
const result = await observeGuestEntry(fixture({ documentOnline: false }).context, "http://127.0.0.1");
|
||||
assert.equal(result.nativeOnlineSamples.documentStart, false);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
for (const options of [{ online: false }, { cookies: [{ name: "has_session", value: "1" }] }]) test(`前置无效不导航 ${JSON.stringify(options)}`, async () => {
|
||||
const owned = fixture(options);
|
||||
assert.equal((await observeGuestEntry(owned.context, "http://127.0.0.1")).valid, false);
|
||||
assert.equal(owned.navigated(), false);
|
||||
});
|
||||
test("非候选 HTTP 200 不合格,禁止公网探针", async () => {
|
||||
assert.equal((await observeGuestEntry(fixture({ status: 500 }).context, "http://127.0.0.1")).valid, false);
|
||||
await assert.rejects(observeGuestEntry(fixture().context, "https://example.invalid"), /requires_loopback/);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, unlinkSync, symlinkSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { validateExactProductionIdentity } from "../../../deploy/trialrun/production-preflight.mjs";
|
||||
import { browserInventory, createDiagnosticScratch, sourceSnapshot, compareSnapshots } from "../full-local-diagnostic.mjs";
|
||||
|
||||
test("全仓快照发现已跟踪 PNG 覆盖、dirty 二次改写、index-only 和删除", context => {
|
||||
const root = createDiagnosticScratch();
|
||||
context.after(() => rmSync(root, { recursive: true }));
|
||||
const git = args => { const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); };
|
||||
mkdirSync(path.join(root, "client")); mkdirSync(path.join(root, "output"));
|
||||
writeFileSync(path.join(root, ".gitignore"), "output/\n");
|
||||
writeFileSync(path.join(root, "client/source.ts"), "export {};\n");
|
||||
const image = path.join(root, "output/archive.png"); writeFileSync(image, "archived");
|
||||
git(["init", "-q"]); git(["add", "."]); git(["add", "-f", "output/archive.png"]);
|
||||
git(["-c", "user.name=Fixture", "-c", "[email protected]", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture"]);
|
||||
const before = sourceSnapshot(root);
|
||||
assert.equal(before.dirty, false);
|
||||
writeFileSync(path.join(root, "output/ignored.png"), "new run");
|
||||
assert.equal(compareSnapshots(before, sourceSnapshot(root)).repositoryUnchanged, true);
|
||||
writeFileSync(image, "first overwrite"); const first = sourceSnapshot(root);
|
||||
assert.equal(compareSnapshots(before, first).sourceUnchanged, true);
|
||||
assert.equal(compareSnapshots(before, first).repositoryUnchanged, false);
|
||||
assert.ok(first.repository.porcelainV1Z.includes("output/archive.png\0"));
|
||||
writeFileSync(image, "second overwrite"); const second = sourceSnapshot(root);
|
||||
assert.equal(first.dirty, true); assert.equal(second.dirty, true);
|
||||
assert.equal(first.repository.porcelainV1Z, second.repository.porcelainV1Z);
|
||||
assert.equal(compareSnapshots(first, second).repositoryUnchanged, false);
|
||||
git(["add", "-f", "output/archive.png"]); const staged = sourceSnapshot(root);
|
||||
assert.equal(second.repository.trackedDigest, staged.repository.trackedDigest);
|
||||
assert.notEqual(second.repository.indexDigest, staged.repository.indexDigest);
|
||||
assert.equal(compareSnapshots(second, staged).repositoryUnchanged, false);
|
||||
git(["update-index", "--assume-unchanged", "output/archive.png"]);
|
||||
const flagged = sourceSnapshot(root);
|
||||
assert.equal(staged.repository.trackedDigest, flagged.repository.trackedDigest);
|
||||
assert.notEqual(staged.repository.indexDigest, flagged.repository.indexDigest);
|
||||
assert.equal(compareSnapshots(staged, flagged).repositoryUnchanged, false);
|
||||
git(["update-index", "--no-assume-unchanged", "output/archive.png"]);
|
||||
unlinkSync(image); const deleted = sourceSnapshot(root);
|
||||
assert.equal(deleted.repository.trackedFiles.find(entry => entry.file === "output/archive.png").kind, "missing");
|
||||
assert.equal(compareSnapshots(staged, deleted).repositoryUnchanged, false);
|
||||
symlinkSync("absent-target", image); const linked = sourceSnapshot(root);
|
||||
assert.equal(linked.repository.trackedFiles.find(entry => entry.file === "output/archive.png").kind, "symlink");
|
||||
assert.equal(compareSnapshots(deleted, linked).repositoryUnchanged, false);
|
||||
assert.equal(compareSnapshots(null, linked).repositoryUnchanged, false);
|
||||
assert.equal(compareSnapshots(null, linked).candidateIntegrityStatus, "UNKNOWN");
|
||||
const incomplete = structuredClone(linked);
|
||||
delete incomplete.repository.trackedDigest;
|
||||
assert.equal(compareSnapshots(linked, incomplete).candidateIntegrityStatus, "UNKNOWN");
|
||||
});
|
||||
|
||||
test("诊断临时根不污染原生产身份测试的合成合法路径", context => {
|
||||
const scratch = createDiagnosticScratch();
|
||||
context.after(() => rmSync(scratch, { recursive: true }));
|
||||
const identity = {
|
||||
deployServer: "production-node",
|
||||
composeProjectName: "production-project",
|
||||
remoteDir: path.join(scratch, "server-unit", "hyb-oa-deploy-safety-fixture", "app"),
|
||||
};
|
||||
const validate = remoteDir => validateExactProductionIdentity({
|
||||
...identity,
|
||||
remoteDir,
|
||||
expectedDeployServer: identity.deployServer,
|
||||
expectedComposeProjectName: identity.composeProjectName,
|
||||
expectedRemoteDir: remoteDir,
|
||||
});
|
||||
assert.doesNotThrow(() => validate(identity.remoteDir));
|
||||
assert.throws(() => validate(path.join(scratch, "hyb-full-local-diagnostic-fixture", "app")), /REMOTE_DIR_NON_PRODUCTION_IDENTITY/);
|
||||
});
|
||||
|
||||
test("原聚合器在 workflow 失败后停止,同库继续参数会破坏该边界", context => {
|
||||
const scratch = createDiagnosticScratch();
|
||||
context.after(() => rmSync(scratch, { recursive: true }));
|
||||
mkdirSync(path.join(scratch, "lib"));
|
||||
copyFileSync(new URL("../../../server/scripts/regression-all.sh", import.meta.url), path.join(scratch, "regression-all.sh"));
|
||||
copyFileSync(new URL("../../../server/scripts/lib/regression-report.sh", import.meta.url), path.join(scratch, "lib/regression-report.sh"));
|
||||
const suites = ["smoke-test", "regression-dedup", "regression-workflow-actions", "regression-procurement", "regression-assets", "regression-audit", "regression-ocr", "regression-im-notifier"];
|
||||
for (const suite of suites) {
|
||||
writeFileSync(path.join(scratch, `${suite}.sh`), `#!/usr/bin/env bash\nprintf '%s\\n' '${suite}' >> "$QA_AGGREGATE_FIXTURE_LOG"\nexit ${suite === "regression-workflow-actions" ? 31 : 0}\n`, { mode: 0o755 });
|
||||
}
|
||||
const log = path.join(scratch, "invocations.log");
|
||||
const environment = { PATH: process.env.PATH, HOME: scratch, TMPDIR: scratch, QA_AGGREGATE_FIXTURE_LOG: log };
|
||||
const run = args => spawnSync("bash", [path.join(scratch, "regression-all.sh"), ...args], { cwd: scratch, env: environment, encoding: "utf8", timeout: 15000 });
|
||||
const normal = run([]);
|
||||
assert.equal(normal.status, 31, normal.stderr);
|
||||
assert.deepEqual(readFileSync(log, "utf8").trim().split("\n"), suites.slice(0, 3));
|
||||
writeFileSync(log, "");
|
||||
const continued = run(["--json-report", path.join(scratch, "continued.json")]);
|
||||
assert.equal(continued.status, 1, continued.stderr);
|
||||
assert.deepEqual(readFileSync(log, "utf8").trim().split("\n"), suites);
|
||||
});
|
||||
|
||||
const discovery = { cases: [
|
||||
{ file: "client/tests/e2e/ordinary.spec.ts", id: "first", projectName: "", repeatEachIndex: 0 },
|
||||
{ file: "client/tests/e2e/ordinary.spec.ts", id: "second", projectName: "", repeatEachIndex: 0 },
|
||||
] };
|
||||
|
||||
test("缺浏览器回执不能从枚举伪造执行通过", () => {
|
||||
const result = browserInventory(discovery, null);
|
||||
assert.equal(result.reportPresent, false);
|
||||
assert.deepEqual(result.cases.map(entry => entry.status), ["NOT_RUN", "NOT_RUN"]);
|
||||
});
|
||||
|
||||
test("保留失败、serial 跳过、预期状态及重试事实", () => {
|
||||
const execution = { errors: [], suites: [{ specs: [
|
||||
{ id: "first", tests: [{ projectName: "", expectedStatus: "passed", status: "unexpected", results: [{ status: "failed", retry: 0, duration: 100 }] }] },
|
||||
{ id: "second", tests: [{ projectName: "", expectedStatus: "passed", status: "skipped", results: [{ status: "skipped", retry: 0, duration: 0 }] }] },
|
||||
] }] };
|
||||
const result = browserInventory(discovery, execution);
|
||||
assert.deepEqual(result.cases.map(entry => entry.status), ["failed", "skipped"]);
|
||||
assert.equal(result.cases[1].reportedStatus, "skipped");
|
||||
assert.equal(result.cases[0].results[0].retry, 0);
|
||||
});
|
||||
|
||||
test("部分回执只为实际返回的用例计结果", () => {
|
||||
const result = browserInventory(discovery, { errors: [{}], suites: [{ specs: [{ id: "first", tests: [{ results: [{ status: "passed" }] }] }] }] });
|
||||
assert.deepEqual(result.cases.map(entry => entry.status), ["passed", "NOT_RUN"]);
|
||||
assert.equal(result.executionErrors, 1);
|
||||
});
|
||||
|
||||
test("重复和未知用例作为报告问题单列", () => {
|
||||
const result = browserInventory(discovery, { suites: [{ specs: [
|
||||
{ id: "unknown", tests: [{}, {}] },
|
||||
] }] });
|
||||
assert.deepEqual(result.issues, ["duplicate_case", "unknown_cases"]);
|
||||
assert.ok(result.cases.every(entry => entry.status === "NOT_RUN"));
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "node:net";
|
||||
import { topologyMatches, validateEnvironmentFacts, validateEnvironmentReceipt, verifyManagedEnvironment, requestSupervisorObservation } from "../full-local-environment.mjs";
|
||||
|
||||
function receipt() {
|
||||
return {
|
||||
schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy",
|
||||
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: "2026-09-07T00:00:00Z", namespace: "parent" },
|
||||
namespace: { inheritedRunner: "parent", ownedChild: "child", runnerCreated: true, currentMatchesOwnedChild: true, topologyMatched: true },
|
||||
nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true },
|
||||
bootstrapObservation: {
|
||||
valid: true, observationPoint: "guest-entry-black-box", freshContext: true, initialSessionHint: false,
|
||||
listenerBeforeNavigation: true, noRouteMocks: true, loginFormVisible: true, refreshRequestCount: 0, loginRequestCount: 0,
|
||||
nativeOnlineSamples: { beforeNavigation: true, documentStart: true, afterForm: true },
|
||||
browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true, documentStatus: 200, stayedOnLogin: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
test("环境回执必须消费事实而非 valid 标记", () => {
|
||||
assert.deepEqual(validateEnvironmentReceipt(receipt(), "policy"), []);
|
||||
for (const section of ["namespace", "nativeOnlineProbe", "bootstrapObservation", "supervisorObservation"]) {
|
||||
for (const key of Object.keys(receipt()[section])) {
|
||||
const value = receipt();
|
||||
delete value[section][key];
|
||||
assert.ok(validateEnvironmentReceipt(value, "policy").length, `${section}.${key}`);
|
||||
}
|
||||
}
|
||||
for (const mutate of [
|
||||
value => { value.bootstrapObservation.refreshRequestCount = 1; },
|
||||
value => { value.bootstrapObservation.nativeOnlineSamples.documentStart = false; },
|
||||
value => { value.namespace.ownedChild = "parent"; },
|
||||
value => { value.nativeOnlineProbe.browserNamespace = "other"; },
|
||||
value => { value.policyDigest = "old"; },
|
||||
]) {
|
||||
const value = receipt(); mutate(value);
|
||||
assert.ok(validateEnvironmentReceipt(value, "policy").length);
|
||||
}
|
||||
});
|
||||
|
||||
test("父 runner 实时 IPC 绑定本轮、进程和当前策略,错误响应拒绝", async context => {
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-env-ipc-test-"));
|
||||
const owner = { scratch, runId: "current", supervisorPid: process.pid, policyDigest: "policy" };
|
||||
let observation = { runId: owner.runId, pid: owner.supervisorPid, policyDigest: owner.policyDigest, namespace: "parent" };
|
||||
const server = createServer(connection => connection.end(JSON.stringify(observation)));
|
||||
context.after(async () => { await new Promise(resolve => server.close(resolve)); rmSync(scratch, { recursive: true, force: true }); });
|
||||
await new Promise(resolve => server.listen(path.join(scratch, "supervisor.sock"), resolve));
|
||||
assert.equal((await requestSupervisorObservation(owner)).namespace, "parent");
|
||||
for (const key of ["runId", "pid", "policyDigest", "namespace"]) {
|
||||
const previous = observation;
|
||||
observation = { ...previous, [key]: null };
|
||||
await assert.rejects(requestSupervisorObservation(owner), /response_invalid/);
|
||||
observation = previous;
|
||||
}
|
||||
await assert.rejects(requestSupervisorObservation({ ...owner, scratch: path.join(scratch, "missing") }), /unavailable/);
|
||||
});
|
||||
test("缺 context 不接受 GATE_PASSED 或已有 true", () => {
|
||||
assert.throws(() => verifyManagedEnvironment(""), /context_required/);
|
||||
const context = { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy", inheritedNamespace: "parent", ownedNamespace: "child" };
|
||||
const facts = { policyDigest: "policy", supervisorRecognized: true, descendantOfSupervisor: true, supervisorNamespace: "parent", currentNamespace: "child", topologyMatched: true };
|
||||
assert.deepEqual(validateEnvironmentFacts(context, facts), []);
|
||||
for (const key of Object.keys(facts)) assert.ok(validateEnvironmentFacts(context, { ...facts, [key]: null }).length, key);
|
||||
});
|
||||
test("拓扑只接纳 owned veth 且禁止默认出口", () => {
|
||||
const addresses = [{ ifname: "lo" }, ...["qaonline", "qapeer"].map((ifname, index) => ({ ifname, flags: ["UP"], operstate: "UP", addr_info: [{ local: `192.0.2.${index + 1}`, prefixlen: 30 }] }))];
|
||||
assert.equal(topologyMatches(addresses, [], []), true);
|
||||
assert.equal(topologyMatches(addresses, [{ dst: "default" }], []), false);
|
||||
assert.equal(topologyMatches(addresses, [], [{ gateway: "::1" }]), false);
|
||||
assert.equal(topologyMatches([...addresses, { ifname: "eth0" }], [], []), false);
|
||||
assert.equal(topologyMatches(addresses.slice(0, 2), [], []), false);
|
||||
});
|
||||
|
||||
for (const mismatch of [false, true]) test(`namespace 创建失败不降级,清理须匹配 owner:mismatch=${mismatch}`, context => {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const temporary = mkdtempSync(path.join(os.tmpdir(), "hyb-env-command-test-"));
|
||||
const report = mkdtempSync(path.join(root, "artifacts/qa-flow/environment-command-test-"));
|
||||
const commands = path.join(temporary, "commands.jsonl");
|
||||
mkdirSync(path.join(temporary, "bin"));
|
||||
context.after(() => {
|
||||
try {
|
||||
const owned = JSON.parse(readFileSync(path.join(report, "environment/context.private.json")));
|
||||
assert.ok(path.basename(owned.scratch).startsWith("hyb-qa-managed-"));
|
||||
rmSync(owned.scratch, { recursive: true, force: true });
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
rmSync(report, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
writeFileSync(path.join(temporary, "bin/docker"), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(${JSON.stringify(commands)},JSON.stringify(args)+'\\n');
|
||||
if (args[0] === 'image') console.log('sha256:synthetic');
|
||||
if (args[0] === 'run') { fs.writeFileSync(${JSON.stringify(path.join(temporary, "owner"))},args[args.indexOf('--name')+1]); console.log('a'.repeat(64)); }
|
||||
if (args[0] === 'inspect') console.log('a'.repeat(64)+'|'+(${mismatch} ? 'other-owner' : fs.readFileSync(${JSON.stringify(path.join(temporary, "owner"))},'utf8'))+'|none');
|
||||
`, { mode: 0o755 });
|
||||
writeFileSync(path.join(temporary, "bin/unshare"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const result = spawnSync(process.execPath, [path.join(root, "scripts/qa/full-local-environment.mjs"), "full-local-diagnostic"], {
|
||||
cwd: root, encoding: "utf8", timeout: 30000,
|
||||
env: { PATH: `${path.join(temporary, "bin")}:${process.env.PATH}`, HOME: temporary, QA_DIAGNOSTIC_REPORT_DIR: report, QA_DIAGNOSTIC_REASON: "synthetic test", QA_DIAGNOSTIC_OPERATOR: "fixture", QA_DIAGNOSTIC_APPROVAL_REF: "fixture-only", ENVIRONMENT_PASSED: "1" },
|
||||
});
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
const receipt = JSON.parse(readFileSync(path.join(report, "environment/result.json")));
|
||||
assert.equal(receipt.status, "ENVIRONMENT_INVALID");
|
||||
assert.equal(receipt.normalReleaseEligible, false);
|
||||
assert.equal(receipt.cleanup.containerRemoved, !mismatch);
|
||||
const calls = readFileSync(commands, "utf8").trim().split("\n").map(JSON.parse);
|
||||
assert.equal(calls.some(args => args[0] === "rm"), !mismatch);
|
||||
assert.ok(calls.find(args => args[0] === "run").includes("--pull=never"));
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
for (const [flag, protectedLine] of [
|
||||
["FAIL_ENVIRONMENT_VERIFY", ' node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify'],
|
||||
["FAIL_ENVIRONMENT_PROBE", ' node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"'],
|
||||
["FAIL_ENVIRONMENT_CONSUME", ' node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"'],
|
||||
]) test(`环境保护拒绝并对真实源码删除敏感:${flag}`, context => {
|
||||
const assertBlocked = result => {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.ok(!result.commands.some(command => command.args.includes("test:e2e:full")));
|
||||
};
|
||||
const owned = fixture(context, { [flag]: "1", QA_ENVIRONMENT_CONTEXT: "synthetic-reference", ENVIRONMENT_PASSED: "1", ONLINE: "1" });
|
||||
assertBlocked(owned.run());
|
||||
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
|
||||
const original = readFileSync(source, "utf8");
|
||||
assert.ok(original.includes(protectedLine));
|
||||
writeFileSync(source, original.replaceAll(protectedLine, ":"));
|
||||
assert.throws(() => assertBlocked(owned.run()), assert.AssertionError);
|
||||
});
|
||||
|
||||
function fixture(context, overrides = {}) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-full-local-test-"));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
for (const directory of ["bin", "tmp", "scripts/qa", "server/scripts", "client/dist", "client/tests/e2e", "output"]) mkdirSync(path.join(root, directory), { recursive: true });
|
||||
writeFileSync(path.join(root, "output/archive.png"), "historical evidence");
|
||||
for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file));
|
||||
for (const file of ["pwa-cross-version-upgrade.spec.ts", "ordinary.spec.ts"]) writeFileSync(path.join(root, "client/tests/e2e", file), "");
|
||||
writeFileSync(path.join(root, "client/dist/keep.txt"), "original-dist");
|
||||
for (const [file, stage] of [["regression-workflow-actions.sh", "workflow-actions"], ["regression-all.sh", "server-regressions"]]) {
|
||||
writeFileSync(path.join(root, "server/scripts", file), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
fs.appendFileSync(process.env.HYB_QA_FIXTURE_ROOT+'/commands.jsonl',JSON.stringify({command:'regression',stage:'${stage}',args:process.argv.slice(2),database:process.env.QA_DB_NAME})+'\\n');
|
||||
process.exit((process.env.FAIL_STAGES??'').split(',').includes('${stage}') ? 31 : 0);
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
const fake = `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const root = process.env.HYB_QA_FIXTURE_ROOT;
|
||||
if (!root || !path.basename(root).startsWith('hyb-full-local-test-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) throw Error('fixture_only');
|
||||
const command = path.basename(process.argv[1]);
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n');
|
||||
if (command === 'node' && args[0]?.endsWith('full-local-environment.mjs')) {
|
||||
if (process.env.FAIL_ENVIRONMENT) process.exit(41);
|
||||
if (process.env.FAIL_ENVIRONMENT_VERIFY && args[1] === 'verify') process.exit(41);
|
||||
if (process.env.FAIL_ENVIRONMENT_CONSUME && args[1] === 'report-guest') process.exit(41);
|
||||
if (['full-local','full-local-diagnostic'].includes(args[1])) {
|
||||
const result = spawnSync('bash',[path.join(root,'scripts/qa/run-web-gates.sh'),args[1]],{env:{...process.env,QA_ENVIRONMENT_CONTEXT:'synthetic-reference'},stdio:'inherit'});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
console.log(JSON.stringify({bootstrapObservation:{valid:true}}));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === 'node' && args[0]?.endsWith('browser-environment-probe.mjs')) process.exit(process.env.FAIL_ENVIRONMENT_PROBE ? 42 : 0);
|
||||
if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0] === '-e')) {
|
||||
const result = spawnSync(process.execPath, args, {env:process.env, stdio:'inherit'});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
if (command === 'npx' && args[0] === 'vite' && args.includes('--outDir')) {
|
||||
if (process.env.FAIL_BUILD_SHA === process.env.VITE_BUILD_SHA) process.exit(23);
|
||||
const directory = args[args.indexOf('--outDir')+1];
|
||||
fs.mkdirSync(directory, {recursive:true});
|
||||
fs.writeFileSync(path.join(directory,'index.html'),'synthetic');
|
||||
if (!process.env.OMIT_SW) fs.writeFileSync(path.join(directory,'sw.js'),'synthetic');
|
||||
}
|
||||
if (command === 'npx' && args.includes('--list')) {
|
||||
for (const name of ['PWA_BUILD_A_DIR','PWA_BUILD_B_DIR','PWA_BUILD_A_SHA','PWA_BUILD_B_SHA']) if (!process.env[name]) process.exit(24);
|
||||
if (process.env.FAIL_DISCOVERY) process.exit(25);
|
||||
let files = fs.readdirSync(path.join(root,'client/tests/e2e'));
|
||||
if (process.env.OMIT_PWA) files = files.filter(file=>!file.startsWith('pwa-'));
|
||||
const report = {errors:[],suites:files.map(file=>({file,specs:[{file,id:file,title:file,line:1,tests:[{projectName:''}]}]}))};
|
||||
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(report));
|
||||
}
|
||||
if (command === 'psql' && args.some(arg=>arg.includes('count(*)'))) console.log('0');
|
||||
if (command === 'dropdb' && process.env.FAIL_CLEANUP_STAGE && process.env.FAIL_CLEANUP_STAGE === process.env.QA_DIAGNOSTIC_STAGE) process.exit(33);
|
||||
if (command === 'createdb' && process.env.FAIL_PREPARE_STAGE && process.env.FAIL_PREPARE_STAGE === process.env.QA_DIAGNOSTIC_STAGE) process.exit(34);
|
||||
const failStage = (process.env.FAIL_STAGES??'').split(',').includes(process.env.QA_DIAGNOSTIC_STAGE);
|
||||
if (command === 'npm' && args[0] === 'test' && failStage) process.exit(32);
|
||||
if (command === 'npx' && args[0] === 'tsc' && failStage) process.exit(32);
|
||||
if (command === 'npm' && args.includes('test:e2e:full')) {
|
||||
if (process.env.MUTATE_ARCHIVE) fs.writeFileSync(path.join(root,'output/archive.png'),'overwritten');
|
||||
if (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE) {
|
||||
const discovery = JSON.parse(fs.readFileSync(path.join(root,process.env.QA_DIAGNOSTIC_STAGE?'report/full-local-report/discovery.json':'report/discovery.json')));
|
||||
for (const suite of discovery.suites) for (const spec of suite.specs) for (const test of spec.tests) Object.assign(test,{status:process.env.FAIL_BROWSER?'unexpected':'expected',expectedStatus:'passed',results:[{status:process.env.FAIL_BROWSER?'failed':'passed',retry:0,duration:1}]});
|
||||
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(discovery));
|
||||
}
|
||||
if (process.env.FAIL_BROWSER) process.exit(26);
|
||||
}
|
||||
if (!['node','npm','npx','curl','psql','createdb','dropdb'].includes(command)) throw Error('unexpected_fixture_command');
|
||||
`;
|
||||
writeFileSync(path.join(root, "bin/fake"), fake, { mode: 0o755 });
|
||||
for (const command of ["node", "npm", "npx", "curl", "psql", "createdb", "dropdb"]) symlinkSync("fake", path.join(root, "bin", command));
|
||||
const env = {
|
||||
PATH: `${path.join(root, "bin")}:${process.env.PATH}`, HOME: root, LANG: "C.UTF-8",
|
||||
HYB_QA_FIXTURE_ROOT: root, TMPDIR: path.join(root, "tmp"),
|
||||
QA_DB_CLIENT: "host", QA_DB_NAME: "hyb_oa_webgate_fixture", QA_HOST: "127.0.0.1",
|
||||
QA_FULL_REPORT_DIR: path.join(root, "report"), PWA_REPORT_DIR: path.join(root, "report"),
|
||||
QA_SERVER_LOG: path.join(root, "server.log"), QA_SERVER_PID_FILE: path.join(root, "server.pid"),
|
||||
...overrides,
|
||||
};
|
||||
writeFileSync(path.join(root, ".gitignore"), "bin/\ntmp/\nreport/\noutput/\ncommands.jsonl\n*.log\n*.pid\n");
|
||||
for (const args of [["init", "-q"], ["add", "."], ["add", "-f", "output/archive.png"], ["-c", "user.name=QA Fixture", "-c", "[email protected]", "commit", "-qm", "synthetic fixture"]]) {
|
||||
assert.equal(spawnSync("git", args, { cwd: root, encoding: "utf8" }).status, 0);
|
||||
}
|
||||
return {
|
||||
root,
|
||||
run(script = "run-web-gates.sh", args = ["full-local"]) {
|
||||
const result = spawnSync("bash", [path.join(root, "scripts/qa", script), ...args], { env, cwd: root, encoding: "utf8", timeout: 30000 });
|
||||
return { ...result, commands: existsSync(path.join(root, "commands.jsonl")) ? readFileSync(path.join(root, "commands.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const ci of [undefined, "1"]) {
|
||||
test(`full-local 准备 A/B、保留并发、CI=${ci ?? "unset"} 仍强制零重试`, (context) => {
|
||||
const owned = fixture(context, ci ? { CI: ci } : {});
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const builds = result.commands.filter((command) => command.args.includes("--outDir"));
|
||||
assert.deepEqual(builds.map((command) => command.sha), ["111111111111", "222222222222"]);
|
||||
const discovery = result.commands.find((command) => command.args.includes("--list"));
|
||||
const browser = result.commands.find((command) => command.args.includes("test:e2e:full"));
|
||||
assert.ok(!browser.args.some(argument => argument.startsWith("--trace")));
|
||||
for (const command of [discovery, browser]) {
|
||||
assert.ok(command);
|
||||
assert.ok(command.args.includes("--retries=0"));
|
||||
assert.ok(!command.args.some((argument) => argument.startsWith("--workers") || argument.startsWith("--grep") || argument.startsWith("--shard")));
|
||||
assert.ok(command.inputs.every(Boolean));
|
||||
assert.equal(command.visualOutput, path.join(owned.root, "report/visual"));
|
||||
assert.notEqual(command.inputs[0], command.inputs[1]);
|
||||
assert.notEqual(command.inputs[2], command.inputs[3]);
|
||||
}
|
||||
assert.ok(result.commands.indexOf(discovery) < result.commands.findIndex((command) => command.command === "createdb"));
|
||||
const environment = result.commands.find(command => command.args[0]?.endsWith("full-local-environment.mjs") && command.args[1] === "verify");
|
||||
const probe = result.commands.find(command => command.args[0]?.endsWith("browser-environment-probe.mjs"));
|
||||
const consume = result.commands.find(command => command.args[0]?.endsWith("full-local-environment.mjs") && command.args[1] === "report-guest");
|
||||
assert.ok(result.commands.indexOf(environment) < result.commands.indexOf(discovery));
|
||||
assert.ok(result.commands.indexOf(probe) < result.commands.indexOf(consume));
|
||||
assert.ok(result.commands.indexOf(consume) < result.commands.indexOf(browser));
|
||||
assert.ok(result.commands.filter((command) => command.command === "npm" && !command.args.includes("test:e2e:full")).every((command) => command.inputs.every((value) => value === null)));
|
||||
assert.equal(readFileSync(path.join(owned.root, "client/dist/keep.txt"), "utf8"), "original-dist");
|
||||
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
|
||||
assert.ok(existsSync(path.join(owned.root, "report/pwa-build-a.log")));
|
||||
});
|
||||
}
|
||||
|
||||
for (const overrides of [{ FAIL_BUILD_SHA: "111111111111" }, { FAIL_BUILD_SHA: "222222222222" }, { OMIT_SW: "1" }, { FAIL_DISCOVERY: "1" }, { OMIT_PWA: "1" }]) {
|
||||
test(`输入或完整发现失败阻止后续阶段 ${JSON.stringify(overrides)}`, (context) => {
|
||||
const owned = fixture(context, overrides);
|
||||
const result = owned.run();
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.ok(!result.commands.some((command) => command.command === "createdb" || command.args.includes("test:e2e:full")));
|
||||
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
|
||||
assert.ok(existsSync(path.join(owned.root, "report/pwa-build-a.log")));
|
||||
});
|
||||
}
|
||||
|
||||
test("浏览器失败保留非零并清理本轮输入", (context) => {
|
||||
const owned = fixture(context, { FAIL_BROWSER: "1" });
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 26);
|
||||
assert.ok(result.commands.some((command) => command.command === "dropdb"));
|
||||
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
|
||||
assert.ok(existsSync(path.join(owned.root, "report/discovery-coverage.json")));
|
||||
});
|
||||
|
||||
test("原专用 runner 共用输入逻辑且保留自身单 worker", (context) => {
|
||||
const owned = fixture(context);
|
||||
const result = owned.run("run-pwa-cross-version-upgrade.sh", []);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const browser = result.commands.find((command) => command.args.includes("tests/e2e/pwa-cross-version-upgrade.spec.ts"));
|
||||
assert.ok(browser.args.includes("--workers=1"));
|
||||
assert.ok(browser.args.includes("--retries=0"));
|
||||
assert.ok(browser.inputs.every(Boolean));
|
||||
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
|
||||
});
|
||||
|
||||
test("恢复静默排除 PWA 的真实源码变异必须使 full-local 失败", (context) => {
|
||||
const owned = fixture(context);
|
||||
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
|
||||
const original = readFileSync(source, "utf8");
|
||||
const mutated = original.replace("npx playwright test --list --retries=0", "OMIT_PWA=1 npx playwright test --list --retries=0");
|
||||
assert.notEqual(mutated, original);
|
||||
writeFileSync(source, mutated);
|
||||
assert.notEqual(owned.run().status, 0);
|
||||
});
|
||||
|
||||
const diagnosticApproval = {
|
||||
QA_DIAGNOSTIC_REASON: "synthetic coverage inventory",
|
||||
QA_DIAGNOSTIC_OPERATOR: "fixture",
|
||||
QA_DIAGNOSTIC_APPROVAL_REF: "synthetic-test-approval",
|
||||
};
|
||||
|
||||
function runDiagnostic(context, overrides = {}) {
|
||||
const owned = fixture(context, { ...diagnosticApproval, ...overrides });
|
||||
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
|
||||
const reportFile = path.join(owned.root, "report/diagnostic-results.json");
|
||||
return { owned, result, report: existsSync(reportFile) ? JSON.parse(readFileSync(reportFile, "utf8")) : null };
|
||||
}
|
||||
|
||||
test("普通 full-local 遇 workflow 失败仍立即停止", context => {
|
||||
const owned = fixture(context, { FAIL_STAGES: "workflow-actions" });
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 31, result.stderr);
|
||||
assert.ok(!result.commands.some(command => command.stage === "server-regressions" || command.args.includes("test:e2e:full")));
|
||||
});
|
||||
|
||||
test("诊断全绿仍 PARTIAL/2,每个写库阶段使用全新独占库并清理", context => {
|
||||
const { result, report, owned } = runDiagnostic(context, { CI: "1", GATE_PASSED: "1" });
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(report.status, "PARTIAL");
|
||||
assert.ok(report.stages.every(stage => stage.status === "PASS"), JSON.stringify(report.stages));
|
||||
assert.equal(report.sourceUnchanged, true);
|
||||
assert.equal(report.repositoryUnchanged, true);
|
||||
assert.equal(report.cleanAtStart, true);
|
||||
assert.equal(report.cleanAtEnd, true);
|
||||
assert.equal(report.browser.cases.length, 2);
|
||||
assert.ok(report.browser.cases.every(entry => entry.status === "passed"));
|
||||
const created = result.commands.filter(command => command.command === "createdb");
|
||||
assert.equal(new Set(created.map(command => command.database)).size, 3);
|
||||
assert.equal(result.commands.filter(command => command.command === "dropdb").length, 3);
|
||||
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
|
||||
assert.ok(browser.args.includes("--retries=0"));
|
||||
assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]);
|
||||
assert.ok(!browser.args.some(argument => /^(--workers|--grep|--shard)/.test(argument)));
|
||||
assert.deepEqual(result.commands.find(command => command.command === "regression" && command.stage === "server-regressions").args, []);
|
||||
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
|
||||
});
|
||||
|
||||
test("诊断跨过独立测试失败,浏览器失败与来源分别留痕", context => {
|
||||
const { result, report } = runDiagnostic(context, { FAIL_STAGES: "server-unit,workflow-actions,server-regressions", FAIL_BROWSER: "1" });
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
for (const id of ["server-unit", "workflow-actions", "server-regressions", "browser"]) {
|
||||
const stage = report.stages.find(stage => stage.id === id);
|
||||
assert.equal(stage.status, "FAIL");
|
||||
assert.equal(stage.failureKind, "test");
|
||||
}
|
||||
assert.ok(report.browser.cases.every(entry => entry.status === "failed"));
|
||||
});
|
||||
|
||||
test("环境有效与 tracked output 被改写必须独立记录", context => {
|
||||
const { result, report } = runDiagnostic(context, { MUTATE_ARCHIVE: "1" });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(report.environmentQualified, true);
|
||||
assert.equal(report.sourceUnchanged, true);
|
||||
assert.equal(report.repositoryUnchanged, false);
|
||||
assert.equal(report.cleanAtStart, true);
|
||||
assert.equal(report.cleanAtEnd, false);
|
||||
assert.equal(report.candidateIntegrityStatus, "CHANGED");
|
||||
assert.ok(report.sourceAfter.repository.porcelainV1Z.includes("output/archive.png\0"));
|
||||
});
|
||||
|
||||
for (const missing of Object.keys(diagnosticApproval)) {
|
||||
test(`诊断缺授权记录拒绝 ${missing}`, context => {
|
||||
const { result, report } = runDiagnostic(context, { [missing]: " " });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(report, null);
|
||||
assert.ok(!result.commands.some(command => command.command === "createdb" || command.command === "npx"));
|
||||
});
|
||||
}
|
||||
|
||||
test("PWA 前置失败只阻断浏览器依赖,其他测试仍执行", context => {
|
||||
const { result, report } = runDiagnostic(context, { FAIL_BUILD_SHA: "111111111111" });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(report.stages.find(stage => stage.id === "inputs").status, "FAIL");
|
||||
assert.equal(report.stages.find(stage => stage.id === "server-unit").status, "PASS");
|
||||
assert.equal(report.stages.find(stage => stage.id === "browser").status, "NOT_RUN");
|
||||
assert.ok(!result.commands.some(command => command.args.includes("test:e2e:full")));
|
||||
});
|
||||
|
||||
test("建库失败不执行该套件,后续独立新库仍可验证", context => {
|
||||
const { result, report } = runDiagnostic(context, { FAIL_PREPARE_STAGE: "workflow-actions" });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(report.stages.find(stage => stage.id === "workflow-actions").failureKind, "environment");
|
||||
assert.ok(!result.commands.some(command => command.command === "regression" && command.stage === "workflow-actions"));
|
||||
assert.equal(report.stages.find(stage => stage.id === "browser").status, "PASS");
|
||||
});
|
||||
|
||||
test("清理失败必须记录,不能被测试退出 0 吞掉", context => {
|
||||
const { result, report } = runDiagnostic(context, { FAIL_CLEANUP_STAGE: "workflow-actions" });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(report.stages.find(stage => stage.id === "workflow-actions").failureKind, "cleanup");
|
||||
assert.equal(report.scratchCleanup, "PASS");
|
||||
});
|
||||
|
||||
test("必要构建失败明确阻断写库及浏览器,不把缺环境记为产品红项", context => {
|
||||
const { result, report } = runDiagnostic(context, { FAIL_STAGES: "client-build" });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(report.stages.find(stage => stage.id === "client-build").failureKind, "environment");
|
||||
for (const id of ["workflow-actions", "server-regressions", "browser"]) {
|
||||
assert.equal(report.stages.find(stage => stage.id === id).status, "NOT_RUN");
|
||||
}
|
||||
assert.ok(report.browser.cases.every(entry => entry.status === "NOT_RUN"));
|
||||
assert.ok(!result.commands.some(command => command.command === "createdb"));
|
||||
});
|
||||
|
||||
test("移除诊断固定非零保护的源码变异可观测", context => {
|
||||
const owned = fixture(context, diagnosticApproval);
|
||||
const source = path.join(owned.root, "scripts/qa/full-local-diagnostic.mjs");
|
||||
const original = readFileSync(source, "utf8");
|
||||
const mutated = original.replace("process.exitCode = 2;", "process.exitCode = 0;");
|
||||
assert.notEqual(mutated, original);
|
||||
writeFileSync(source, mutated);
|
||||
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.throws(() => assert.equal(result.status, 2));
|
||||
});
|
||||
|
||||
test("移除诊断 trace 实参的源码变异会被真实命令捕获拒绝", context => {
|
||||
const owned = fixture(context, diagnosticApproval);
|
||||
const source = path.join(owned.root, "scripts/qa/full-local-diagnostic-stage.sh");
|
||||
const original = readFileSync(source, "utf8");
|
||||
const mutated = original.replace("qa_run_full_browser --trace=on", "qa_run_full_browser");
|
||||
assert.notEqual(mutated, original);
|
||||
writeFileSync(source, mutated);
|
||||
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
|
||||
assert.ok(browser);
|
||||
assert.ok(!browser.args.includes("--trace=on"));
|
||||
assert.throws(() => assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]));
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { validateRegisteredEvidence } from "../check-trellis-governance.mjs";
|
||||
|
||||
function fixture(context) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-evidence-test-"));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(root, "output"));
|
||||
const digest = createHash("sha256").update("archive").digest("hex");
|
||||
const rows = ["first.png", "second.png"].map(name => `| \`${name}\` | \`${digest}\` |`);
|
||||
const document = "| 截图 | SHA-256 |\n| --- | --- |\n" + rows.join("\n") + "\n";
|
||||
writeFileSync(path.join(root, "evidence.md"), document);
|
||||
for (const name of ["first.png", "second.png"]) writeFileSync(path.join(root, "output", name), "archive");
|
||||
return { root, document, check: () => validateRegisteredEvidence(root, [{ document: "evidence.md", directory: "output", count: 2 }]) };
|
||||
}
|
||||
|
||||
test("归档登记摘要匹配时计数明确", context => {
|
||||
assert.deepEqual(fixture(context).check(), { checked: 2, errors: [] });
|
||||
});
|
||||
for (const mutation of ["overwrite", "missing", "symlink", "table-missing", "row-missing", "duplicate", "escape", "invalid-hash"]) test(`证据错误必须被机器发现:${mutation}`, context => {
|
||||
const owned = fixture(context); const image = path.join(owned.root, "output/first.png");
|
||||
if (mutation === "overwrite") writeFileSync(image, "new run");
|
||||
if (["missing", "symlink"].includes(mutation)) rmSync(image);
|
||||
if (mutation === "symlink") symlinkSync("second.png", image);
|
||||
const changes = {
|
||||
"table-missing": "history only\n",
|
||||
"row-missing": owned.document.split("\n").slice(0, 3).join("\n"),
|
||||
duplicate: owned.document.replace("second.png", "first.png"),
|
||||
escape: owned.document.replace("first.png", "../first.png"),
|
||||
"invalid-hash": owned.document.replace(/`[a-f0-9]{64}`/, "`unknown`"),
|
||||
};
|
||||
if (mutation in changes) writeFileSync(path.join(owned.root, "evidence.md"), changes[mutation]);
|
||||
assert.ok(owned.check().errors.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fullLocalSpecFiles, verifyFullLocalDiscovery } from "../verify-full-local-discovery.mjs";
|
||||
|
||||
function fixture(context) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-discovery-test-"));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const files = ["pwa-cross-version-upgrade.spec.ts", "nested/ordinary.test.ts", "live-smoke.spec.ts", "manual/private.spec.ts"];
|
||||
for (const file of files) {
|
||||
const target = path.join(root, "client/tests/e2e", file);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function report(files) {
|
||||
return {
|
||||
errors: [],
|
||||
suites: files.map((file, index) => ({ file, specs: [{ file, id: `case-${index}`, title: `case ${index}`, line: 1, tests: [{ projectName: "" }] }] })),
|
||||
};
|
||||
}
|
||||
|
||||
test("归属来自源码:包含专用输入和 live 名称,不包含 manual", (context) => {
|
||||
const root = fixture(context);
|
||||
const files = fullLocalSpecFiles(root);
|
||||
assert.equal(files.length, 3);
|
||||
assert.ok(files.some((file) => file.endsWith("pwa-cross-version-upgrade.spec.ts")));
|
||||
assert.ok(files.some((file) => file.endsWith("live-smoke.spec.ts")));
|
||||
const summary = verifyFullLocalDiscovery(root, report(files));
|
||||
assert.equal(summary.status, "PASS");
|
||||
assert.equal(summary.kind, "FULL_LOCAL_DISCOVERY_ONLY");
|
||||
assert.equal(summary.cases.length, 3);
|
||||
});
|
||||
|
||||
test("只报 manifest 子集或排除 PWA 必须失败", (context) => {
|
||||
const root = fixture(context);
|
||||
const files = fullLocalSpecFiles(root).filter((file) => !file.includes("pwa-cross-version"));
|
||||
const summary = verifyFullLocalDiscovery(root, report(files));
|
||||
assert.equal(summary.status, "FAIL");
|
||||
assert.deepEqual(summary.missingSpecs, ["client/tests/e2e/pwa-cross-version-upgrade.spec.ts"]);
|
||||
});
|
||||
|
||||
test("新增非 manual 文件不能被旧清单遗漏", (context) => {
|
||||
const root = fixture(context);
|
||||
const original = report(fullLocalSpecFiles(root));
|
||||
writeFileSync(path.join(root, "client/tests/e2e/newly-added.spec.ts"), "");
|
||||
assert.deepEqual(verifyFullLocalDiscovery(root, original).missingSpecs, ["client/tests/e2e/newly-added.spec.ts"]);
|
||||
});
|
||||
|
||||
test("完整文件集合也不能掩盖收集错误或零用例", (context) => {
|
||||
const root = fixture(context);
|
||||
const failed = report(fullLocalSpecFiles(root));
|
||||
failed.errors.push({ message: "PWA_BUILD_A_DIR_missing" });
|
||||
assert.ok(verifyFullLocalDiscovery(root, failed).issues.includes("discovery_errors"));
|
||||
failed.errors = [];
|
||||
failed.suites[0].specs[0].tests = [];
|
||||
assert.ok(verifyFullLocalDiscovery(root, failed).issues.includes("spec_without_cases"));
|
||||
assert.equal(verifyFullLocalDiscovery(root, {}).status, "FAIL");
|
||||
});
|
||||
|
||||
test("manual、未知或越界来源不能冒充完整集合", (context) => {
|
||||
const root = fixture(context);
|
||||
for (const extra of ["manual/private.spec.ts", "unknown.spec.ts", "../../../outside.spec.ts"]) {
|
||||
const summary = verifyFullLocalDiscovery(root, report([...fullLocalSpecFiles(root), extra]));
|
||||
assert.equal(summary.status, "FAIL");
|
||||
assert.ok(summary.issues.includes("unexpected_specs"));
|
||||
}
|
||||
});
|
||||
|
||||
test("支持 Playwright 的根相对、测试目录相对与绝对文件来源", (context) => {
|
||||
const root = fixture(context);
|
||||
const files = fullLocalSpecFiles(root);
|
||||
const sources = [files[0].replace(/^client\//u, ""), files[1].replace(/^client\/tests\/e2e\//u, ""), path.join(root, files[2])];
|
||||
assert.equal(verifyFullLocalDiscovery(root, report(sources)).status, "PASS");
|
||||
});
|
||||
|
||||
test("符号链接必须有明确归属,不能静默跳过", (context) => {
|
||||
const root = fixture(context);
|
||||
symlinkSync("live-smoke.spec.ts", path.join(root, "client/tests/e2e/linked.spec.ts"));
|
||||
assert.throws(() => fullLocalSpecFiles(root), /explicit_ownership/u);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import * as fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import test from "node:test";
|
||||
|
||||
const require = createRequire(new URL("../../../client/package.json", import.meta.url));
|
||||
const ts = require("typescript");
|
||||
for (const configured of [false, true]) test(`截图输出按 spec/加载隔离,配置输出根=${configured}`, context => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-visual-path-test-"));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(root, "client"));
|
||||
const outputRoot = path.join(root, configured ? "report-visual" : "output/playwright");
|
||||
const directories = [];
|
||||
for (const [file, marker, output] of [
|
||||
["internal-readiness-ui.spec.ts", "const WECOM_UA", "[SCREENSHOT_DIR]"],
|
||||
["pc-finance-admin-visual-wave4.spec.ts", "const visualUser", "[outputDir, taskOutputDir]"],
|
||||
]) {
|
||||
const source = readFileSync(new URL(`../../../client/tests/e2e/${file}`, import.meta.url), "utf8");
|
||||
assert.ok(source.includes(marker));
|
||||
const prefix = source.slice(0, source.indexOf(marker));
|
||||
assert.ok(!prefix.includes("07-18-small-team-internal-readiness"));
|
||||
const script = ts.transpileModule(prefix + `\nglobalThis.observed = ${output};`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const sandbox = { exports: {}, process: { cwd: () => path.join(root, "client"), env: configured ? { QA_VISUAL_OUTPUT_DIR: outputRoot } : {} }, require: name => name === "node:fs" ? fs : name === "node:path" ? path : { test: { use() {} } } };
|
||||
vm.runInNewContext(script, sandbox);
|
||||
for (const directory of sandbox.observed) {
|
||||
assert.ok(directory.startsWith(`${outputRoot}${path.sep}`));
|
||||
assert.ok(!directory.includes("07-18-small-team-internal-readiness"));
|
||||
assert.ok(!directories.includes(directory));
|
||||
directories.push(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const TEST_FILE_PATTERN = /\.(?:spec|test)\.[cm]?[jt]sx?$/u;
|
||||
|
||||
export function fullLocalSpecFiles(root) {
|
||||
const files = [];
|
||||
function visit(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "manual") visit(path.join(directory, entry.name));
|
||||
} else if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
|
||||
files.push(path.relative(root, path.join(directory, entry.name)).split(path.sep).join("/"));
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
throw new Error("full_local_symlink_requires_explicit_ownership");
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(path.join(root, "client/tests/e2e"));
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function sourceFile(root, file) {
|
||||
if (typeof file !== "string" || !file) return "invalid_source";
|
||||
let relative = path.isAbsolute(file) ? path.relative(root, file) : file;
|
||||
relative = relative.replaceAll("\\", "/");
|
||||
if (relative.startsWith("tests/e2e/")) relative = `client/${relative}`;
|
||||
if (!relative.startsWith("client/tests/e2e/")) relative = `client/tests/e2e/${relative}`;
|
||||
relative = path.posix.normalize(relative);
|
||||
return relative.startsWith("client/tests/e2e/") ? relative : "invalid_source";
|
||||
}
|
||||
|
||||
export function verifyFullLocalDiscovery(root, report) {
|
||||
const expectedSpecs = fullLocalSpecFiles(root);
|
||||
const cases = [];
|
||||
const observed = new Set();
|
||||
const issues = [];
|
||||
function visit(suites, inheritedFile = "") {
|
||||
for (const suite of Array.isArray(suites) ? suites : []) {
|
||||
const suiteFile = suite.file ?? inheritedFile;
|
||||
for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
|
||||
const file = sourceFile(root, spec.file ?? suiteFile);
|
||||
observed.add(file);
|
||||
if (!Array.isArray(spec.tests) || !spec.tests.length) issues.push("spec_without_cases");
|
||||
for (const test of Array.isArray(spec.tests) ? spec.tests : []) {
|
||||
cases.push({ file, id: spec.id, title: spec.title, line: spec.line, projectName: test.projectName ?? "", repeatEachIndex: test.repeatEachIndex ?? 0 });
|
||||
}
|
||||
}
|
||||
visit(suite.suites, suiteFile);
|
||||
}
|
||||
}
|
||||
visit(report?.suites);
|
||||
if (!Array.isArray(report?.suites) || !Array.isArray(report?.errors)) issues.push("invalid_discovery_report");
|
||||
if (report?.errors?.length) issues.push("discovery_errors");
|
||||
if (!expectedSpecs.length || !cases.length) issues.push("empty_discovery");
|
||||
const missingSpecs = expectedSpecs.filter((file) => !observed.has(file));
|
||||
const unexpectedSpecs = [...observed].filter((file) => !expectedSpecs.includes(file)).sort();
|
||||
if (missingSpecs.length) issues.push("missing_specs");
|
||||
if (unexpectedSpecs.length) issues.push("unexpected_specs");
|
||||
return {
|
||||
status: issues.length ? "FAIL" : "PASS",
|
||||
kind: "FULL_LOCAL_DISCOVERY_ONLY",
|
||||
expectedSpecs,
|
||||
observedSpecs: [...observed].sort(),
|
||||
missingSpecs,
|
||||
unexpectedSpecs,
|
||||
cases,
|
||||
issues: [...new Set(issues)],
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const summary = verifyFullLocalDiscovery(root, JSON.parse(readFileSync(process.argv[2], "utf8")));
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
process.exitCode = summary.status === "PASS" ? 0 : 1;
|
||||
} catch {
|
||||
console.error("[full-local-discovery] report_or_source_invalid");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,47 @@ create_and_bind_invoice() {
|
||||
echo "$invoice_id"
|
||||
}
|
||||
|
||||
set_non_positive_item_fixture() {
|
||||
DATABASE_URL="$DATABASE_URL" node - "$1" <<'NODE'
|
||||
const reimbursementId = process.argv[2];
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const itemId = await prisma.$transaction(async (tx) => {
|
||||
const items = await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (items.length !== 1) throw new Error('non_positive_item_fixture_requires_one_item');
|
||||
const item = await tx.reimbursementItem.update({
|
||||
where: { id: items[0].id },
|
||||
data: { amount: '-1.00' },
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
const aggregate = await tx.reimbursementItem.aggregate({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
if (item.amount.toFixed(2) !== '-1.00' || aggregate._sum.amount?.toFixed(2) !== '-1.00') {
|
||||
throw new Error('non_positive_item_fixture_not_persisted');
|
||||
}
|
||||
return item.id;
|
||||
});
|
||||
process.stdout.write(itemId);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
set_reimbursement_total_amount() {
|
||||
local reimbursement_id="$1"
|
||||
local total_amount="$2"
|
||||
@@ -988,16 +1029,21 @@ withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$
|
||||
assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup"
|
||||
assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup"
|
||||
|
||||
echo "[6/23] negative_not_allowed: submit blocked when total_amount < 0"
|
||||
RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")"
|
||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null
|
||||
set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00"
|
||||
echo "[6/23] items_positive_amount_required: submit blocked for a negative item"
|
||||
RID_NON_POSITIVE_ITEM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/non-positive-item-create.json")"
|
||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NON_POSITIVE_ITEM" "non-positive-item" >/dev/null
|
||||
NON_POSITIVE_ITEM_ID="$(set_non_positive_item_fixture "$RID_NON_POSITIVE_ITEM")"
|
||||
|
||||
negative_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NEGATIVE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-submit.json" '{}')"
|
||||
assert_status "$negative_submit_status" "400" "negative_not_allowed submit" "$TMP_DIR/negative-submit.json"
|
||||
assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allowed submit"
|
||||
assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit"
|
||||
assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed"
|
||||
non_positive_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NON_POSITIVE_ITEM/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/non-positive-item-submit.json" '{}')"
|
||||
assert_status "$non_positive_submit_status" "400" "items_positive_amount_required submit" "$TMP_DIR/non-positive-item-submit.json"
|
||||
assert_error_code "$TMP_DIR/non-positive-item-submit.json" "VALID_001" "items_positive_amount_required submit"
|
||||
assert_error_detail "$TMP_DIR/non-positive-item-submit.json" '.error.details.items' "positive_amount_required" "items_positive_amount_required submit"
|
||||
assert_error_detail "$TMP_DIR/non-positive-item-submit.json" '.error.details.reasonCode' "reimbursement.items_positive_amount_required" "items_positive_amount_required submit"
|
||||
if ! jq -e --arg item_id "$NON_POSITIVE_ITEM_ID" '.error.details.itemIds == [$item_id]' "$TMP_DIR/non-positive-item-submit.json" >/dev/null; then
|
||||
echo "[ERROR] items_positive_amount_required must identify exactly the injected item" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_workflow_instance "$RID_NON_POSITIVE_ITEM" "items_positive_amount_required"
|
||||
|
||||
echo "[7/23] Prepare manager-missing context"
|
||||
set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__"
|
||||
@@ -1338,7 +1384,7 @@ echo "- reject reimbursementId: $RID_REJECT"
|
||||
echo "- return reimbursementId: $RID_RETURN"
|
||||
echo "- withdraw reimbursementId: $RID_WITHDRAW"
|
||||
echo "- perm reimbursementId: $RID_PERM"
|
||||
echo "- negative reimbursementId: $RID_NEGATIVE"
|
||||
echo "- non-positive item reimbursementId: $RID_NON_POSITIVE_ITEM"
|
||||
echo "- manager-skip reimbursementId: $RID_SKIP"
|
||||
echo "- no-actionable reimbursementId: $RID_NO_ACTIONABLE"
|
||||
echo "- transfer-ok reimbursementId: $RID_TRANSFER_OK"
|
||||
|
||||
@@ -4820,5 +4820,6 @@ async function reimbursementRoutes(fastify) {
|
||||
module.exports = reimbursementRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
ensureNonNegativeAmount,
|
||||
finalizeReimbursementSubmitSideEffects,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const { ensureNonNegativeAmount } = require("../../../src/routes/reimbursements").__testables;
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
describe("reimbursement total amount defense", () => {
|
||||
function assertRejected(value, detail, reasonCode) {
|
||||
let caught;
|
||||
try {
|
||||
ensureNonNegativeAmount(value);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AppError);
|
||||
expect(caught).toMatchObject({
|
||||
code: "VALID_001",
|
||||
statusCode: 400,
|
||||
details: { total_amount: detail, reasonCode },
|
||||
});
|
||||
}
|
||||
|
||||
it.each([-1, "-0.01", -Number.MIN_VALUE])("directly rejects negative total %s", (value) => {
|
||||
assertRejected(value, "negative_not_allowed", "reimbursement.total_amount_negative_not_allowed");
|
||||
});
|
||||
|
||||
it.each([NaN, Infinity, -Infinity, "not-a-number", undefined])("directly rejects non-finite total %s", (value) => {
|
||||
assertRejected(value, "invalid", "reimbursement.total_amount_invalid");
|
||||
});
|
||||
|
||||
it.each([0, "0.00", 1, "188.80"])("preserves acceptance of finite nonnegative total %s", (value) => {
|
||||
expect(() => ensureNonNegativeAmount(value)).not.toThrow();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户