diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index c3c3ff9..ffd697f 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -27,9 +27,7 @@ jobs: run: npm ci - name: Test and build working-directory: frontend - env: - VITE_BASE: /${{ github.event.repository.name }}/ - run: npm test && npm run build + run: npm test && npm run build:pages - name: Publish gh-pages branch uses: peaceiris/actions-gh-pages@v4 with: diff --git a/.gitignore b/.gitignore index 1af6aee..75506d9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ Thumbs.db .env .env.* !.env.example +!frontend/.env.pages diff --git a/docs/accessibility-checklist.md b/docs/accessibility-checklist.md index c066838..f3297f8 100644 --- a/docs/accessibility-checklist.md +++ b/docs/accessibility-checklist.md @@ -33,6 +33,14 @@ - `styles.css` 已確認含 `max-width: 720px` responsive rules 與 `prefers-reduced-motion` rules。 - 手動驗收已通過:使用實體鍵盤確認 `Tab`/`Enter`/`Space` 可操作 Lesson/Lab controls,terminal submit、reset、錯誤回饋與返回課程地圖流程可完成;reduced-motion 偏好下內容與操作仍完整。`GIT-REVIEW` 已通過。 +### 2026-08-23:M6 delivery topic browser smoke + +- CI/CD Lesson → Lab route 與 Deploy Lesson → Lab route 可達;兩個 Lab 的 native buttons、command input、`aria-live` feedback、progressbar 與 reset 都存在。 +- CI/CD 已驗證 green pipeline 與 test failure;failure state 保留 `lint`/`build: not-run`、required check failed 與 merge gate blocked。 +- Deploy 已驗證 green release、artifact blocked、probe failure → rollback、release record、Pages pointer 與 reset/replay completion。 +- CI/CD、Deploy 在 390×844 viewport 下 document width 為 375,mobile menu 可開關,command input 可取得 focus;兩個 topic styles 都含 `prefers-reduced-motion` rule。 +- M6 full regression:83 test files / 265 tests、TypeScript lint、GitHub Pages base-path build 與 `git diff --check` 通過。完整 release 結論見 [`release-audit.md`](./release-audit.md)。 + ## 1. Keyboard 與焦點 - [ ] 所有互動控制項都能以 `Tab`、`Shift+Tab`、`Enter` 或 `Space` 操作。 diff --git a/docs/build-acceptance.md b/docs/build-acceptance.md index c5c0ba5..4ed5d1d 100644 --- a/docs/build-acceptance.md +++ b/docs/build-acceptance.md @@ -11,7 +11,7 @@ ```text cat package.json → npm run lint - → VITE_BASE=/software-engineering-workshop/ npm run build + → npm run build:pages → ls dist → npm run preview → BUILD complete @@ -21,7 +21,7 @@ cat package.json - `lint` 是 TypeScript gate;先擋住 source 層的型別錯誤。 - `build` 產出 `dist/index.html` 與 hashed assets。 -- `VITE_BASE=/software-engineering-workshop/` 對應 GitHub Pages project site 的 nested path。 +- `npm run build:pages` 透過 `--mode pages` 載入 `frontend/.env.pages`,其 `VITE_BASE=/software-engineering-workshop/` 對應 GitHub Pages project site 的 nested path。 - `preview` 服務 `dist/`,不是 dev server,也不是 production server。 - `dist/` 是可發布 artifact,不應被當成手寫 source。 diff --git a/docs/cicd-acceptance.md b/docs/cicd-acceptance.md index 67de100..aca6e93 100644 --- a/docs/cicd-acceptance.md +++ b/docs/cicd-acceptance.md @@ -15,7 +15,7 @@ 2. 讀懂 workflow trigger,知道 push、pull request 與手動 dispatch 會在什麼 ref 上啟動哪些 fixture job。 3. 理解 checkout、Node setup/cache、install、test、lint、build 的順序與 gate 責任。 4. 以 required check 判斷 pull request 是否可以進入 merge,而不是只看某個單獨 step 的綠色結果。 -5. 分辨 test failure 與 build failure 的 evidence,知道下游 step 在 job failure 後不應被偽造為已執行。 +5. 分辨 install、test 與 build failure 的 evidence,知道下游 step 在 job failure 後不應被偽造為已執行。 6. 用固定的 success/failure scenario 重跑 pipeline,理解 reset、retry 與 deterministic feedback 的關係;本 Lab 不連接真實 GitHub Actions。 ## 2. 教學邊界 @@ -45,7 +45,7 @@ CI/CD Lesson → run build → publish required check → 判斷 mergeable/blocked - → 完成 success、test failure、build failure 三個 scenarios + → 完成 success、install failure、test failure、build failure 四個 scenarios → reset 後重跑 green pipeline regression → 標記 CI/CD topic complete ``` @@ -67,6 +67,7 @@ CI/CD Lesson | Scenario | Fixture input | Fixture outcome | 教學重點 | | --- | --- | --- | --- | | `pull-request-green` | event `pull_request`、base `dev`、test/lint/build 全部 pass | `frontend` required check passed,merge gate `mergeable` | 完整 CI gate 通過才表示 pull request 可以進入 merge。 | +| `pull-request-install-failure` | event `pull_request`、base `dev`、`npm ci` fixture failed | install `failed`;test/lint/build `not-run`;required check failed;merge gate blocked | install 是第一個依賴 boundary,不能用舊的 node_modules 或下游輸出假裝通過。 | | `pull-request-test-failure` | event `pull_request`、base `dev`、test fixture failed | test `failed`;lint/build `not-run`;required check failed;merge gate blocked | 不把下游未執行的 steps 畫成綠色,先保留第一個 failure boundary。 | | `pull-request-build-failure` | event `pull_request`、base `dev`、test/lint pass、build fixture failed | build `failed`;required check failed;merge gate blocked | test 與 lint 綠色不能掩蓋 production build failure。 | @@ -204,8 +205,9 @@ CicdLabState { 只有下列條件全部成立時,CI/CD Lab 才算完成: -- 三個 required scenarios 都完成各自的 terminal outcome。 +- 四個 required scenarios 都完成各自的 terminal outcome。 - `pull-request-green` 顯示完整 stage、required check passed 與 mergeable。 +- `pull-request-install-failure` 顯示 install failed、test/lint/build not-run、required check failed 與 merge gate blocked。 - `pull-request-test-failure` 顯示 test failed、lint/build not-run、required check failed 與 merge gate blocked。 - `pull-request-build-failure` 顯示 test/lint passed、build failed、artifact missing、required check failed 與 merge gate blocked。 - reset 後重跑 green pipeline,trigger、ref、stage status、check、merge gate 與 feedback 與第一次一致。 @@ -224,7 +226,7 @@ CicdLabState { ## 11. CICD-01 驗收 -- 文件明確描述 CI/CD boundary、trigger/ref、workflow fixture、九個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- 文件明確描述 CI/CD boundary、trigger/ref、workflow fixture、九個 observable stages、四個 scenarios、failure feedback、completion 與 out-of-scope。 - `CICD-02` 可依本文件撰寫 lesson 與 workflow fixture,不需要重新決定 job step、required check 或 failure semantics。 - `CICD-03` 可依本文件建立純 simulator;不需要真實 GitHub Actions、runner、network、secret 或 shell。 - `CICD-04` 可依本文件設計 Lab 的 trigger selector、step evidence、required check、merge gate、reset、keyboard、mobile 與 reduced-motion interaction。 diff --git a/docs/deploy-acceptance.md b/docs/deploy-acceptance.md index 5fc6b64..5853e2b 100644 --- a/docs/deploy-acceptance.md +++ b/docs/deploy-acceptance.md @@ -15,7 +15,7 @@ 2. 讀懂部署 workflow 的 `main` trigger、`workflow_dispatch`、`frontend/dist` 與 `gh-pages` publish branch。 3. 理解 release source、版本識別、Pages base path 與 artifact provenance 的關係。 4. 在 artifact 缺失或部署驗證失敗時,保留 blocked/failed evidence,不把失敗版本誤標成 live。 -5. 以固定 release scenario 練習成功發布、artifact 缺失與 rollback 到上一個可用版本。 +5. 以固定 release scenario 練習成功發布、artifact 缺失、base path mismatch 與 rollback 到上一個可用版本。 6. 用部署狀態、live probe、release record 與 rollback evidence 判斷一次交付是否真的完成。 ## 2. 教學邊界 @@ -26,6 +26,8 @@ fixture 以目前 repository 的 `.github/workflows/deploy-pages.yml` 為輸入 本主題不重新定義 CI 的 test/lint/build gate;它只接收一個固定的 CI passed/artifact state,專注 release、publish、verify、record 與 rollback。真實 domain、DNS、CDN、帳號權限與維運告警留給後續產品決策。 +Pages build 使用 `frontend/.env.pages` 與 `npm run build:pages` 載入 repository-specific `VITE_BASE`;這個公開路徑設定不依賴 shell-specific environment assignment。 + ## 3. Lesson/Lab Pageflow ```text @@ -45,7 +47,7 @@ Deploy Lesson → verify deployment → record release → evaluate release/rollback - → 完成 success、missing artifact、rollback 三個 scenarios + → 完成 success、missing artifact、base path mismatch、rollback 四個 scenarios → reset 後重跑 green release regression → 標記 Deploy topic complete ``` @@ -67,6 +69,7 @@ Deploy Lesson | --- | --- | --- | --- | | `main-pages-success` | source `main`、CI passed、`dist` verified、Pages base path verified | `gh-pages` updated to `release-2026.08.23`;live probe 200;release `verified` | artifact、publish 與 live verification 必須全部完成,才是成功部署。 | | `missing-artifact-blocked` | source `main`、CI passed、`dist` missing | publish blocked;`gh-pages` 保持上一個 verified version;release `blocked` | build/artifact 缺失時不可更新 Pages,也不可假裝 live。 | +| `base-path-mismatch-blocked` | source `main`、CI passed、`dist` verified、Pages base path mismatch | publish blocked;`gh-pages` 保持上一個 verified version;release `blocked` | artifact 存在不代表資產可用;repository path 與 Vite base path 不一致時必須阻擋發布。 | | `rollback-after-probe-failure` | source `main`、new artifact verified、publish succeeded、live probe failed | release `release-2026.08.23` failed;rollback 到 `release-2026.08.16`;live probe 200 | rollback 指向上一個可用版本,保留失敗版本與原因。 | 每個 scenario 都必須讓學習者看見:release source、version、CI result、artifact、base path、Pages branch pointer、publish result、deployment status、live URL/probe、release record 與 rollback evidence;失敗時要看見未更新或已回復的邊界。 @@ -108,10 +111,8 @@ jobs: cache-dependency-path: frontend/package-lock.json - run: npm ci working-directory: frontend - - run: npm test && npm run build + - run: npm test && npm run build:pages working-directory: frontend - env: - VITE_BASE: /${{ github.event.repository.name }}/ - uses: peaceiris/actions-gh-pages@v4 with: publish_dir: ./frontend/dist @@ -140,7 +141,7 @@ inspect-workflow | inspect workflow | `cat .github/workflows/deploy-pages.yml` | main trigger、workflow_dispatch、dist、gh-pages 可見 | 未 inspect 時不可選擇 release source | | select release | `main → release-2026.08.23` | source、candidate version 與 previous verified version 可見 | 非 main source 應被阻擋 | | verify CI artifact | `artifact: frontend/dist` | CI passed、dist exists、artifact provenance 可見 | artifact missing 時 publish 不可更新 gh-pages | -| verify Pages base | `VITE_BASE=/software-engineering-workshop/` | base path 與 repository path 一致 | base path mismatch 時 deployment blocked | +| verify Pages base | `cat frontend/.env.pages` | profile 的 base path 與 repository path 一致 | base path mismatch 時 deployment blocked | | publish Pages | `publish → gh-pages` | gh-pages pointer 指向 candidate version | artifact/base path 不完整時 branch 保持 previous version | | verify deployment | `probe /software-engineering-workshop/` | live status 200、candidate version 可觀測 | probe failure 要保留 failed version,不宣稱 live | | record release | `record release` | version、source、artifact、URL、status 完整 | 沒有 deployment evidence 時不可寫 verified record | @@ -206,9 +207,10 @@ DeployLabState { 只有下列條件全部成立時,Deploy Lab 才算完成: -- 三個 required scenarios 都完成各自的 terminal outcome。 +- 四個 required scenarios 都完成各自的 terminal outcome。 - `main-pages-success` 顯示 candidate artifact、gh-pages publish、live status 200 與 verified release record。 - `missing-artifact-blocked` 顯示 artifact missing、publish blocked、Pages pointer 保持 previous verified version 與 blocked record。 +- `base-path-mismatch-blocked` 顯示 artifact verified、base path mismatch、publish blocked、Pages pointer 保持 previous verified version 與 blocked record。 - `rollback-after-probe-failure` 顯示 candidate probe failed、failed release record、rollback version、Pages pointer 回到 previous verified version 與 rolled-back record。 - reset 後重跑 green release,source、version、artifact、Pages pointer、probe、record 與 feedback 與第一次一致。 - 完成後使用 `se-workshop-deploy-complete` 保存進度。 @@ -226,7 +228,7 @@ DeployLabState { ## 11. DEPLOY-01 驗收 -- 文件明確描述 release/artifact/Pages/live probe/rollback boundary、workflow fixture、八個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- 文件明確描述 release/artifact/Pages/live probe/rollback boundary、workflow fixture、八個 observable stages、四個 scenarios、failure feedback、completion 與 out-of-scope。 - `DEPLOY-02` 可依本文件撰寫 lesson 與 workflow/artifact fixture,不需要重新決定 release source、version 或 Pages semantics。 - `DEPLOY-03` 可依本文件建立純 simulator;不需要真實 GitHub Pages、network、secret、shell 或 runner。 - `DEPLOY-04` 可依本文件設計 Lab 的 release selector、stage evidence、live probe、rollback、reset、keyboard、mobile 與 reduced-motion interaction。 diff --git a/docs/logs-acceptance.md b/docs/logs-acceptance.md index 422e178..e32c5c2 100644 --- a/docs/logs-acceptance.md +++ b/docs/logs-acceptance.md @@ -107,6 +107,9 @@ const baseRequest = { method: "POST", route: "/orders", authorization: "Bearer test-secret-001", + password: "workshop-password-001", + accessToken: "access-token-001", + cookie: "session=workshop-session-001", email: "learner@example.test", payload: { sku: "book", quantity: 1 }, }; @@ -131,7 +134,7 @@ Fixture rules: - 每個 scenario 至少先產生 `request.received`,再產生一筆固定 terminal event;兩筆 event 的 `correlationId` 必須相同。 - `request.received` 的 `level` 固定為 `debug`、`source` 固定為 `api`、`outcome` 固定為 `started`。 - `request-success` 只能改變 terminal success outcome;不得同時加入 timeout 或 validation failure。 -- `validation-rejected` 只能因缺少 `amount` 被拒絕;不得輸出 `authorization`、`email` 或完整 payload。 +- `validation-rejected` 只能因缺少 `amount` 被拒絕;不得輸出任何敏感欄位或完整 payload。 - `dependency-timeout` 只能因固定 `payment-provider` 在 `3000ms` 逾時失敗;不得產生成功付款或訂單副作用。 - 相同 initial state 加上相同 event sequence,必須得到相同的 events、feedback、terminal outcome 與 completion 結果。 - 不可使用目前時間、random UUID、網路回應、真實 provider、瀏覽器 local state 以外的外部輸入。 @@ -141,7 +144,7 @@ Fixture rules: Redaction 必須在 event 格式化前完成,而不是只在畫面上用 CSS 或字串替換遮住結果: - `authorization`、`password`、`accessToken`、`cookie` 與 `email` 的 raw value 不得出現在 `message`、`context`、`redactedFields` 或 serialized event output。 -- `redactedFields` 必須至少記錄本 fixture 中被移除的 `authorization` 與 `email`,並以固定順序輸出。 +- `redactedFields` 必須記錄本 fixture 中被移除的 `authorization`、`password`、`accessToken`、`cookie` 與 `email`,並以固定順序輸出。 - 安全輸出只能使用 allowlist 欄位;不得透過 `JSON.stringify(baseRequest)` 再事後刪除一部分文字。 - 任一 raw sensitive value 被找到時,scenario 必須進入 `redaction-failed` feedback,不能完成,也不能把該 event 當作有效證據。 - reset 後 redaction 結果必須與第一次執行完全一致。 diff --git a/docs/release-audit.md b/docs/release-audit.md index 996c391..e430e61 100644 --- a/docs/release-audit.md +++ b/docs/release-audit.md @@ -37,13 +37,13 @@ Core 19 個 topic 全部為 `ready`,Extension 2 個 topic 維持獨立完成 - `npm --prefix frontend test`:83 test files / 265 tests passed。 - `npm --prefix frontend run lint`:passed。 -- `VITE_BASE=/software-engineering-workshop/ npm --prefix frontend run build`:passed。 +- `npm --prefix frontend run build:pages`:passed。 - Pages build output `frontend/dist/index.html` 使用 `/software-engineering-workshop/assets/...` base path。 - `git diff --check`:passed。 ## RELEASE-006:release、遠端狀態與 Pages -- `git merge-tree --write-tree origin/main origin/dev`:clean,未發現 merge conflict。 +- 初始 `origin/main` ↔ `origin/dev` release merge-tree(功能 release PR #90 前)為 clean;#91 後新增的 closeout 文件在 `main` 與 `dev` 間產生預期的 docs-only add/add/content conflict,已在 `feature/m6-closeout-release` 手動解決,未涉及 source code。 - M5 的 feature PR(Docker、CI/CD、Deploy)均經 `feature/* → dev`、required checks 與 squash merge。 - Release PR #90 已 squash merge 到 `main`,merge commit:`2838d34`。 - Core release Publish workflow #16:`32611711879`,Success。 diff --git a/frontend/.env.pages b/frontend/.env.pages new file mode 100644 index 0000000..629be62 --- /dev/null +++ b/frontend/.env.pages @@ -0,0 +1,2 @@ +# Public, repository-specific base path for the GitHub Pages build profile. +VITE_BASE=/software-engineering-workshop/ diff --git a/frontend/package.json b/frontend/package.json index ddf809e..7d22587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:pages": "tsc -b && vite build --mode pages", "lint": "tsc --noEmit", "review": "node ../.github/scripts/ollama-review.mjs", "preview": "vite preview", diff --git a/frontend/src/components/tab-navigation.test.ts b/frontend/src/components/tab-navigation.test.ts new file mode 100644 index 0000000..06f8dd3 --- /dev/null +++ b/frontend/src/components/tab-navigation.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { tabIndexForKey } from "./tab-navigation"; + +describe("tab keyboard navigation", () => { + it("wraps through horizontal and vertical arrow keys", () => { + expect(tabIndexForKey("ArrowRight", 0, 3)).toBe(1); + expect(tabIndexForKey("ArrowDown", 2, 3)).toBe(0); + expect(tabIndexForKey("ArrowLeft", 0, 3)).toBe(2); + expect(tabIndexForKey("ArrowUp", 2, 3)).toBe(1); + }); + + it("jumps to the first or last tab with Home and End", () => { + expect(tabIndexForKey("Home", 2, 3)).toBe(0); + expect(tabIndexForKey("End", 0, 3)).toBe(2); + }); + + it("ignores unsupported keys and invalid tab positions", () => { + expect(tabIndexForKey("Enter", 1, 3)).toBeNull(); + expect(tabIndexForKey("ArrowRight", -1, 3)).toBeNull(); + expect(tabIndexForKey("ArrowRight", 0, 0)).toBeNull(); + }); +}); diff --git a/frontend/src/components/tab-navigation.ts b/frontend/src/components/tab-navigation.ts new file mode 100644 index 0000000..c3bc411 --- /dev/null +++ b/frontend/src/components/tab-navigation.ts @@ -0,0 +1,8 @@ +export function tabIndexForKey(key: string, currentIndex: number, tabCount: number): number | null { + if (tabCount <= 0 || currentIndex < 0 || currentIndex >= tabCount) return null; + if (key === "Home") return 0; + if (key === "End") return tabCount - 1; + if (key === "ArrowRight" || key === "ArrowDown") return (currentIndex + 1) % tabCount; + if (key === "ArrowLeft" || key === "ArrowUp") return (currentIndex - 1 + tabCount) % tabCount; + return null; +} diff --git a/frontend/src/git/release-simulator.test.ts b/frontend/src/git/release-simulator.test.ts index 8a2468a..05a763b 100644 --- a/frontend/src/git/release-simulator.test.ts +++ b/frontend/src/git/release-simulator.test.ts @@ -68,6 +68,38 @@ describe("Git cowork release simulator", () => { expect(result.output.join(" ")).toContain("Fork"); }); + it("rejects commands with unsupported suffixes instead of matching by prefix", () => { + const initial = createInitialGitReleaseState(); + const invalidClone = runGitReleaseCommand(initial, "git clone --mirror "); + const invalidCommit = runGitReleaseCommand(initial, "git commit --amend"); + + expect(invalidClone.accepted).toBe(false); + expect(invalidClone.state).toBe(initial); + expect(invalidCommit.accepted).toBe(false); + expect(invalidCommit.state).toBe(initial); + }); + + it("rejects force push after the workflow reaches the push step", () => { + const beforePush = runCommands([ + "Fork repository", + "git clone ", + "git checkout -b feature/profile", + "git stash", + "git stash pop", + "git diff", + "git add src/profile.ts", + 'git commit -m "add profile page"', + "git fetch origin", + "git pull --rebase origin dev", + "git rebase origin/dev", + "git cherry-pick a1b2c3d", + ]); + const result = runGitReleaseCommand(beforePush, "git push --force"); + + expect(result.accepted).toBe(false); + expect(result.state).toBe(beforePush); + }); + it("supports GitLab Merge Request terminology", () => { let state = createInitialGitReleaseState(); state = runGitReleaseEvent(state, { type: "set-provider", provider: "gitlab" }).state; diff --git a/frontend/src/git/release-simulator.ts b/frontend/src/git/release-simulator.ts index 4898dd1..a2acef7 100644 --- a/frontend/src/git/release-simulator.ts +++ b/frontend/src/git/release-simulator.ts @@ -92,6 +92,35 @@ export const GIT_RELEASE_PIPELINE_JOBS: readonly GitReleaseState["pipelineJobs"] "build", ]; +const GIT_RELEASE_COMMAND_ALLOWLIST: Readonly> = { + "fork repository": "fork", + fork: "fork", + "git clone ": "clone", + "git checkout -b feature/profile": "checkout", + "git switch -c feature/profile": "checkout", + "git stash": "stash", + "git stash pop": "stash-pop", + "git stash apply": "stash-pop", + "git diff": "diff", + "git add src/profile.ts": "add", + 'git commit -m "add profile page"': "commit", + "git commit -m 'add profile page'": "commit", + "git fetch origin": "fetch", + "git pull --rebase origin dev": "pull", + "git rebase origin/dev": "rebase", + "git cherry-pick a1b2c3d": "cherry-pick", + "git push -u origin feature/profile": "push", + "open pr": "open-review", + "open mr": "open-review", + "open pr / mr": "open-review", + "run pipeline": "pipeline", + "ci run": "pipeline", + "resolve conflict": "resolve-conflict", + "merge pr": "merge", + "merge mr": "merge", + "merge pr / mr": "merge", +}; + export function createInitialGitReleaseState(): GitReleaseState { return { phase: "initial", @@ -235,23 +264,7 @@ export function runGitReleaseEvent(current: GitReleaseState, event: GitReleaseEv export function runGitReleaseCommand(current: GitReleaseState, rawCommand: string): GitReleaseCommandResult { const command = rawCommand.trim().replace(/\s+/g, " "); - const lower = command.toLowerCase(); - if (lower === "fork repository" || lower === "fork") return runGitReleaseEvent(current, { type: "fork" }); - if (lower.startsWith("git clone")) return runGitReleaseEvent(current, { type: "clone" }); - if (lower.startsWith("git checkout") || lower.startsWith("git switch")) return runGitReleaseEvent(current, { type: "checkout" }); - if (lower === "git stash") return runGitReleaseEvent(current, { type: "stash" }); - if (lower === "git stash pop" || lower === "git stash apply") return runGitReleaseEvent(current, { type: "stash-pop" }); - if (lower === "git diff") return runGitReleaseEvent(current, { type: "diff" }); - if (lower.startsWith("git add")) return runGitReleaseEvent(current, { type: "add" }); - if (lower.startsWith("git commit")) return runGitReleaseEvent(current, { type: "commit" }); - if (lower.startsWith("git fetch")) return runGitReleaseEvent(current, { type: "fetch" }); - if (lower.startsWith("git pull")) return runGitReleaseEvent(current, { type: "pull" }); - if (lower.startsWith("git rebase")) return runGitReleaseEvent(current, { type: "rebase" }); - if (lower.startsWith("git cherry-pick")) return runGitReleaseEvent(current, { type: "cherry-pick" }); - if (lower.startsWith("git push")) return runGitReleaseEvent(current, { type: "push" }); - if (lower === "open pr" || lower === "open mr" || lower === "open pr / mr") return runGitReleaseEvent(current, { type: "open-review" }); - if (lower === "run pipeline" || lower === "ci run") return runGitReleaseEvent(current, { type: "pipeline" }); - if (lower === "resolve conflict") return runGitReleaseEvent(current, { type: "resolve-conflict" }); - if (lower === "merge pr" || lower === "merge mr" || lower === "merge pr / mr") return runGitReleaseEvent(current, { type: "merge" }); + const step = GIT_RELEASE_COMMAND_ALLOWLIST[command.toLowerCase()]; + if (step) return runGitReleaseEvent(current, { type: step }); return blocked(current, "這個操作不在本次 Git release fixture;請先讀目前任務與下一步提示。"); } diff --git a/frontend/src/topics/build/content.test.ts b/frontend/src/topics/build/content.test.ts index 90de231..c76a58d 100644 --- a/frontend/src/topics/build/content.test.ts +++ b/frontend/src/topics/build/content.test.ts @@ -17,7 +17,7 @@ describe("build lesson content", () => { expect(buildLessonSteps.map((step) => step.command)).toEqual([ "cat package.json", "npm run lint", - "VITE_BASE=/software-engineering-workshop/ npm run build", + "npm run build:pages", "ls dist", "npm run preview", ]); diff --git a/frontend/src/topics/build/content.ts b/frontend/src/topics/build/content.ts index cdd4aac..e7e10c3 100644 --- a/frontend/src/topics/build/content.ts +++ b/frontend/src/topics/build/content.ts @@ -66,8 +66,8 @@ export const buildLessonSteps: readonly BuildLessonStep[] = [ { id: "bundle", title: "產出 production bundle", - command: "VITE_BASE=/software-engineering-workshop/ npm run build", - explanation: "用 Pages 的公開 base path 執行正式建置,將 source 轉成可部署的 dist artifact。", + command: "npm run build:pages", + explanation: "用 pages mode 載入 frontend/.env.pages 的公開 base path,將 source 轉成可部署的 dist artifact。", takeaway: "部署路徑是 build input,不能等上線後才猜。", }, { @@ -125,6 +125,7 @@ export const buildFileFixtures: readonly BuildFileFixture[] = [ lines: [ '"lint": "tsc --noEmit",', '"build": "tsc -b && vite build",', + '"build:pages": "tsc -b && vite build --mode pages",', '"preview": "vite preview"', ], }, @@ -132,8 +133,8 @@ export const buildFileFixtures: readonly BuildFileFixture[] = [ id: "vite-config", name: "vite.config.ts", lines: [ - "export default defineConfig({", - ' base: process.env.VITE_BASE ?? "/",', + 'const env = loadEnv(mode, frontendRoot, "");', + ' base: process.env.VITE_BASE ?? env.VITE_BASE ?? "/",', " plugins: [react()],", "});", ], @@ -172,7 +173,7 @@ export interface BuildFailureFixture { export const buildFailureFixtures: readonly BuildFailureFixture[] = [ { - command: "npm run build", + command: "npm run build:pages", message: "請先通過 TypeScript gate,再產出 production bundle。", expectedPhase: "blocked", }, diff --git a/frontend/src/topics/build/lab.tsx b/frontend/src/topics/build/lab.tsx index f66d6cc..9009f69 100644 --- a/frontend/src/topics/build/lab.tsx +++ b/frontend/src/topics/build/lab.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useMemo, useState } from "react"; +import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; import { buildFileFixtures, buildLabHappyPath, @@ -10,6 +10,7 @@ import { } from "./content"; import { createInitialBuildState, isBuildLabComplete, runBuildEvent } from "./simulator"; import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; +import { tabIndexForKey } from "../../components/tab-navigation"; interface BuildHistoryEntry { command?: string; @@ -29,8 +30,7 @@ function eventForCommand(rawCommand: string): BuildLabEvent | null { const command = rawCommand.trim().replace(/\s+/g, " "); if (command === "cat package.json") return { type: "inspect-scripts" }; if (command === "npm run lint") return { type: "typecheck" }; - if (command === "VITE_BASE=/software-engineering-workshop/ npm run build") return { type: "bundle" }; - if (command === "npm run build") return { type: "bundle" }; + if (command === "npm run build:pages") return { type: "bundle" }; if (command === "ls dist") return { type: "inspect-dist" }; if (command === "npm run preview") return { type: "preview" }; return null; @@ -57,6 +57,18 @@ function buildFileLines(state: BuildLabState, fileId: BuildFileId): readonly str return file.lines; } +function buildFileSlug(fileId: BuildFileId): string { + return fileId.replace(/[^a-z0-9]+/gi, "-"); +} + +function buildTabId(fileId: BuildFileId): string { + return `build-file-tab-${buildFileSlug(fileId)}`; +} + +function buildPanelId(fileId: BuildFileId): string { + return `build-file-panel-${buildFileSlug(fileId)}`; +} + export function buildLabProgress(state: BuildLabState): number { return Math.round((state.completedStepIds.length / buildLessonSteps.length) * 100); } @@ -129,27 +141,49 @@ export function BuildLab({ onComplete }: { onComplete?: () => void }) { workshop-build-lab {state.phase} -
- {buildFileFixtures.map((file) => ( +
+ {buildFileFixtures.map((file, index) => ( ))}
-
- {buildFileLines(state, selectedFile).map((line, index) => ( -
- {String(index + 1).padStart(2, "0")}{line || " "} -
- ))} -
+ {buildFileFixtures.map((file) => ( + + ))}
{history.map((entry, index) => (
diff --git a/frontend/src/topics/build/simulator.test.ts b/frontend/src/topics/build/simulator.test.ts index 9ec1faa..ff4f1b6 100644 --- a/frontend/src/topics/build/simulator.test.ts +++ b/frontend/src/topics/build/simulator.test.ts @@ -50,7 +50,7 @@ describe("build deterministic simulator", () => { it("documents the main out-of-order failure fixtures", () => { expect(buildFailureFixtures.map((fixture) => fixture.command)).toEqual([ - "npm run build", + "npm run build:pages", "ls dist", "npm run preview", ]); diff --git a/frontend/src/topics/build/simulator.ts b/frontend/src/topics/build/simulator.ts index 7d071af..46a34fb 100644 --- a/frontend/src/topics/build/simulator.ts +++ b/frontend/src/topics/build/simulator.ts @@ -105,7 +105,7 @@ export function runBuildEvent(current: BuildLabState, event: BuildLabEvent): Bui state, { selectedFile: "package-json", completedStepIds: withStep(state, "inspect-scripts"), lastCommand: "cat package.json" }, "已確認 lint、build、preview 三個 script 的責任分界。", - ["lint: tsc --noEmit", "build: tsc -b && vite build", "preview: vite preview"], + ["lint: tsc --noEmit", "build: tsc -b && vite build", "build:pages: tsc -b && vite build --mode pages", "preview: vite preview"], ); case "typecheck": if (!hasCompleted(state, "inspect-scripts")) { @@ -119,7 +119,7 @@ export function runBuildEvent(current: BuildLabState, event: BuildLabEvent): Bui ); case "bundle": if (!hasCompleted(state, "typecheck") || state.typecheckState !== "passed") { - return blocked(state, "npm run build", "請先通過 TypeScript gate,再產出 production bundle。 "); + return blocked(state, "npm run build:pages", "請先通過 TypeScript gate,再產出 production bundle。 "); } return accepted( state, @@ -128,10 +128,10 @@ export function runBuildEvent(current: BuildLabState, event: BuildLabEvent): Bui bundleState: "created", basePath: "/software-engineering-workshop/", completedStepIds: withStep(state, "bundle"), - lastCommand: "VITE_BASE=/software-engineering-workshop/ npm run build", + lastCommand: "npm run build:pages", }, "production bundle 已產生,並套用 GitHub Pages base path。", - ["tsc -b: passed", "vite build: passed", "base: /software-engineering-workshop/", "output: dist/"], + ["tsc -b: passed", "vite build --mode pages: passed", "base: /software-engineering-workshop/", "output: dist/"], ); case "inspect-dist": if (!hasCompleted(state, "bundle") || state.bundleState !== "created") { diff --git a/frontend/src/topics/cicd/content.test.ts b/frontend/src/topics/cicd/content.test.ts index fc90106..08d6501 100644 --- a/frontend/src/topics/cicd/content.test.ts +++ b/frontend/src/topics/cicd/content.test.ts @@ -58,9 +58,10 @@ describe("CI/CD topic content contract", () => { expect(cicdStageFixtures.every((fixture) => fixture.successEvidence.length > 0 && fixture.failureEvidence.length > 0)).toBe(true); }); - it("keeps green, test failure, and build failure scenarios distinct", () => { + it("keeps green, install failure, test failure, and build failure scenarios distinct", () => { expect(cicdScenarioFixtures.map((scenario) => scenario.id)).toEqual([ "pull-request-green", + "pull-request-install-failure", "pull-request-test-failure", "pull-request-build-failure", ]); @@ -74,6 +75,16 @@ describe("CI/CD topic content contract", () => { mergeGate: "mergeable", }); expect(cicdScenarioFixtures[1]).toMatchObject({ + installOutcome: "failed", + testOutcome: "not-run", + lintOutcome: "not-run", + buildOutcome: "not-run", + artifactState: "missing", + requiredCheck: "failed", + mergeGate: "blocked", + failureStage: "install-dependencies", + }); + expect(cicdScenarioFixtures[2]).toMatchObject({ testOutcome: "failed", lintOutcome: "not-run", buildOutcome: "not-run", @@ -82,7 +93,7 @@ describe("CI/CD topic content contract", () => { mergeGate: "blocked", failureStage: "run-test", }); - expect(cicdScenarioFixtures[2]).toMatchObject({ + expect(cicdScenarioFixtures[3]).toMatchObject({ testOutcome: "passed", lintOutcome: "passed", buildOutcome: "failed", @@ -95,6 +106,7 @@ describe("CI/CD topic content contract", () => { it("documents failure boundaries without running Actions or shell", () => { expect(cicdFailureFixtures.map((fixture) => fixture.expectedBoundary)).toEqual([ + "lockfile / runtime", "behavior gate", "TypeScript gate", "production artifact", diff --git a/frontend/src/topics/cicd/content.ts b/frontend/src/topics/cicd/content.ts index 9622ca7..5f459ef 100644 --- a/frontend/src/topics/cicd/content.ts +++ b/frontend/src/topics/cicd/content.ts @@ -184,7 +184,7 @@ export const cicdWorkflowFixture: CicdWorkflowFixture = { export type CicdStepOutcome = "passed" | "failed" | "not-run"; export type CicdTriggerEvent = "pull_request" | "push" | "workflow_dispatch"; export type CicdTargetRef = "dev" | "main"; -export type CicdScenarioId = "pull-request-green" | "pull-request-test-failure" | "pull-request-build-failure"; +export type CicdScenarioId = "pull-request-green" | "pull-request-install-failure" | "pull-request-test-failure" | "pull-request-build-failure"; export interface CicdScenarioFixture { id: CicdScenarioId; @@ -218,6 +218,21 @@ export const cicdScenarioFixtures: readonly CicdScenarioFixture[] = [ failureStage: null, learningPoint: "所有必要 gate 通過後,frontend required check 才能讓 PR mergeable。", }, + { + id: "pull-request-install-failure", + title: "Install failure 停住 pipeline", + triggerEvent: "pull_request", + targetRef: "dev", + installOutcome: "failed", + testOutcome: "not-run", + lintOutcome: "not-run", + buildOutcome: "not-run", + artifactState: "missing", + requiredCheck: "failed", + mergeGate: "blocked", + failureStage: "install-dependencies", + learningPoint: "npm ci 失敗時保留 lockfile/runtime evidence,test、lint、build 都必須維持 not-run。", + }, { id: "pull-request-test-failure", title: "Test failure 停住 pipeline", @@ -276,6 +291,7 @@ export interface CicdFailureFixture { } export const cicdFailureFixtures: readonly CicdFailureFixture[] = [ + { command: "npm ci # frontend", message: "install failed;test、lint 與 build 維持 not-run,先修正 lockfile/runtime boundary。", expectedBoundary: "lockfile / runtime" }, { command: "npm test", message: "test failed;lint 與 build 維持 not-run,先修正行為 gate。", expectedBoundary: "behavior gate" }, { command: "npm run lint", message: "lint 尚未通過;build 不能被標記為成功。", expectedBoundary: "TypeScript gate" }, { command: "npm run build", message: "build failed;production artifact missing,required check 會 blocked。", expectedBoundary: "production artifact" }, diff --git a/frontend/src/topics/cicd/lab.test.ts b/frontend/src/topics/cicd/lab.test.ts index 67501ca..d4e8817 100644 --- a/frontend/src/topics/cicd/lab.test.ts +++ b/frontend/src/topics/cicd/lab.test.ts @@ -5,6 +5,6 @@ import { cicdLabProgress } from "./lab"; describe("CI/CD Lab progress", () => { it("starts empty and records the first pipeline without claiming full completion", () => { expect(cicdLabProgress(createInitialCicdState())).toBe(0); - expect(cicdLabProgress(runCicdEvents(cicdGreenHappyPath).state)).toBe(27); + expect(cicdLabProgress(runCicdEvents(cicdGreenHappyPath).state)).toBe(20); }); }); diff --git a/frontend/src/topics/cicd/lab.tsx b/frontend/src/topics/cicd/lab.tsx index 99ffdf5..34bedb8 100644 --- a/frontend/src/topics/cicd/lab.tsx +++ b/frontend/src/topics/cicd/lab.tsx @@ -26,7 +26,7 @@ interface CicdHistoryEntry { } const INITIAL_HISTORY: readonly CicdHistoryEntry[] = [ - { lines: ["CI/CD sandbox v1", "固定 workflow、required check 與三個 pipeline scenario 已準備好。"] }, + { lines: ["CI/CD sandbox v1", "固定 workflow、required check 與四個 pipeline scenario 已準備好。"] }, ]; function scenarioFor(scenarioId: CicdScenarioId | null): CicdScenarioFixture | undefined { @@ -60,6 +60,7 @@ function eventForCommand(rawCommand: string): CicdLabEvent | null { function scenarioOutcomeLabel(scenario: CicdScenarioFixture): string { if (scenario.mergeGate === "mergeable") return "MERGEABLE"; + if (scenario.failureStage === "install-dependencies") return "INSTALL BLOCKED"; if (scenario.failureStage === "run-test") return "TEST BLOCKED"; return "BUILD BLOCKED"; } @@ -124,7 +125,7 @@ export function CicdLab({ onComplete }: { onComplete?: () => void }) { {completed ? ( diff --git a/frontend/src/topics/cicd/simulator.test.ts b/frontend/src/topics/cicd/simulator.test.ts index 9981ef9..8c38ad2 100644 --- a/frontend/src/topics/cicd/simulator.test.ts +++ b/frontend/src/topics/cicd/simulator.test.ts @@ -20,6 +20,16 @@ const testFailurePath = [ { type: "evaluate-merge-gate" as const }, ]; +const installFailurePath = [ + { type: "select-scenario" as const, scenarioId: "pull-request-install-failure" as const }, + { type: "inspect-workflow" as const }, + { type: "select-trigger" as const }, + { type: "checkout-source" as const }, + { type: "install-dependencies" as const }, + { type: "publish-required-check" as const }, + { type: "evaluate-merge-gate" as const }, +]; + const buildFailurePath = [ { type: "select-scenario" as const, scenarioId: "pull-request-build-failure" as const }, { type: "inspect-workflow" as const }, @@ -126,6 +136,32 @@ describe("CI/CD deterministic simulator", () => { }); }); + it("stops install failure before test and keeps every downstream gate not-run", () => { + const beforeCheck = runCicdEvents(installFailurePath.slice(0, 5)); + + expect(beforeCheck.accepted).toBe(true); + expect(beforeCheck.results.at(-1)?.observedFailure).toBe(true); + expect(beforeCheck.state).toMatchObject({ + phase: "blocked", + installState: "failed", + testState: "not-run", + lintState: "not-run", + buildState: "not-run", + artifactState: "missing", + requiredCheck: "pending", + mergeGate: "pending", + }); + expect(beforeCheck.state.lastFeedback).toContain("not-run"); + + const completed = runCicdEvents(installFailurePath, beforeCheck.state); + expect(completed.state).toMatchObject({ + phase: "completed", + requiredCheck: "failed", + mergeGate: "blocked", + completedScenarioIds: ["pull-request-install-failure"], + }); + }); + it("preserves test/lint success when build fails", () => { const result = runCicdEvents(buildFailurePath); @@ -147,6 +183,8 @@ describe("CI/CD deterministic simulator", () => { const fullFlow = [ ...cicdGreenHappyPath, { type: "reset" as const }, + ...installFailurePath, + { type: "reset" as const }, ...testFailurePath, { type: "reset" as const }, ...buildFailurePath, @@ -160,6 +198,7 @@ describe("CI/CD deterministic simulator", () => { expect(first.state).toEqual(second.state); expect(first.state.completedScenarioIds).toEqual([ "pull-request-green", + "pull-request-install-failure", "pull-request-test-failure", "pull-request-build-failure", ]); diff --git a/frontend/src/topics/cicd/simulator.ts b/frontend/src/topics/cicd/simulator.ts index 648beaa..0ff2ef1 100644 --- a/frontend/src/topics/cicd/simulator.ts +++ b/frontend/src/topics/cicd/simulator.ts @@ -316,6 +316,15 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv ); case "install-dependencies": if (!hasCompletedStage(state, "checkout-source")) return blocked(state, commandFor(event.type), "請先 checkout source,再執行 npm ci。 "); + if (scenario.installOutcome === "failed") { + return accepted( + state, + { phase: "blocked", activeStageId: "publish-required-check", completedStageIds: withStage(state, event.type), installState: "failed", testState: "not-run", lintState: "not-run", buildState: "not-run", artifactState: "missing", lastCommand: commandFor(event.type) }, + "npm ci failed;test、lint 與 build 維持 not-run,required check 後續會 blocked。", + [`node: ${cicdFixture.nodeVersion}`, `cache: ${cicdFixture.cacheDependencyPath}`, "npm ci: failed", "test: not-run", "lint: not-run", "build: not-run", "next: publish required check"], + true, + ); + } return accepted( state, { phase: "running", activeStageId: "run-test", completedStageIds: withStage(state, event.type), installState: scenario.installOutcome, lastCommand: commandFor(event.type) }, @@ -366,7 +375,7 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv ); case "publish-required-check": { const hasFailure = [state.installState, state.testState, state.lintState, state.buildState].includes("failed"); - if (!hasCompletedStage(state, "run-test")) return blocked(state, commandFor(event.type), "至少要先執行 test gate,才能彙總 required check。 "); + if (!hasCompletedStage(state, "install-dependencies")) return blocked(state, commandFor(event.type), "至少要先執行 install gate,才能彙總 required check。 "); if (!hasFailure && !hasCompletedStage(state, "run-build")) return blocked(state, commandFor(event.type), "test、lint、build 尚未全部完成,不能提前發布 required check。 "); const requiredCheck = hasFailure ? "failed" : "passed"; return accepted( @@ -395,7 +404,7 @@ export function runCicdEvent(current: CicdLabState, event: CicdLabEvent): CicdEv if (scenario.id === "pull-request-green") { Object.assign(nextState, completionAfterGreen(nextState)); if (nextState.regressionVerified && scenarioIds.every((scenarioId) => nextState.completedScenarioIds.includes(scenarioId))) { - nextState.lastFeedback = "三個 CI/CD scenario 與 reset regression 都完成;CI/CD Lab 完成。"; + nextState.lastFeedback = "四個 CI/CD scenario 與 reset regression 都完成;CI/CD Lab 完成。"; } } return { diff --git a/frontend/src/topics/deploy/content.test.ts b/frontend/src/topics/deploy/content.test.ts index 6984996..f6cac46 100644 --- a/frontend/src/topics/deploy/content.test.ts +++ b/frontend/src/topics/deploy/content.test.ts @@ -35,6 +35,8 @@ describe("Deploy lesson contract", () => { repositoryBasePath: "/software-engineering-workshop/", }); expect(deployWorkflowFixture.lines).toContain("publish_branch: gh-pages"); + expect(deployWorkflowFixture.lines).toContain("npm test && npm run build:pages · cwd frontend"); + expect(deployLessonSteps.find((step) => step.id === "verify-pages-base")?.command).toBe("cat frontend/.env.pages"); expect(deployLessonSteps).toHaveLength(8); expect(deployStageFixtures.map((fixture) => fixture.id)).toEqual(deployLessonSteps.map((step) => step.id)); expect(deployStageFixtures.every((fixture) => fixture.successEvidence.length > 0 && fixture.failureEvidence.length > 0)).toBe(true); @@ -44,11 +46,13 @@ describe("Deploy lesson contract", () => { expect(deployScenarioFixtures.map((scenario) => scenario.id)).toEqual([ "main-pages-success", "missing-artifact-blocked", + "base-path-mismatch-blocked", "rollback-after-probe-failure", ]); expect(deployScenarioFixtures[0]).toMatchObject({ artifactOutcome: "verified", publishOutcome: "published", finalRecord: "verified" }); expect(deployScenarioFixtures[1]).toMatchObject({ artifactOutcome: "missing", publishOutcome: "blocked", finalRecord: "blocked" }); - expect(deployScenarioFixtures[2]).toMatchObject({ deploymentOutcome: "rolled-back", previousVerifiedVersion: "release-2026.08.16", finalRecord: "rolled-back" }); + expect(deployScenarioFixtures[2]).toMatchObject({ artifactOutcome: "verified", basePathOutcome: "mismatch", publishOutcome: "blocked", finalRecord: "blocked" }); + expect(deployScenarioFixtures[3]).toMatchObject({ deploymentOutcome: "rolled-back", previousVerifiedVersion: "release-2026.08.16", finalRecord: "rolled-back" }); expect(deployFailureFixtures.map((fixture) => fixture.expectedBoundary)).toEqual(["CI / artifact", "base path", "live probe", "verify / rollback"]); }); }); diff --git a/frontend/src/topics/deploy/content.ts b/frontend/src/topics/deploy/content.ts index 23f0344..a2ae70b 100644 --- a/frontend/src/topics/deploy/content.ts +++ b/frontend/src/topics/deploy/content.ts @@ -66,7 +66,7 @@ export type DeployBasePathState = "unknown" | "verified" | "mismatch"; export type DeployPublishState = "pending" | "published" | "blocked"; export type DeployDeploymentState = "pending" | "live" | "failed" | "rolled-back"; export type DeployReleaseRecord = "none" | "verified" | "failed" | "blocked" | "rolled-back"; -export type DeployScenarioId = "main-pages-success" | "missing-artifact-blocked" | "rollback-after-probe-failure"; +export type DeployScenarioId = "main-pages-success" | "missing-artifact-blocked" | "base-path-mismatch-blocked" | "rollback-after-probe-failure"; export interface DeployLessonStep { id: DeployStageId; @@ -106,8 +106,8 @@ export const deployLessonSteps: readonly DeployLessonStep[] = [ id: "verify-pages-base", sectionId: "artifact-provenance", title: "驗證 Pages base path", - command: "VITE_BASE=/software-engineering-workshop/", - explanation: "確認 Vite base path 與 repository path 一致,避免 HTML 成功 publish 但資源 URL 指錯位置。", + command: "cat frontend/.env.pages", + explanation: "確認 pages build profile 的 VITE_BASE 與 repository path 一致,避免 HTML 成功 publish 但資源 URL 指錯位置。", takeaway: "Artifact 的可用性包含它會被部署到哪個 path。", }, { @@ -164,8 +164,8 @@ export const deployWorkflowFixture = { "actions/checkout@v4", "actions/setup-node@v4 · node 22 · cache npm", "npm ci · cwd frontend", - "npm test && npm run build · cwd frontend", - "VITE_BASE: /software-engineering-workshop/", + "npm test && npm run build:pages · cwd frontend", + "mode: pages · frontend/.env.pages", "peaceiris/actions-gh-pages@v4 · publish_dir frontend/dist", "publish_branch: gh-pages", ], @@ -180,7 +180,7 @@ export interface DeployScenarioFixture { previousVerifiedVersion: string; ciOutcome: "passed"; artifactOutcome: DeployArtifactState; - basePathOutcome: "verified"; + basePathOutcome: Exclude; publishOutcome: "published" | "blocked"; deploymentOutcome: "live" | "failed" | "rolled-back"; liveStatus: number | null; @@ -221,6 +221,22 @@ export const deployScenarioFixtures: readonly DeployScenarioFixture[] = [ finalRecord: "blocked", learningPoint: "沒有 frontend/dist 時 gh-pages 必須保持上一個 verified version,不能假裝已上線。", }, + { + id: "base-path-mismatch-blocked", + title: "Base path mismatch 阻擋發布", + trigger: "workflow_dispatch", + releaseSource: "main", + candidateVersion: deployFixture.candidateRelease, + previousVerifiedVersion: deployFixture.currentVerifiedRelease, + ciOutcome: "passed", + artifactOutcome: "verified", + basePathOutcome: "mismatch", + publishOutcome: "blocked", + deploymentOutcome: "failed", + liveStatus: null, + finalRecord: "blocked", + learningPoint: "artifact 存在不代表 Pages 可用;base path mismatch 時 publish 必須阻擋並保留上一版。", + }, { id: "rollback-after-probe-failure", title: "Live probe failure 後回滾", @@ -265,7 +281,7 @@ export interface DeployFailureFixture { export const deployFailureFixtures: readonly DeployFailureFixture[] = [ { command: "artifact: frontend/dist", message: "frontend/dist missing;gh-pages 保持上一個 verified version。", expectedBoundary: "CI / artifact" }, - { command: "VITE_BASE=/software-engineering-workshop/", message: "base path 尚未 verified;不能把 publish 當成 live。", expectedBoundary: "base path" }, + { command: "cat frontend/.env.pages", message: "VITE_BASE mismatch;不能安全更新 gh-pages。", expectedBoundary: "base path" }, { command: "probe /software-engineering-workshop/", message: "live probe failed;candidate release 需要 rollback。", expectedBoundary: "live probe" }, { command: "evaluate release / rollback", message: "failed release 尚未完成 rollback evidence,release 維持 blocked。", expectedBoundary: "verify / rollback" }, ] as const; diff --git a/frontend/src/topics/deploy/lab.test.ts b/frontend/src/topics/deploy/lab.test.ts index 72c4b9a..acf92a1 100644 --- a/frontend/src/topics/deploy/lab.test.ts +++ b/frontend/src/topics/deploy/lab.test.ts @@ -5,6 +5,6 @@ import { deployLabProgress } from "./lab"; describe("Deploy Lab progress", () => { it("starts empty and records the first release without claiming full completion", () => { expect(deployLabProgress(createInitialDeployState())).toBe(0); - expect(deployLabProgress(runDeployEvents(deployGreenHappyPath).state)).toBe(27); + expect(deployLabProgress(runDeployEvents(deployGreenHappyPath).state)).toBe(20); }); }); diff --git a/frontend/src/topics/deploy/lab.tsx b/frontend/src/topics/deploy/lab.tsx index b42fbb3..a13cd31 100644 --- a/frontend/src/topics/deploy/lab.tsx +++ b/frontend/src/topics/deploy/lab.tsx @@ -26,7 +26,7 @@ interface DeployHistoryEntry { } const INITIAL_HISTORY: readonly DeployHistoryEntry[] = [ - { lines: ["Deploy sandbox v1", "固定 Pages workflow、release record 與三個 deployment scenario 已準備好。"] }, + { lines: ["Deploy sandbox v1", "固定 Pages workflow、release record 與四個 deployment scenario 已準備好。"] }, ]; function scenarioFor(scenarioId: DeployScenarioId | null): DeployScenarioFixture | undefined { @@ -124,7 +124,7 @@ export function DeployLab({ onComplete }: { onComplete?: () => void }) { {completed ? ( diff --git a/frontend/src/topics/deploy/simulator.test.ts b/frontend/src/topics/deploy/simulator.test.ts index d50c0b9..8dd6da2 100644 --- a/frontend/src/topics/deploy/simulator.test.ts +++ b/frontend/src/topics/deploy/simulator.test.ts @@ -18,6 +18,16 @@ const missingArtifactPath = [ { type: "evaluate-release" as const }, ]; +const basePathMismatchPath = [ + { type: "select-scenario" as const, scenarioId: "base-path-mismatch-blocked" as const }, + { type: "inspect-workflow" as const }, + { type: "select-release" as const }, + { type: "verify-ci-artifact" as const }, + { type: "verify-pages-base" as const }, + { type: "record-release" as const }, + { type: "evaluate-release" as const }, +]; + const rollbackPath = [ { type: "select-scenario" as const, scenarioId: "rollback-after-probe-failure" as const }, { type: "inspect-workflow" as const }, @@ -108,6 +118,25 @@ describe("Deploy deterministic simulator", () => { }); }); + it("blocks publish and preserves the previous Pages pointer on base path mismatch", () => { + const result = runDeployEvents(basePathMismatchPath); + + expect(result.accepted).toBe(true); + expect(result.results[4]?.observedFailure).toBe(true); + expect(result.state).toMatchObject({ + phase: "completed", + artifactState: "verified", + basePathState: "mismatch", + publishState: "blocked", + pagesBranchVersion: "release-2026.08.16", + deploymentState: "failed", + liveStatus: null, + releaseRecord: "blocked", + completedScenarioIds: ["base-path-mismatch-blocked"], + }); + expect(result.state.lastFeedback).toContain("blocked"); + }); + it("preserves failed candidate evidence and rolls back after probe failure", () => { const result = runDeployEvents(rollbackPath); @@ -133,6 +162,8 @@ describe("Deploy deterministic simulator", () => { { type: "reset" as const }, ...missingArtifactPath, { type: "reset" as const }, + ...basePathMismatchPath, + { type: "reset" as const }, ...rollbackPath, { type: "reset" as const }, ...deployGreenHappyPath, @@ -145,6 +176,7 @@ describe("Deploy deterministic simulator", () => { expect(first.state.completedScenarioIds).toEqual([ "main-pages-success", "missing-artifact-blocked", + "base-path-mismatch-blocked", "rollback-after-probe-failure", ]); expect(first.state.regressionVerified).toBe(true); diff --git a/frontend/src/topics/deploy/simulator.ts b/frontend/src/topics/deploy/simulator.ts index 5689a32..0f927a8 100644 --- a/frontend/src/topics/deploy/simulator.ts +++ b/frontend/src/topics/deploy/simulator.ts @@ -318,6 +318,24 @@ export function runDeployEvent(current: DeployLabState, event: DeployLabEvent): ); case "verify-pages-base": if (!hasCompletedStage(state, "verify-ci-artifact") || state.artifactState !== "verified") return blocked(state, commandFor(event.type), "artifact 尚未 verified;先確認 frontend/dist,再檢查 Pages base path。 "); + if (scenario.basePathOutcome === "mismatch") { + return accepted( + state, + { + phase: "blocked", + activeStageId: "record-release", + completedStageIds: withStage(state, event.type), + basePathState: "mismatch", + publishState: "blocked", + pagesBranchVersion: scenario.previousVerifiedVersion, + deploymentState: "failed", + lastCommand: commandFor(event.type), + }, + "VITE_BASE 與 repository path 不一致;publish blocked,Pages pointer 保持上一個 verified version。", + [`VITE_BASE: mismatch`, `expected: ${deployFixture.repositoryBasePath}`, "publish: blocked", `gh-pages: ${scenario.previousVerifiedVersion}`, "next: record blocked release"], + true, + ); + } return accepted( state, { phase: "releasing", activeStageId: "publish-pages", completedStageIds: withStage(state, event.type), basePathState: scenario.basePathOutcome, lastCommand: commandFor(event.type) }, @@ -351,6 +369,15 @@ export function runDeployEvent(current: DeployLabState, event: DeployLabEvent): ); case "record-release": if (!hasCompletedStage(state, "verify-ci-artifact")) return blocked(state, commandFor(event.type), "至少要先驗證 CI artifact,再建立 release record。 "); + if (state.basePathState === "mismatch") { + return accepted( + state, + { phase: "blocked", activeStageId: "evaluate-release", completedStageIds: withStage(state, event.type), releaseRecord: "blocked", liveStatus: null, liveUrl: null, pagesBranchVersion: state.previousVerifiedVersion, lastCommand: commandFor(event.type) }, + "blocked release record 已保留 base path mismatch;candidate 不可發布,下一步是評估 release outcome。", + [`source: ${state.releaseSource}`, `candidate: ${state.candidateVersion}`, "base path: mismatch", "record: blocked", "next: evaluate release"], + true, + ); + } if (state.artifactState === "missing") { return accepted( state, diff --git a/frontend/src/topics/logs/content.test.ts b/frontend/src/topics/logs/content.test.ts index 64f0cc9..9df3b3d 100644 --- a/frontend/src/topics/logs/content.test.ts +++ b/frontend/src/topics/logs/content.test.ts @@ -89,16 +89,16 @@ describe("Logs lesson content", () => { }); it("proves redaction before serialized event output", () => { - expect(logsSensitiveFieldNames).toEqual(["authorization", "email"]); - expect(logsFixtureRedactedFields).toEqual(["authorization", "email"]); + expect(logsSensitiveFieldNames).toEqual(["authorization", "password", "accessToken", "cookie", "email"]); + expect(logsFixtureRedactedFields).toEqual(["authorization", "password", "accessToken", "cookie", "email"]); - const sensitiveValues = [logsBaseRequest.authorization, logsBaseRequest.email]; + const sensitiveValues = logsSensitiveFieldNames.map((field) => logsBaseRequest[field]); const serializedEvents = JSON.stringify(logsScenarios.map((scenario) => scenario.events)); sensitiveValues.forEach((value) => expect(serializedEvents).not.toContain(value)); logsScenarios.forEach((scenario) => { scenario.events.forEach((event) => { - expect(event.redactedFields).toEqual(["authorization", "email"]); + expect(event.redactedFields).toEqual(logsFixtureRedactedFields); expect(Object.keys(event.context).every((key) => logsSafeContextKeys.includes(key as (typeof logsSafeContextKeys)[number]))).toBe(true); }); }); diff --git a/frontend/src/topics/logs/content.ts b/frontend/src/topics/logs/content.ts index 50cd17b..30b34cb 100644 --- a/frontend/src/topics/logs/content.ts +++ b/frontend/src/topics/logs/content.ts @@ -40,6 +40,9 @@ export interface LogsRequestFixture { method: "POST"; route: "/orders"; authorization: string; + password: string; + accessToken: string; + cookie: string; email: string; payload: { sku: "book"; @@ -108,7 +111,7 @@ export const logsLesson: LessonDefinition = { "讀懂包含 level、event、source、correlationId 與 outcome 的結構化 log event。", "把正常完成、輸入被拒絕與依賴逾時分別記成 info、warn 與 error。", "用固定 correlationId 把同一個 request 的 events 串成可重現的時間線。", - "在格式化前使用 safe context allowlist,移除 authorization 與 email 等敏感欄位。", + "在格式化前使用 safe context allowlist,移除 authorization、password、accessToken、cookie 與 email。", "從固定 log 證據判斷 terminal outcome,並在 reset 後重跑相同 regression flow。", ], sections: [ @@ -183,7 +186,7 @@ export const logsLessonSteps: readonly LogsLessonStep[] = [ id: "redaction", title: "套用 safe context allowlist", code: "context = pick(input, [route, statusCode, dependency, timeoutMs])", - explanation: "只把 route、statusCode、field、dependency 與 timeout 等安全欄位放入 context;authorization 與 email 在格式化前被移除並列入 redactedFields。", + explanation: "只把 route、statusCode、field、dependency 與 timeout 等安全欄位放入 context;所有宣告的敏感欄位在格式化前被移除並列入 redactedFields。", takeaway: "Redaction 是輸出邊界的責任,不是畫面遮罩的責任。", }, { @@ -202,7 +205,7 @@ export const logsLessonSteps: readonly LogsLessonStep[] = [ }, ] as const; -export const logsSensitiveFieldNames: readonly LogsSensitiveField[] = ["authorization", "email"] as const; +export const logsSensitiveFieldNames: readonly LogsSensitiveField[] = ["authorization", "password", "accessToken", "cookie", "email"] as const; export const logsSafeContextKeys = [ "route", @@ -219,6 +222,9 @@ export const logsBaseRequest: LogsRequestFixture = { method: "POST", route: "/orders", authorization: "Bearer test-secret-001", + password: "workshop-password-001", + accessToken: "access-token-001", + cookie: "session=workshop-session-001", email: "learner@example.test", payload: { sku: "book", quantity: 1, amount: 90 }, }; @@ -399,6 +405,9 @@ export const logsResults: Readonly> = { columns: ["input field", "event output", "evidence"], rows: [ ["authorization", "absent", "redactedFields includes authorization"], + ["password", "absent", "redactedFields includes password"], + ["accessToken", "absent", "redactedFields includes accessToken"], + ["cookie", "absent", "redactedFields includes cookie"], ["email", "absent", "redactedFields includes email"], ["route", "safe context", "allowlisted for diagnosis"], ], @@ -451,6 +460,6 @@ export const logsFailureFixtures: readonly LogsFailureFixture[] = [ { event: "raw-sensitive-value", message: "輸出包含敏感欄位;先套用 safe context allowlist,再重新格式化 event。", - evidence: "authorization 與 email 的 raw value 不得出現在 message、context 或 serialized output。", + evidence: "authorization、password、accessToken、cookie 與 email 的 raw value 不得出現在 message、context 或 serialized output。", }, ] as const; diff --git a/frontend/src/topics/logs/lab.tsx b/frontend/src/topics/logs/lab.tsx index 5b570e0..24de0db 100644 --- a/frontend/src/topics/logs/lab.tsx +++ b/frontend/src/topics/logs/lab.tsx @@ -230,7 +230,7 @@ export function LogsLab({ onComplete }: { onComplete?: () => void }) {
SAFE REQUEST FIXTURE{scenario ? "loaded" : "waiting"}
{scenario ? safeRequestSummary(scenario) : "先選 scenario"}
-

authorization 與 email 只作為 redaction fixture,不會渲染 raw value。

+

authorization、password、accessToken、cookie 與 email 只作為 redaction fixture,不會渲染 raw value。

EXPECTED TERMINAL{scenario ? scenario.expected.level : "—"}
diff --git a/frontend/src/topics/logs/simulator.test.ts b/frontend/src/topics/logs/simulator.test.ts index e30b055..6b88794 100644 --- a/frontend/src/topics/logs/simulator.test.ts +++ b/frontend/src/topics/logs/simulator.test.ts @@ -8,7 +8,7 @@ import { runLogsEvents, type LogsLabEvent, } from "./simulator"; -import { findLogsScenario } from "./content"; +import { findLogsScenario, logsSensitiveFieldNames } from "./content"; const selectSuccess: LogsLabEvent = { type: "select-scenario", scenarioId: "request-success" }; const selectValidation: LogsLabEvent = { type: "select-scenario", scenarioId: "validation-rejected" }; @@ -112,6 +112,24 @@ describe("Logs deterministic simulator", () => { expect(terminal.state.completedScenarioIds).toEqual(["request-success"]); }); + it("rejects every declared sensitive raw value", () => { + const inspected = runLogsEvents([selectSuccess, ...inspectAll()]).state; + const correlationPassed = runLogsEvent(inspected, verifyCorrelation).state; + const scenario = findLogsScenario("request-success"); + + for (const field of logsSensitiveFieldNames) { + const leaked = runLogsEvent(correlationPassed, { + type: "verify-redaction", + serializedOutput: `${field}=${scenario.request[field]}`, + }); + + expect(leaked.accepted).toBe(false); + expect(leaked.state.redactionCheck).toBe("failed"); + expect(leaked.state.lastMessage).toContain(field); + expect(leaked.state.lastMessage).not.toContain(scenario.request[field]); + } + }); + it("preserves the expected severity and terminal outcome for rejection and timeout", () => { const validation = runLogsEvents(completeScenario(selectValidation)); expect(validation.accepted).toBe(true); diff --git a/frontend/src/topics/logs/simulator.ts b/frontend/src/topics/logs/simulator.ts index eccf482..bdb3ee1 100644 --- a/frontend/src/topics/logs/simulator.ts +++ b/frontend/src/topics/logs/simulator.ts @@ -133,9 +133,14 @@ function selectedScenarioOrBlock(current: LogsLabState): } function sensitiveValueFor(scenario: NonNullable>, field: LogsSensitiveField): string | undefined { - if (field === "authorization") return scenario.request.authorization; - if (field === "email") return scenario.request.email; - return undefined; + const sensitiveValues: Readonly> = { + authorization: scenario.request.authorization, + password: scenario.request.password, + accessToken: scenario.request.accessToken, + cookie: scenario.request.cookie, + email: scenario.request.email, + }; + return sensitiveValues[field]; } function rawSensitiveFieldIn( @@ -389,7 +394,7 @@ function verifyRedaction(current: LogsLabState, serializedOutput?: string): Logs return accepted( current, { redactionCheck: "passed" }, - "redaction check 通過:authorization 與 email 沒有出現在 serialized event output;下一步驗證 terminal outcome。", + `redaction check 通過:${logsFixtureRedactedFields.join("、")} 沒有出現在 serialized event output;下一步驗證 terminal outcome。`, ); } diff --git a/frontend/src/topics/package/lab.tsx b/frontend/src/topics/package/lab.tsx index d04b54b..416470c 100644 --- a/frontend/src/topics/package/lab.tsx +++ b/frontend/src/topics/package/lab.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useMemo, useState } from "react"; +import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; import { packageLabHappyPath, packageLessonSteps, @@ -8,6 +8,7 @@ import { } from "./content"; import { createInitialPackageState, isPackageLabComplete, runPackageEvent } from "./simulator"; import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; +import { tabIndexForKey } from "../../components/tab-navigation"; interface PackageHistoryEntry { command?: string; @@ -59,6 +60,18 @@ function packageFileLines(state: PackageLabState, file: PackageFile): readonly s : ["node_modules/", " // 尚未安裝任何依賴"]; } +function packageFileSlug(file: PackageFile): string { + return file.replace(/[^a-z0-9]+/gi, "-"); +} + +function packageTabId(file: PackageFile): string { + return `package-file-tab-${packageFileSlug(file)}`; +} + +function packagePanelId(file: PackageFile): string { + return `package-file-panel-${packageFileSlug(file)}`; +} + export function packageLabProgress(state: PackageLabState): number { return Math.round((state.completedStepIds.length / packageLessonSteps.length) * 100); } @@ -130,27 +143,49 @@ export function PackageLab({ onComplete }: { onComplete?: () => void }) { workshop-package-lab {state.phase}
-
- {PACKAGE_FILES.map((file) => ( +
+ {PACKAGE_FILES.map((file, index) => ( ))}
-
- {packageFileLines(state, selectedFile).map((line, index) => ( -
- {String(index + 1).padStart(2, "0")}{line || " "} -
- ))} -
+ {PACKAGE_FILES.map((file) => ( + + ))}
{history.map((entry, index) => (
diff --git a/frontend/src/topics/postgresql/content.test.ts b/frontend/src/topics/postgresql/content.test.ts index 4747f1b..f3586b0 100644 --- a/frontend/src/topics/postgresql/content.test.ts +++ b/frontend/src/topics/postgresql/content.test.ts @@ -27,6 +27,7 @@ describe("PostgreSQL lesson content", () => { "define-contract", "insert-returning", "read-jsonb", + "create-jsonb-index", "explain-query", "commit-transaction", ]); @@ -37,8 +38,9 @@ describe("PostgreSQL lesson content", () => { expect(postgresqlPlans["after-index"].operation).toBe("bitmap-index-scan"); expect(postgresqlLessonSteps[1].code).toContain("occurred_at timestamptz NOT NULL"); expect(postgresqlLessonSteps[2].code).toContain("RETURNING id, occurred_at, payload"); - expect(postgresqlLessonSteps[5].code).toContain("UPDATE events"); + expect(postgresqlLessonSteps[4].code).toContain("USING GIN"); + expect(postgresqlLessonSteps[6].code).toContain("UPDATE events"); expect(postgresqlResults["insert-returning"].rows[0][0]).toBe(104); - expect(postgresqlLabHappyPath).toHaveLength(6); + expect(postgresqlLabHappyPath).toHaveLength(7); }); }); diff --git a/frontend/src/topics/postgresql/content.ts b/frontend/src/topics/postgresql/content.ts index c34ee99..41af495 100644 --- a/frontend/src/topics/postgresql/content.ts +++ b/frontend/src/topics/postgresql/content.ts @@ -5,6 +5,7 @@ export type PostgreSqlStepId = | "define-contract" | "insert-returning" | "read-jsonb" + | "create-jsonb-index" | "explain-query" | "commit-transaction"; @@ -61,6 +62,7 @@ export interface PostgreSqlLabState { returnedId: number | null; jsonbMatchCount: number; plan: PostgreSqlQueryPlan | null; + indexCreated: boolean; transactionStatus: PostgreSqlTransactionStatus; result: PostgreSqlLabResult | null; lastCode: string | null; @@ -80,7 +82,7 @@ export const postgresqlLesson: LessonDefinition = { "用 psql 指令確認目前 database、user 與可用資料表。", "選擇 timestamptz、jsonb、identity 與 constraint 表達資料契約。", "用 INSERT ... RETURNING 取得資料庫剛建立的 row。", - "讀懂 JSONB 條件與 PostgreSQL EXPLAIN,再用 transaction 提交完整寫入。", + "用 JSONB 條件、GIN index 與 PostgreSQL EXPLAIN 驗證查詢,再用 transaction 提交完整寫入。", ], sections: [ { @@ -100,8 +102,8 @@ export const postgresqlLesson: LessonDefinition = { }, { id: "explain-and-commit", - title: "用 EXPLAIN 和交易驗證行為", - body: "EXPLAIN 顯示 PostgreSQL 選擇的 plan,transaction 則把多個寫入包成可提交的單位;兩者都要以可觀察結果驗證。", + title: "用 index、EXPLAIN 和交易驗證行為", + body: "先建立與分析 JSONB GIN index,再用 EXPLAIN 觀察 plan;transaction 則把多個寫入包成可提交的單位。", }, ], }; @@ -135,12 +137,19 @@ export const postgresqlLessonSteps: readonly PostgreSqlLessonStep[] = [ explanation: "->> 取出文字值,@> 判斷 JSONB 是否包含指定結構;查詢的是資料形狀,不是原始字串。", takeaway: "JSONB 仍然需要明確的查詢語意與可驗證的 fixture。", }, + { + id: "create-jsonb-index", + title: "建立 JSONB GIN index", + code: "CREATE INDEX idx_events_payload_gin\nON events USING GIN (payload);\n\nANALYZE events;", + explanation: "為 payload 建立 GIN index 並更新 planner statistics,讓後續 EXPLAIN 有一個真實存在的 index candidate;真實 planner 仍會依資料量與成本選擇 plan。", + takeaway: "Index 必須先存在且被分析,EXPLAIN 才有資格討論它是否被採用。", + }, { id: "explain-query", title: "讀懂 PostgreSQL EXPLAIN", code: "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM events WHERE payload @> '{\"kind\":\"signup\"}';", - explanation: "EXPLAIN 顯示 PostgreSQL 實際採用的 plan 與 buffer 線索,讓效能討論不只停在猜測。", - takeaway: "先看 plan 和 rows,再決定是否需要調整索引或查詢。", + explanation: "在 GIN index 已建立並完成 ANALYZE 後,EXPLAIN 顯示 plan 與 buffer 線索;本 Lab 用 deterministic fixture 示範 Bitmap Index Scan,真實 PostgreSQL 仍可能依 table size 選擇 Seq Scan。", + takeaway: "先看實際 plan 和 rows,再判斷 index 是否真的改善查詢。", }, { id: "commit-transaction", @@ -205,6 +214,12 @@ export const postgresqlResults: Readonly void }) {
DATABASE{state.session?.database ?? "—"}
+
JSONB INDEX{state.indexCreated ? "GIN READY" : "—"}
PLAN{planLabel(state.plan)}
TRANSACTION{state.transactionStatus}
diff --git a/frontend/src/topics/postgresql/simulator.test.ts b/frontend/src/topics/postgresql/simulator.test.ts index c51bf88..7e499c7 100644 --- a/frontend/src/topics/postgresql/simulator.test.ts +++ b/frontend/src/topics/postgresql/simulator.test.ts @@ -26,11 +26,12 @@ describe("PostgreSQL deterministic simulator", () => { expect(result.accepted).toBe(true); expect(result.state.phase).toBe("completed"); - expect(result.state.completedStepIds).toHaveLength(6); + expect(result.state.completedStepIds).toHaveLength(7); expect(result.state.session?.database).toBe("workshop"); expect(result.state.schemaReady).toBe(true); expect(result.state.returnedId).toBe(104); expect(result.state.jsonbMatchCount).toBe(2); + expect(result.state.indexCreated).toBe(true); expect(result.state.plan?.operation).toBe("bitmap-index-scan"); expect(result.state.transactionStatus).toBe("committed"); expect(result.state.result?.id).toBe("commit-transaction"); @@ -48,10 +49,20 @@ describe("PostgreSQL deterministic simulator", () => { expect(commit.state.lastMessage).toContain("EXPLAIN"); }); + it("requires the GIN index before reading the indexed query plan", () => { + const beforeIndex = runPostgreSqlEvents(postgresqlLabHappyPath.slice(0, 4)).state; + const explain = runPostgreSqlEvent(beforeIndex, { type: "explain-query" }); + + expect(explain.accepted).toBe(false); + expect(explain.state.indexCreated).toBe(false); + expect(explain.state.lastMessage).toContain("GIN index"); + }); + it("documents the main out-of-order failure fixtures", () => { expect(postgresqlFailureFixtures.map((fixture) => fixture.event)).toEqual([ "define-contract", "insert-returning", + "explain-query", "commit-transaction", ]); }); diff --git a/frontend/src/topics/postgresql/simulator.ts b/frontend/src/topics/postgresql/simulator.ts index 50f1abc..3d9e726 100644 --- a/frontend/src/topics/postgresql/simulator.ts +++ b/frontend/src/topics/postgresql/simulator.ts @@ -43,7 +43,8 @@ function requiredStep(stepId: PostgreSqlStepId): PostgreSqlStepId | null { if (stepId === "define-contract") return "inspect-session"; if (stepId === "insert-returning") return "define-contract"; if (stepId === "read-jsonb") return "insert-returning"; - if (stepId === "explain-query") return "read-jsonb"; + if (stepId === "create-jsonb-index") return "read-jsonb"; + if (stepId === "explain-query") return "create-jsonb-index"; if (stepId === "commit-transaction") return "explain-query"; return null; } @@ -81,6 +82,7 @@ function accepted( returnedId: stepId === "insert-returning" ? 104 : current.returnedId, jsonbMatchCount: stepId === "read-jsonb" ? 2 : current.jsonbMatchCount, plan, + indexCreated: current.indexCreated || stepId === "create-jsonb-index", transactionStatus: stepId === "commit-transaction" ? "committed" : current.transactionStatus, result, lastCode: codeFor(stepId), @@ -140,7 +142,8 @@ export function runPostgreSqlEvent( "define-contract": "請先確認 psql session,再建立資料契約。", "insert-returning": "請先建立 events schema,RETURNING 才有明確的資料邊界。", "read-jsonb": "請先用 INSERT ... RETURNING 建立並取得 event row。", - "explain-query": "請先完成 JSONB containment 查詢,再閱讀 PostgreSQL plan。", + "create-jsonb-index": "請先完成 JSONB containment 查詢,再建立 GIN index。", + "explain-query": "請先建立 JSONB GIN index,再閱讀 PostgreSQL plan。", "commit-transaction": "請先讀完 EXPLAIN plan,再提交完整 transaction。", }; return blocked(current, event.type, messages[event.type]); @@ -162,8 +165,12 @@ export function runPostgreSqlEvent( return accepted(current, event.type, "JSONB @> 找到 2 筆 signup events,->> 取出 kind 文字值。"); } + if (event.type === "create-jsonb-index") { + return accepted(current, event.type, "idx_events_payload_gin 已建立並完成 ANALYZE;可以檢查 planner plan。"); + } + if (event.type === "explain-query") { - return accepted(current, event.type, "EXPLAIN 顯示 Bitmap Index Scan,plan 與 rows 都可被觀察。"); + return accepted(current, event.type, "EXPLAIN fixture 顯示 Bitmap Index Scan;真實 planner 仍要依資料量與成本驗證。"); } return accepted(current, event.type, "events 的新增與更新都完成,COMMIT 讓 transaction 正式可見。", "completed"); diff --git a/frontend/src/topics/rest/lab.test.ts b/frontend/src/topics/rest/lab.test.ts index 9c22ea5..b6fa66d 100644 --- a/frontend/src/topics/rest/lab.test.ts +++ b/frontend/src/topics/rest/lab.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { restScenarios, restTraceStages } from "./content"; -import { restLabProgress } from "./lab"; +import { lineForFileSelection, restLabProgress } from "./lab"; import { createInitialRestState, runRestEvents } from "./simulator"; describe("REST Lab progress", () => { + it("falls back to the first line when a browsed file has no active-stage mapping", () => { + expect(lineForFileSelection("database.py", "browser")).toBe("db-1"); + }); + it("starts at zero and reaches 100 only with all scenarios and stages", () => { const completed = { ...createInitialRestState(), diff --git a/frontend/src/topics/rest/lab.tsx b/frontend/src/topics/rest/lab.tsx index 782b054..6ef216e 100644 --- a/frontend/src/topics/rest/lab.tsx +++ b/frontend/src/topics/rest/lab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { type KeyboardEvent, useState } from "react"; import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; import { findRestCodeFile, @@ -14,7 +14,8 @@ import { type RestScenarioId, type RestTraceStageId, } from "./content"; -import { createInitialRestState, isRestLabComplete, runRestEvent } from "./simulator"; +import { createInitialRestState, isRestLabComplete, isRestStageUnlocked, runRestEvent } from "./simulator"; +import { tabIndexForKey } from "../../components/tab-navigation"; type RestCodeMode = "annotated" | "source"; @@ -32,13 +33,42 @@ function statusTone(state: RestLabState): TopicStatusTone { return "neutral"; } -function relatedLine(fileId: RestCodeFileId, stageId: RestTraceStageId): string { +function relatedLine(fileId: RestCodeFileId, stageId: RestTraceStageId): string | undefined { const file = findRestCodeFile(fileId); const line = file.lines.find((candidate) => candidate.stages.includes(stageId)); - if (!line) { + return line?.id; +} + +function requiredRelatedLine(fileId: RestCodeFileId, stageId: RestTraceStageId): string { + const lineId = relatedLine(fileId, stageId); + if (!lineId) { throw new Error(`No REST code line maps to ${fileId} at stage ${stageId}.`); } - return line.id; + return lineId; +} + +function firstLineId(fileId: RestCodeFileId): string { + const firstLine = findRestCodeFile(fileId).lines[0]; + if (!firstLine) { + throw new Error(`REST code file ${fileId} has no code lines.`); + } + return firstLine.id; +} + +function restFileSlug(fileId: RestCodeFileId): string { + return fileId.replace(/[^a-z0-9]+/gi, "-"); +} + +function restTabId(fileId: RestCodeFileId): string { + return `rest-file-tab-${restFileSlug(fileId)}`; +} + +function restPanelId(fileId: RestCodeFileId): string { + return `rest-file-panel-${restFileSlug(fileId)}`; +} + +export function lineForFileSelection(fileId: RestCodeFileId, stageId: RestTraceStageId): string { + return relatedLine(fileId, stageId) ?? firstLineId(fileId); } export function restLabProgress(state: RestLabState): number { @@ -51,6 +81,7 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { const [state, setState] = useState(createInitialRestState); const [selectedFileId, setSelectedFileId] = useState("api.ts"); const [selectedLineId, setSelectedLineId] = useState("api-6"); + const [fileSelectionNotice, setFileSelectionNotice] = useState(null); const [codeMode, setCodeMode] = useState("annotated"); const scenario = findRestScenario(state.selectedScenarioId); const selectedFile = findRestCodeFile(selectedFileId); @@ -58,11 +89,6 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { const terminalIndex = stageIndex(scenario.terminalStageId); const currentIndex = stageIndex(state.activeStageId); const completed = isRestLabComplete(state); - const activeLineIds = useMemo( - () => selectedFile.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id), - [selectedFile, state.activeStageId], - ); - function dispatch(event: RestLabEvent) { const result = runRestEvent(state, event); if (!isRestLabComplete(state) && isRestLabComplete(result.state)) onComplete?.(); @@ -73,7 +99,8 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { throw new Error(`Unknown REST trace stage: ${result.state.activeStageId}`); } setSelectedFileId(nextStage.fileId); - setSelectedLineId(relatedLine(nextStage.fileId, nextStage.id)); + setSelectedLineId(requiredRelatedLine(nextStage.fileId, nextStage.id)); + setFileSelectionNotice(null); } } @@ -81,6 +108,7 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { setState(createInitialRestState()); setSelectedFileId("api.ts"); setSelectedLineId("api-6"); + setFileSelectionNotice(null); setCodeMode("annotated"); } @@ -88,11 +116,14 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { dispatch({ type: "select-scenario", scenarioId }); setSelectedFileId("api.ts"); setSelectedLineId(scenarioId === "create-success" || scenarioId === "validation-error" ? "api-6" : "api-15"); + setFileSelectionNotice(null); } function changeFile(fileId: RestCodeFileId) { + const mappedLineId = relatedLine(fileId, state.activeStageId); setSelectedFileId(fileId); - setSelectedLineId(relatedLine(fileId, state.activeStageId)); + setSelectedLineId(lineForFileSelection(fileId, state.activeStageId)); + setFileSelectionNotice(mappedLineId ? null : `目前 ${state.activeStageId} stage 沒有 ${fileId} 的對應執行行,先顯示檔案第一行。`); } return ( @@ -140,12 +171,13 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) { {restTraceStages.map((stage, index) => { const isBlocked = index > terminalIndex; const isVisited = state.currentVisitedStageIds.includes(stage.id); + const isLocked = state.requestStarted && !isRestStageUnlocked(state, stage.id); return ( +
+ {restCodeFiles.map((file, index) => ( + ))}
@@ -168,23 +220,41 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
{selectedFile.path}{selectedFile.language} · {selectedFile.role}
-
- {selectedFile.lines.map((line, index) => { - const isRelated = activeLineIds.includes(line.id); - return ( - - ); - })} -
+ {fileSelectionNotice ?

{fileSelectionNotice}

: null} + {restCodeFiles.map((file) => { + const isSelected = selectedFileId === file.id; + const activeLineIds = file.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id); + return ( + + ); + })}