比较提交
4
次代码提交
750a184b8a
...
70af0c6b9a
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
70af0c6b9a | ||
|
|
0931b1ea49 | ||
|
|
59e20e45cc | ||
|
|
34675e1a98 |
@@ -22,7 +22,7 @@
|
||||
- After a successfully owned local server start, `run-web-gates.sh` recalculates `PLAYWRIGHT_OCR_MOCK` from that startup's `OCR_PROVIDER=mock` and `JOBS_ENABLED=true`; a caller's flag cannot enable unsupported capability. Each startup appends non-secret provider/job facts to `server-capabilities.jsonl` in the run report. Actual missing-field correction still has to pass; configuration alone is not behavior evidence.
|
||||
- R108 uses an active synthetic reimbursement submitted through formal APIs, with a valid PDF and explicit item binding. Initialization fails the test rather than skipping it. Preserve the employee approval refusal, current approve/return and no-reject contract, offline upload queue, 390/414 layouts, and independent output paths. An incomplete visual report is not PASS just because findings is empty.
|
||||
- The existing project visual matrix explicitly lists all 14 current system routes. Unknown wildcards/parameters and unowned system routes fail route-plan preparation. AP uses finance, system/audit uses admin, approval uses manager, and login uses an anonymous context. Require exact target URL plus a page-specific title or workspace anchor; 403/404/login fallback and same-origin API 403/404 are failures, not target page coverage. Mock response controls must declare UTF-8 so Chinese error headings exercise the intended guard.
|
||||
- Per-route arrival and full audit completion are separate: record completion only after the screenshot and all DOM checks. The approved matrix is 25 PC + 8 mobile pages, grouped into seven role/viewport tests with 180 seconds each and one timed synthetic preparation. Preserve all assertions, screenshots, configured workers and zero retries; only this file's groups remain serial as before. Compare exact planned/completed route-role-viewport identities, reject missing/duplicate/unexpected pages and wrong arrivals, and require all seven actual test statuses passed. Failed or skipped serial successors must leave the report incomplete; unrelated guard tests cannot overwrite it. Matrix coverage does not mean every product page. Live/frozen obligations remain in full discovery; focused passes do not change ordinary eligibility.
|
||||
- Per-route arrival and full audit completion are separate: record completion only after the screenshot and all DOM checks. The approved matrix is 25 PC + 8 mobile pages, grouped into seven role/viewport tests with 240 seconds each (per-test override; the Playwright project default stays 180 seconds and remains a receipt check) and one timed synthetic preparation. Preserve all assertions, screenshots, configured workers and zero retries; only this file's groups remain serial as before. Compare exact planned/completed route-role-viewport identities, reject missing/duplicate/unexpected pages and wrong arrivals, and require all seven actual test statuses passed. Failed or skipped serial successors must leave the report incomplete; unrelated guard tests cannot overwrite it. Matrix coverage does not mean every product page. Live/frozen obligations remain in full discovery; focused passes do not change ordinary eligibility.
|
||||
|
||||
|
||||
## Public OA Boundary
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## 2026-09-26 根因确认与修复(当前)
|
||||
|
||||
- [x] 带诊断复现命中(CG-20260926-0006,任务 48d14805):浏览器 01.973 发 GET,**05.049 收到 pending**;服务端 02.255 收到同一 GET,**05.598 才读库且读到 completed**。浏览器拿到的响应早于服务端读库,来源只能是 Service Worker 缓存。
|
||||
- [x] 根因:`client/vite.config.ts` 的 `app-api-runtime` 规则对 `/api/me`、`/api/workflow/tasks*`、`/api/workflow/approvers*`、`/api/notifications*` 用 `NetworkFirst` + `networkTimeoutSeconds: 3`;网络响应超过 3 秒时 SW 返回审批前缓存的旧响应,真实响应被丢弃。自 2026-03 P21PWA2(9112383d)起存在,生产同样受影响(慢网络/企微手机下审批后页面显示未通过,再点报「已处理」,待办列表可能显示已处理单据)。审批写入本身从未丢失。
|
||||
- [x] 修复:去掉 `networkTimeoutSeconds`,仅网络失败(离线)才回退缓存;退出登录清理该缓存(`client/src/pwa.ts:201`)不变。新增 `server/tests/unit/utils/client-pwa-api-cache.test.js`(有修复通过、去掉修复失败);构建产物 `dist/sw.js` 已无超时。
|
||||
- [ ] 修复后复现脚本 3 轮验证(修复前 3 轮命中 2 轮)。
|
||||
- [ ] 通过后 `verify-candidate` 取得 PASS 回执。
|
||||
- 后续另议:`/api/workflow/tasks*` 含报销审批内容仍会被 SW 缓存供离线读取,与「报销审批证据不进入离线缓存」口径是否冲突待确认;采购提交事务 P2028 见下方。
|
||||
|
||||
## 2026-09-26 调查进度
|
||||
|
||||
- [x] 非 managed 复现环境:`research/repro.sh`(需先 `sed` 抽取 run-web-gates.sh 1–929 行并删 9–17 行为 gates-lib.sh;库前缀须 `hyb_oa_webgate_`;直接调用 `prepareAccounts`)。采购 ×6 + permission-direct-access ×6,4 worker,约 13 分钟。
|
||||
- [x] 第 1 轮复现命中:CG-20260926-0006 页面停在「待派发」、历史仅「提交」;**保留库中该单 approved、任务 completed/approved、历史 start,approve**。结论:审批写入未丢失,问题在审批后页面刷新拿到/显示旧状态。时序:approve 06.418→09.868(200),task completed_at 07.948,页面 GET 10.289→13.099(200)。
|
||||
- [x] 第 2 轮加服务端 DIAG 读日志 + 浏览器响应监听(`research/diag-instrumentation.patch`,未提交,已从工作区还原):未复现;22 次成功审批后的 27 次读取无 pending。
|
||||
- [ ] 带诊断多跑几轮直至命中,确定旧状态来自服务端响应还是前端未应用。
|
||||
- [ ] 另行处理:负载下采购提交事务超过 Prisma 默认 5000ms 交互事务超时(P2028→500,`server/src/routes/purchaseRequests.js:1704`,两轮各 1–2 次)。生产 b1d25d3c 同写法,非本次回归。
|
||||
- 顺带出现的其他随机失败:permission-direct-access AP6(待办汇总计数)/PA6(VALID_001),负载相关,未分析。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 审批通过返回成功但任务状态未变的并发异常调查
|
||||
|
||||
## Goal
|
||||
|
||||
查清完整门禁中采购主链路间歇失败的根因:经理审批接口返回 200,但随后任务详情仍为 `pending`、处理历史无 `approve`。确认它是测试隔离问题还是产品缺陷;若为产品缺陷,确认影响范围(是否波及全部审批实体)及是否在生产 `b1d25d3c` 已存在。
|
||||
|
||||
## Background / Evidence(2026-09-26)
|
||||
|
||||
- 候选 `750a184b` / `34675e1a` 共 4 轮 `verify-candidate`(2 核/8G,4 worker 固定)。`procurement-flow.spec.ts:29` 在第 2、4 轮失败,第 1 轮通过;managed `retained-local` 单跑两次均通过。第 4 轮 371/372,只剩此项。
|
||||
- 失败断言 `procurement-flow.spec.ts:118`:审批后 30s 内 `main` 不含「已完成」。
|
||||
- 第 4 轮服务端日志(相对采购 submit):+45.6s `POST /api/workflow/tasks/024c0c17…/approve` → 200(2917ms);+48.8s 同页 `GET /api/workflow/tasks/024c0c17…` → 200(3085ms)。审批期间无 warn/error 日志。
|
||||
- 失败录像:确认框「处理中…」结束后,页面仍是同一任务、状态徽标「待派发」(= task `pending`)、可执行「通过/退回/驳回」,处理历史只有「提交」。
|
||||
- 静态核对:`executeApproveWorkflowTask`(`server/src/routes/workflow.js:1395`)全部写入在同一个 `$transaction` 内;任务详情 GET 直接读库、无缓存;前端 `onSuccess` 用 `refetchTask()` 刷新(`client/src/pages/WorkflowTaskDetailPage.tsx:96`),PWA 不缓存 `/api`。采购单未匹配预算池,`confirmBudgetForEntity` 不写账。审批路径及任务详情页自 `b1d25d3c` 以来仅删除 ERP 同步。
|
||||
- 第 1 轮(通过)与第 2/4 轮(失败)请求序列一致,差异只能在数据层判断;managed 环境强制 `QA_KEEP_DB=0`。
|
||||
|
||||
## Scope
|
||||
|
||||
- 非 managed 本地复现:本机 `hyb-oa-db` 临时库、`QA_KEEP_DB=1`,采购主链路与 `permission-direct-access` 并发重复运行;失败后直接查库区分「写入丢失」与「读取陈旧/错读」。
|
||||
- 根因确认后给出最小修复方案(测试隔离或产品代码),修复另行确认后实施。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
生产/ARM 访问、真实数据写入、push/部署、放宽断言或改用重试掩盖。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 在保留库的环境复现至少一次,并记录失败单据任务/实例/历史的库内真实状态
|
||||
- [x] 给出有证据支撑的根因(代码位置 + 机制),区分测试问题与产品缺陷
|
||||
- [x] 若为产品缺陷:说明影响实体范围,及 `b1d25d3c` 是否同样存在(SW 规则覆盖 /api/me、workflow/tasks、approvers、notifications;自 2026-03 9112383d 起存在,生产同样受影响)
|
||||
- [ ] 修复后 `verify-candidate` 完整通过(取得 PASS 回执)
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 回归单测:`server/tests/unit/utils/client-pwa-api-cache.test.js` 锁定 `app-api-runtime` 不得设置网络超时(去掉修复即失败);`client-pwa-lifecycle.test.js` 保持退出登录清理运行时缓存。
|
||||
- 构建产物核对:`client/dist/sw.js` 中 `app-api-runtime` 的 `NetworkFirst` 不含 `networkTimeoutSeconds`。
|
||||
- 负载复现:`research/repro.sh`(采购 ×8 + permission-direct-access ×8,4 worker)修复前 3 轮命中 2 轮,修复后 3 轮 0 命中。
|
||||
- 完整门禁:`verify-candidate` 取得 PASS 回执。
|
||||
@@ -0,0 +1,25 @@
|
||||
diff --git a/client/tests/e2e/procurement-flow.spec.ts b/client/tests/e2e/procurement-flow.spec.ts
|
||||
index aa156213..e4777e10 100644
|
||||
--- a/client/tests/e2e/procurement-flow.spec.ts
|
||||
+++ b/client/tests/e2e/procurement-flow.spec.ts
|
||||
@@ -30,6 +30,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const title = `Playwright 采购 ${Date.now()}`;
|
||||
let requestNo = "";
|
||||
|
||||
+ page.on("response", async (r) => { const u = new URL(r.url()); if (r.request().method() === "GET" && /^\/api\/workflow\/tasks\/[0-9a-f-]{36}$/.test(u.pathname)) { try { const j = await r.json(); console.log("DIAG-CLIENT", new Date().toISOString(), u.pathname.slice(-36), r.status(), j?.data?.task?.status, j?.data?.workflowInstance?.currentStatus); } catch { console.log("DIAG-CLIENT parse-fail", u.pathname); } } });
|
||||
+ page.on("request", (q) => { const u = new URL(q.url()); if (/^\/api\/workflow\/tasks\//.test(u.pathname)) console.log("DIAG-REQ", new Date().toISOString(), q.method(), u.pathname.slice(-44)); });
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "采购")).click();
|
||||
diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js
|
||||
index 3bb49e47..5b5c715e 100644
|
||||
--- a/server/src/routes/workflow.js
|
||||
+++ b/server/src/routes/workflow.js
|
||||
@@ -2306,6 +2306,7 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
+ request.log.info({ diagTaskId: taskId, diagTaskStatus: task?.status ?? null, diagInstanceStatus: task?.workflow_instance?.current_status ?? null }, "DIAG task detail read");
|
||||
if (!task) {
|
||||
const existing = await fastify.prisma.workflowTask.findUnique({
|
||||
where: { id: taskId },
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
S=/tmp/claude-1000/-home-shunli-Projects-hyb-oa/118e4fec-64a1-446b-8573-31e26633f5d6/scratchpad
|
||||
MODE=diag-repro
|
||||
export QA_KEEP_DB=1 QA_DB_PREFIX=hyb_oa_webgate_repro
|
||||
export QA_FULL_REPORT_DIR=/home/shunli/Projects/hyb-oa/artifacts/qa-flow/repro-$(date +%H%M%S)
|
||||
source $S/gates-lib.sh
|
||||
trap qa_cleanup EXIT
|
||||
qa_select_port
|
||||
export JOBS_ENABLED=true RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false
|
||||
qa_export_app_env
|
||||
export IM_NOTIFIER_ENABLED=false
|
||||
qa_seed_synthetic_credentials
|
||||
qa_prepare_db
|
||||
qa_build_client
|
||||
qa_start_server
|
||||
QA_DB_NAME="$QA_DB_NAME" E2E_BASE_URL="$QA_BASE_URL" node --input-type=module -e '
|
||||
import { prepareAccounts } from "/home/shunli/Projects/hyb-oa/scripts/qa/prepare-local-e2e-accounts.mjs";
|
||||
const r = await prepareAccounts({ origin: process.env.E2E_BASE_URL, initialPassword: process.env.QA_SYNTHETIC_INITIAL_PASSWORD, databaseName: process.env.QA_DB_NAME });
|
||||
console.log("accounts", r.status, r.accounts.length);'
|
||||
echo "REPRO_DB=$QA_DB_NAME REPRO_URL=$QA_BASE_URL SERVER_LOG=$QA_SERVER_LOG"
|
||||
cd "$CLIENT_DIR"
|
||||
set +e
|
||||
E2E_BASE_URL="$QA_BASE_URL" E2E_MODE=local node node_modules/@playwright/test/cli.js test procurement-flow.spec.ts permission-direct-access.spec.ts \
|
||||
--repeat-each="${REPEAT:-6}" --workers=4 --retries=0 --reporter=line --output="$QA_FULL_REPORT_DIR/test-results"
|
||||
echo "playwright_exit=$?"
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "approve-success-state-unchanged",
|
||||
"name": "approve-success-state-unchanged",
|
||||
"title": "审批通过返回成功但任务状态未变的并发异常调查",
|
||||
"description": "verify-candidate 34675e1a/750a184b 在4 worker负载下采购主链路3轮挂2次:经理approve返回200,随后任务详情仍pending且历史无approve;单跑每次通过。需保留数据库负载复现查库,确认是否为新引入并影响全部审批。",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": "pwa-service-worker",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-26",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"client/vite.config.ts",
|
||||
"server/tests/unit/utils/client-pwa-api-cache.test.js",
|
||||
"client/src/pwa.ts"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {
|
||||
"implementationApproval": "APPROVED_BY_USER_20260926"
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
||||
// The URL changes before the saved form state reaches the navigation guard;
|
||||
// going back earlier is blocked as "unsaved" on a loaded host.
|
||||
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "false");
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
|
||||
@@ -606,6 +606,8 @@ test.describe.serial("项目级全页面视觉巡检", () => {
|
||||
|
||||
for (const group of groups) {
|
||||
test(group.id, async ({ browser }) => {
|
||||
// Admin audits 15 pages in one group; 180s ran out on a loaded 2-core host.
|
||||
test.setTimeout(240_000);
|
||||
const groupPlans = plans.filter(plan => plan.viewport === group.viewport && plan.role === group.role);
|
||||
await auditPlans(browser, groupPlans, group.viewport);
|
||||
expect(findings.filter(finding => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath()}`).toEqual([]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from "@playwright/test";
|
||||
import { reimbursementPaymentFlow } from "./helpers/reimbursementPaymentFlow";
|
||||
|
||||
test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
// Every stage finished at ~305s under 4 workers on a 2-core host (~145s alone).
|
||||
test.setTimeout(420_000);
|
||||
await reimbursementPaymentFlow(page);
|
||||
});
|
||||
|
||||
+2
-1
@@ -136,10 +136,11 @@ export default defineConfig(({ mode }) => {
|
||||
url.pathname.startsWith("/api/notifications")
|
||||
);
|
||||
},
|
||||
// Fall back to cache only when the network fails (offline). A network
|
||||
// timeout here served pre-approval task state to slow-but-online clients.
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "app-api-runtime",
|
||||
networkTimeoutSeconds: 3,
|
||||
cacheableResponse: {
|
||||
statuses: [200],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const viteConfig = readFileSync(path.resolve(__dirname, "../../../../client/vite.config.ts"), "utf8");
|
||||
|
||||
function runtimeCacheRule(cacheName) {
|
||||
const marker = `cacheName: "${cacheName}"`;
|
||||
const at = viteConfig.indexOf(marker);
|
||||
expect(at, `${cacheName} rule must exist`).toBeGreaterThan(-1);
|
||||
const start = viteConfig.lastIndexOf("urlPattern:", at);
|
||||
const end = viteConfig.indexOf("urlPattern:", at);
|
||||
return viteConfig.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
describe("client PWA API runtime cache", () => {
|
||||
it("falls back to cached API data only when the network fails, never on a slow response", () => {
|
||||
const rule = runtimeCacheRule("app-api-runtime");
|
||||
expect(rule).toContain('handler: "NetworkFirst"');
|
||||
// A timeout made the service worker answer a post-approval refetch with the
|
||||
// cached pre-approval task after 3s, so the page kept showing "待派发".
|
||||
expect(rule).not.toMatch(/networkTimeoutSeconds/);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户