|
@@ -1,21 +1,33 @@
|
|
|
import { test, expect } from '../fixtures/auth';
|
|
import { test, expect } from '../fixtures/auth';
|
|
|
|
|
+import { STORAGE_STATE_PATH } from '../fixtures/auth';
|
|
|
|
|
+import { request as playwrightRequest } from '@playwright/test';
|
|
|
import type { Page } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
|
+import fs from 'node:fs';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * G-01 非破坏性补强(S2-EV 安全夹具):
|
|
|
|
|
|
|
+ * G-01 非破坏性补强(S2-EV 安全夹具 + S2-EX cleanup 接入):
|
|
|
* M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
|
|
* M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
|
|
|
* M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
|
|
* M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
|
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
* M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
|
|
|
*
|
|
*
|
|
|
- * 安全契约(无后端 hard-delete API 之前由 fixture 强制保证):
|
|
|
|
|
|
|
+ * 安全契约(C1-C5 由 fixture 强制;C6 由 S2-EW cleanup endpoint 提供兜底):
|
|
|
* C1 spec 不闭单、不改数据、不调动作类接口。
|
|
* C1 spec 不闭单、不改数据、不调动作类接口。
|
|
|
* C2 spec 进入 run-once 前先快照 AUTO_WATCH 未闭环异常计数;
|
|
* C2 spec 进入 run-once 前先快照 AUTO_WATCH 未闭环异常计数;
|
|
|
* 若 baseline 为 0(即 dedup 目标缺失),spec 主动 test.skip(),避免任何写入。
|
|
* 若 baseline 为 0(即 dedup 目标缺失),spec 主动 test.skip(),避免任何写入。
|
|
|
* C3 任意一条 hit 出现 created=true 即立即 hard fail,并指明"无清理路径"。
|
|
* C3 任意一条 hit 出现 created=true 即立即 hard fail,并指明"无清理路径"。
|
|
|
* C4 spec 末尾再次快照计数;若 finalCount > baselineCount 立即 hard fail。
|
|
* C4 spec 末尾再次快照计数;若 finalCount > baselineCount 立即 hard fail。
|
|
|
* C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
|
|
* C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
|
|
|
|
|
+ * C6 test.afterAll 调用 DELETE /api/aidop/s8/watch-debug/test-cleanup 兜底软删任何
|
|
|
|
|
+ * RelatedObjectCode 以 TEST_G09_ 开头的异常;cleanup 失败立即 hard fail,不吞错。
|
|
|
*
|
|
*
|
|
|
- * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(当前后端无清理路径,禁止主动写入)。
|
|
|
|
|
|
|
+ * 当前 RUN STATUS:BLOCKED-by-test-seed-gap。
|
|
|
|
|
+ * 原因:现有 watch rule (id=1) 的 RelatedObjectCode 由 AdoS8DataSource 配置的 SQL
|
|
|
|
|
+ * 查询结果决定('EQ-01',业务设备数据),后端没有 API 注入 TEST_G09_* 设备行;
|
|
|
|
|
+ * 无法构造一条只命中 TEST_G09_* 对象的 watch rule。在出现安全 seed 路径前,
|
|
|
|
|
+ * 即便接入 C6 cleanup,spec 仍会因 baseline 检查(C2)skip 或 fail,**不应当 live 跑**。
|
|
|
|
|
+ * C6 兜底依然有价值:未来任何 TEST_G09_ 残留(人工种入或后续 seed 路径上线)会被 afterAll 清理。
|
|
|
|
|
+ *
|
|
|
|
|
+ * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(无 seed 路径前禁止主动写入)。
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
|
|
const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
|
|
@@ -52,6 +64,31 @@ async function postRunOnce(page: Page) {
|
|
|
return (await r.json()) as { count: number; results: any[] };
|
|
return (await r.json()) as { count: number; results: any[] };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 从 storage-state.json 读取 superAdmin access-token,用于不依赖 Page 的 hooks(afterAll)。
|
|
|
|
|
+ * storage-state.json 由 global-setup 在测试运行前生成;不存在则视为 setup 未跑,hook 主动 throw。
|
|
|
|
|
+ */
|
|
|
|
|
+function tokenFromStorageState(): string {
|
|
|
|
|
+ if (!fs.existsSync(STORAGE_STATE_PATH)) {
|
|
|
|
|
+ throw new Error(`storage-state.json not found at ${STORAGE_STATE_PATH}; global-setup must run first.`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const raw = fs.readFileSync(STORAGE_STATE_PATH, 'utf8');
|
|
|
|
|
+ const state = JSON.parse(raw) as { origins?: Array<{ localStorage?: Array<{ name: string; value: string }> }> };
|
|
|
|
|
+ for (const origin of state.origins ?? []) {
|
|
|
|
|
+ for (const item of origin.localStorage ?? []) {
|
|
|
|
|
+ if (/access-token$/i.test(item.name) && !/x-access-token$/i.test(item.name)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(item.value);
|
|
|
|
|
+ return typeof parsed === 'string' ? parsed : (parsed?.value ?? item.value);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return item.value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error('access-token not found in storage-state.json');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function getException(page: Page, id: string | number) {
|
|
async function getException(page: Page, id: string | number) {
|
|
|
const t = await token(page);
|
|
const t = await token(page);
|
|
|
const base = new URL(page.url()).origin;
|
|
const base = new URL(page.url()).origin;
|
|
@@ -182,3 +219,33 @@ test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage
|
|
|
`[${RUN_ID}] AUTO_WATCH pending 计数从 ${baseline.count} 漂移到 ${after.count};可能存在未清理的写入。`,
|
|
`[${RUN_ID}] AUTO_WATCH pending 计数从 ${baseline.count} 漂移到 ${after.count};可能存在未清理的写入。`,
|
|
|
).toBe(baseline.count);
|
|
).toBe(baseline.count);
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * C6 安全契约:测试套件结束后调用 S2-EW cleanup endpoint 兜底软删 TEST_G09_ 残留。
|
|
|
|
|
+ * 走独立的 request context(不依赖 authedPage 的 per-test 生命周期),从 storage-state.json
|
|
|
|
|
+ * 取 token;接口 404 / 401 / 500 一律 hard fail,不吞错。tenantId/factoryId=1/1 与本仓库
|
|
|
|
|
+ * dev e2e 约定一致;如未来上下文允许动态获取,应替换。
|
|
|
|
|
+ */
|
|
|
|
|
+test.afterAll(async () => {
|
|
|
|
|
+ const baseURL = process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888';
|
|
|
|
|
+ const apiOrigin = baseURL.replace(/\/+$/, '');
|
|
|
|
|
+ const token = tokenFromStorageState();
|
|
|
|
|
+ const ctx = await playwrightRequest.newContext({
|
|
|
|
|
+ baseURL: apiOrigin,
|
|
|
|
|
+ extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
|
|
|
|
+ });
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await ctx.delete('/api/aidop/s8/watch-debug/test-cleanup?tenantId=1&factoryId=1');
|
|
|
|
|
+ expect(
|
|
|
|
|
+ r.status(),
|
|
|
|
|
+ `[${RUN_ID}] cleanup endpoint 返回非 200(status=${r.status()});可能 debug endpoint 未启用或后端未升级。`,
|
|
|
|
|
+ ).toBe(200);
|
|
|
|
|
+ const body = (await r.json()) as { deletedCount?: number };
|
|
|
|
|
+ expect(
|
|
|
|
|
+ typeof body.deletedCount === 'number' && body.deletedCount >= 0,
|
|
|
|
|
+ `[${RUN_ID}] cleanup 响应 deletedCount 字段缺失或非数字:${JSON.stringify(body)}`,
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await ctx.dispose();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|