From d3ace78141b7381f15bae28d9a4088ca77872f30 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Mon, 21 Sep 2026 22:35:51 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20S1-01=20=E5=A4=8D=E9=AA=8C?= =?UTF-8?q?=E6=8C=87=E5=90=91=E7=BA=BF=E4=B8=8A=E6=BA=90=E6=97=B6=E5=A6=82?= =?UTF-8?q?=E5=AE=9E=E8=B7=B3=E8=BF=87=E9=A2=98=E5=BA=93=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 CSP_E2E_ORIGIN 指向线上站点跑 e2e:s1-01 时,「题库异常降级」段要把坏题库写进 lessons/s1-01/index.html 再复验,而线上页面是只读的、注入改不动:其中 6 项报失败(页面 其实没坏),7 项因为线上碰巧满足而假通过。改成按来源判定:本机来源照旧整段跑;来源是 线上只读内容时逐项打印 SKIP 并说明原因,期望项数由 88 减去跳过名单长度推出(75)。 - 跳过名单显式列出,跳过几项由名单算出来,不写死第二套数字 - 本机跑完自查名单与实跑逐项一致,改一段忘了改名单会当场失败(exit 4) - 新增两条静态契约测试,绑定名单与实测检查项、只读判定与推导式 - 手工清单补一句,说明这一段为何在线上来源跳过 验证:bun test 796/796;本机 e2e:s1-01 88/88;线上来源 75/75 且打印 13 项 SKIP。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/e2e-count-facts.test.ts | 35 ++++++++ tests/e2e/s1-01-cdp.ts | 127 ++++++++++++++++++++++------ tests/e2e/s1-01-manual-checklist.md | 2 + 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/tests/e2e-count-facts.test.ts b/tests/e2e-count-facts.test.ts index 302e0b7..46f7616 100644 --- a/tests/e2e-count-facts.test.ts +++ b/tests/e2e-count-facts.test.ts @@ -325,6 +325,41 @@ test("S1-01 单课复验的项数与 s1-01-cdp.ts 的声明一致", () => { expect(row).toContain(`${actual} 项检查覆盖八个场景`); }); +/** 单课复验里「题库异常降级」那一段的检查项名单,逐项列在脚本里。 */ +function poolFaultCheckNames(): string[] { + const match = read(singleLessonScript).match(/const poolFaultCheckNames = \[([\s\S]*?)\];/); + if (!match) throw new Error(`${singleLessonScript} 里找不到 poolFaultCheckNames 名单`); + return [...match[1].matchAll(/"([^"]+)"/g)].map((item) => item[1]); +} + +test("单课复验的跳过名单与该段的实际检查逐项对齐", () => { + const source = read(singleLessonScript); + // 从名单声明之后再找这一段:脚本开头的说明注释里也会提到段名。 + const listAt = source.indexOf("const poolFaultCheckNames"); + if (listAt < 0) throw new Error(`${singleLessonScript} 里找不到 poolFaultCheckNames 名单`); + const sectionAt = source.indexOf("E2E-S1-01-05", listAt); + if (sectionAt < 0) throw new Error(`${singleLessonScript} 里找不到 E2E-S1-01-05 段`); + + // 这一段之后的 check(...) 就是题库异常降级段实际跑的检查。 + const ran = [...source.slice(sectionAt).matchAll(/check\("([^"]+)"/g)].map((item) => item[1]); + const declared = poolFaultCheckNames(); + + expect(ran.length).toBeGreaterThan(0); + expect([...declared].sort()).toEqual([...ran].sort()); +}); + +test("单课复验在只读来源下如实跳过题库异常降级段,而不是报失败", () => { + const source = read(singleLessonScript); + + // 这一段要把坏题库写进 lessons/s1-01/index.html 再复验,只有被复验的页面就是本机 + // checkout 时才成立;判定必须看来源地址,不能看「有没有设置 CSP_E2E_ORIGIN」—— + // 指向自己的本地开发服务器时,注入仍然有效。 + expect(source).toMatch(/servesLocalCheckout\(origin\)/); + // 跳过几项由名单推出来,不能再写死第二套数字,否则「88 项」和「跳过」会各说各话。 + expect(source).toMatch(/expectedChecks\s*-\s*[^;\n]*poolFaultCheckNames\.length/); + expect(source).toContain("SKIP"); +}); + test("两个复验脚本都自带项数自查,声明与实跑不符会当场失败", () => { for (const document of [typedScript, singleLessonScript]) { expect({ document, selfCheck: read(document).includes("检查项数与声明不符") }).toEqual({ diff --git a/tests/e2e/s1-01-cdp.ts b/tests/e2e/s1-01-cdp.ts index 64810f5..182db78 100644 --- a/tests/e2e/s1-01-cdp.ts +++ b/tests/e2e/s1-01-cdp.ts @@ -13,6 +13,9 @@ * CSP_E2E_CDP_PORT 无头 Chrome 的调试端口(默认 9333) * CSP_E2E_CHROME Chrome 可执行文件路径 * CSP_E2E_ORIGIN 复用已在运行的服务地址(设置后不自行启动服务器) + * + * 指向线上站点时,「题库异常降级」那段(要把坏题库写进 lessons/s1-01/index.html)无从验证: + * 会逐项打印 SKIP 并说明原因,期望项数相应减到 88 减去跳过名单的长度,而不是报失败。 */ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -41,11 +44,50 @@ const checks: Check[] = []; // 文档里的项数也绑在这里(见 tests/e2e-count-facts.test.ts)。 const expectedChecks = 88; +// E2E-S1-01-05 要把坏题库写进 lessons/s1-01/index.html 再复验,只有被复验的页面就是本机 +// checkout 时才成立。指向线上站点时页面是只读的:注入改不动线上内容,这一段既不能算通过 +// (线上只是碰巧满足),也不能算失败(不是页面坏了),只能如实跳过。 +// 跳过几项由这张名单推出来,改一段忘了改名单会由跑完后的自查当场抓住。 +const poolFaultCheckNames = [ + "空题库时小测面板仍可见", + "空题库不渲染题目", + "空题库说明题库不可用", + "空题库提示无法组卷并保留进度", + "空题库时提交按钮不可点击", + "空题库不显示伪造的分数", + "空题库时页面其余部分仍可用", + "越界答案临时改动已生效", + "答案越界时不渲染无法判分的试卷", + "答案越界时说明题目无效", + "答案越界时提示无法组卷", + "答案越界时提交按钮不可点击", + "临时改动已还原", +]; +const skipped: string[] = []; + +/** 被复验的页面是不是本机 checkout:只有本机来源才能改文件再复验。 */ +function servesLocalCheckout(value: string): boolean { + try { + const host = new URL(value).hostname; + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; + } catch { + return false; + } +} + +const poolFaultApplicable = servesLocalCheckout(origin); + function check(name: string, ok: boolean, detail = "") { checks.push({ name, ok, detail }); console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` -> ${detail}` : ""}`); } +/** 如实记账:这一段没跑就是没跑,既不写成通过也不写成失败。 */ +function skip(name: string, reason: string) { + skipped.push(name); + console.log(`SKIP ${name} -> ${reason}`); +} + function section(title: string) { console.log(`\n=== ${title} ===`); } @@ -222,6 +264,8 @@ const poolPattern = /const questionPool = (\[[\s\S]*?\n {6}\]);/; let client: Cdp | null = null; let failure: unknown = null; +/** 题库异常降级段在 checks 里的起点,供跑完后的名单自查使用。 */ +let poolFaultStart = 0; try { await ensureDevServer(); @@ -682,7 +726,8 @@ try { check("重试把待写答案补写落盘", midSession.answerCountAfterRetry === 1, `已保存答案 ${midSession.answerCountAfterRetry} 条`); check("恢复后不再显示重试入口", midSession.retryAfterRecovery === false, ""); - section("E2E-S1-01-05 题库异常降级"); + section(`E2E-S1-01-05 题库异常降级${poolFaultApplicable ? "" : "(当前来源不是本机 checkout,这一段跳过)"}`); + poolFaultStart = checks.length; async function probeBrokenPool() { await visit(client!, 3); return client!.evaluateJson(`(() => { @@ -697,29 +742,35 @@ try { })()`); } - try { - writeFileSync(lessonFile, originalLessonHtml.replace(poolPattern, "const questionPool = [];")); - const empty = await probeBrokenPool(); - check("空题库时小测面板仍可见", empty.panelVisible === true, ""); - check("空题库不渲染题目", empty.questions === 0, `题目 ${empty.questions} 道`); - check("空题库说明题库不可用", empty.formText.includes("题库暂时不可用或题目无效"), empty.formText.slice(0, 40)); - check("空题库提示无法组卷并保留进度", empty.resultText.includes("无法组卷") && empty.resultText.includes("进度没有丢失"), empty.resultText); - check("空题库时提交按钮不可点击", empty.submitDisabled === true, ""); - check("空题库不显示伪造的分数", !/\d+\s*\/\s*3/.test(`${empty.formText}${empty.resultText}`), ""); - check("空题库时页面其余部分仍可用", empty.pageUsable === true, ""); - - const poolLiteral = originalLessonHtml.match(poolPattern)![1]; - const brokenPool = poolLiteral.replace(/answer: \d+/g, "answer: 99"); - check("越界答案临时改动已生效", brokenPool !== poolLiteral, ""); - writeFileSync(lessonFile, originalLessonHtml.replace(poolPattern, `const questionPool = ${brokenPool};`)); - const outOfRange = await probeBrokenPool(); - check("答案越界时不渲染无法判分的试卷", outOfRange.questions === 0, `题目 ${outOfRange.questions} 道`); - check("答案越界时说明题目无效", outOfRange.formText.includes("题库暂时不可用或题目无效"), outOfRange.formText.slice(0, 40)); - check("答案越界时提示无法组卷", outOfRange.resultText.includes("无法组卷"), outOfRange.resultText); - check("答案越界时提交按钮不可点击", outOfRange.submitDisabled === true, ""); - } finally { - writeFileSync(lessonFile, originalLessonHtml); - check("临时改动已还原", readFileSync(lessonFile, "utf8") === originalLessonHtml, ""); + if (!poolFaultApplicable) { + for (const name of poolFaultCheckNames) { + skip(name, `${origin} 不是本机 checkout,坏题库注入改不动被复验的页面`); + } + } else { + try { + writeFileSync(lessonFile, originalLessonHtml.replace(poolPattern, "const questionPool = [];")); + const empty = await probeBrokenPool(); + check("空题库时小测面板仍可见", empty.panelVisible === true, ""); + check("空题库不渲染题目", empty.questions === 0, `题目 ${empty.questions} 道`); + check("空题库说明题库不可用", empty.formText.includes("题库暂时不可用或题目无效"), empty.formText.slice(0, 40)); + check("空题库提示无法组卷并保留进度", empty.resultText.includes("无法组卷") && empty.resultText.includes("进度没有丢失"), empty.resultText); + check("空题库时提交按钮不可点击", empty.submitDisabled === true, ""); + check("空题库不显示伪造的分数", !/\d+\s*\/\s*3/.test(`${empty.formText}${empty.resultText}`), ""); + check("空题库时页面其余部分仍可用", empty.pageUsable === true, ""); + + const poolLiteral = originalLessonHtml.match(poolPattern)![1]; + const brokenPool = poolLiteral.replace(/answer: \d+/g, "answer: 99"); + check("越界答案临时改动已生效", brokenPool !== poolLiteral, ""); + writeFileSync(lessonFile, originalLessonHtml.replace(poolPattern, `const questionPool = ${brokenPool};`)); + const outOfRange = await probeBrokenPool(); + check("答案越界时不渲染无法判分的试卷", outOfRange.questions === 0, `题目 ${outOfRange.questions} 道`); + check("答案越界时说明题目无效", outOfRange.formText.includes("题库暂时不可用或题目无效"), outOfRange.formText.slice(0, 40)); + check("答案越界时提示无法组卷", outOfRange.resultText.includes("无法组卷"), outOfRange.resultText); + check("答案越界时提交按钮不可点击", outOfRange.submitDisabled === true, ""); + } finally { + writeFileSync(lessonFile, originalLessonHtml); + check("临时改动已还原", readFileSync(lessonFile, "utf8") === originalLessonHtml, ""); + } } } catch (error) { failure = error; @@ -749,10 +800,34 @@ if (failure) { } const failed = checks.filter((item) => !item.ok); +// 本机复验时题库异常降级段整段都会跑;来源只读时那几项如实跳过,期望项数随之减去跳过名单。 +const expectedRunCount = expectedChecks - (poolFaultApplicable ? 0 : poolFaultCheckNames.length); + +if (poolFaultApplicable) { + // 名单与实跑必须逐项对齐:改一段忘了改名单,跳过几项就会算错,进而把项数自查骗过去。 + const ran = checks.slice(poolFaultStart).map((item) => item.name); + const missing = poolFaultCheckNames.filter((name) => !ran.includes(name)); + const extra = ran.filter((name) => !poolFaultCheckNames.includes(name)); + if (missing.length > 0 || extra.length > 0) { + console.error( + `题库异常降级段的检查项与 poolFaultCheckNames 名单不符:` + + `名单里有而没跑 ${missing.join(" | ") || "无"};跑了而名单里没有 ${extra.join(" | ") || "无"}。`, + ); + process.exit(4); + } +} + console.log(`\n${checks.length - failed.length}/${checks.length} 项浏览器检查通过`); -if (checks.length !== expectedChecks) { +if (skipped.length > 0) { + console.log( + `${skipped.length} 项跳过:题库异常降级段需要把坏题库写进本机 checkout,` + + `当前来源 ${origin} 是只读的线上内容,注入改不动被复验的页面。`, + ); +} +if (checks.length !== expectedRunCount) { console.error( - `检查项数与声明不符:脚本声明 ${expectedChecks} 项,实际跑了 ${checks.length} 项。` + + `检查项数与声明不符:脚本声明 ${expectedChecks} 项、跳过 ${expectedChecks - expectedRunCount} 项,` + + `实际跑了 ${checks.length} 项。` + `\n请同步 tests/e2e/s1-01-cdp.ts、tests/e2e/manual-checklist.md、docs/roadmap.md 与 README.md 里的数字。`, ); process.exit(3); diff --git a/tests/e2e/s1-01-manual-checklist.md b/tests/e2e/s1-01-manual-checklist.md index 7fbea56..ea0a776 100644 --- a/tests/e2e/s1-01-manual-checklist.md +++ b/tests/e2e/s1-01-manual-checklist.md @@ -30,6 +30,8 @@ **预期:** 两种情况都显示“题库暂时不可用或题目无效”的说明和“无法组卷”提示,提交按钮不可点击;任务进度、本地存储与常见问题互动继续可用,不出现白屏或伪造的分数。 +这一段要靠“改坏本机文件再复验”才能验,所以在自动化里指向线上站点时(`CSP_E2E_ORIGIN=https://cplus.talkincode.net bun run e2e:s1-01`)会逐项打印 SKIP 并说明原因,既不写成通过也不写成失败;降级行为必须在本机来源上复验。 + ## E2E-S1-01-03 恢复与降级 1. 完成任意任务、在补分号框里敲字或选择部分小测答案后刷新页面。