iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 10

Day 10 — 雙層驗證:結構驗證器 + 瀏覽器 smoke test

  • 分享至 

  • xImage
  •  

今天要解的問題

網站功能齊了,但有一個問題我逃避了十天:我沒有任何自動化驗證。

規模已經到了「不可能手動全檢」的程度——十幾門課、幾十章、兩百多課。而 Day 7 的兩個 bug 已經證明了最可怕的失敗模式:靜默失敗。不報錯、不當掉、console 乾淨,公式就是不見了。

今天做兩層驗證,並且說清楚為什麼一層不夠

第一層:零依賴結構驗證器

node scripts/verify.js

沒有 Jest、沒有 Mocha、沒有 npm install。整個專案的 node_modules 是空的,我不打算為了驗證引入依賴。

核心技巧還是 Day 3 那招——用 node:vm 把瀏覽器 script 當資料讀進來:

const fs = require("fs"), path = require("path"), vm = require("vm");

const root = path.resolve(__dirname, "..");
const lessonDir = path.join(root, "js", "lessons");
const lessonFiles = fs.readdirSync(lessonDir).filter(n => n.endsWith(".js")).sort();

const errors = [];
function assert(condition, message) { if (!condition) errors.push(message); }

/* Step 1:語法檢查(只解析、不執行,抓得到瀏覽器程式的語法錯) */
for (const file of jsFiles) {
  try { new vm.Script(fs.readFileSync(path.join(root, file), "utf8"), { filename: file }); }
  catch (e) { errors.push(`JavaScript syntax: ${file}: ${e.message}`); }
}

/* Step 2:按瀏覽器的載入順序執行 Model 與課文 */
const ctx = { window: {}, console };
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(root, "js/models/curriculum.js"), "utf8"), ctx);
for (const file of lessonFiles)
  vm.runInContext(fs.readFileSync(path.join(lessonDir, file), "utf8"), ctx);

const courses = vm.runInContext("COURSES", ctx);
const lessons = ctx.window.LESSONS;

Step 1 用 new vm.Script() 只解析不執行——這樣連 View 和 Controller(會碰 document)的語法錯也抓得到,而不會因為 Node 沒有 DOM 就爆掉。這是免費的「全專案語法檢查」,取代了 lint 的一部分價值。

Step 2 特別重要:它用與瀏覽器相同的順序執行,所以載入順序寫錯(Day 5 那個雷)會在這裡直接 throw。驗證器順便驗了載入契約。

斷言清單

assert(courses.length === 18, `Expected 18 courses, got ${courses.length}`);
assert(duplicates(courseIds).length === 0, `Duplicate course ids: …`);
assert(courses.every(c => c.modules.length > 0), "Every course must contain at least one module");

/* 章/課 id 唯一 */
assert(duplicates(chapterIds).length === 0, `Duplicate chapter ids: …`);

/* 地圖 ↔ 課程雙向對應 */
assert(courseIds.every(id => nodeIds.includes(id)), `Courses without map node: …`);
assert(nodeIds.every(id => courseIds.includes(id)), `Map nodes without course: …`);
for (const [from, to] of map.edges)
  assert(nodeIds.includes(from) && nodeIds.includes(to), `Bad map edge: ${from} -> ${to}`);

/* 課綱 ↔ 課文雙向對應(這條最常救我) */
assert(expectedKeys.every(k => lessons[k]), `Missing lesson content: …`);
assert(actual.every(k => expected.has(k)), `Extra lesson content: …`);

/* 每課恰一題測驗、答案索引有效(Day 8) */
for (const key of expectedKeys) {
  const html = lessons[key] || "";
  assert(countClass(html, "quiz") === 1, `${key}: expected exactly one quiz`);
  const m = html.match(/class=["']quiz["'][^>]*data-answer=["'](\d+)["']/);
  assert(Boolean(m), `${key}: quiz is missing numeric data-answer`);
  if (m) assert(Number(m[1]) < countClass(html, "quiz-opt"),
                `${key}: data-answer ${m[1]} outside options`);
}

「雙向」是關鍵字。 只檢查「課綱的每一課都有課文」不夠——反過來「課文檔裡有課綱沒列的孤兒 key」也是 bug(通常是改課綱時漏改課文,那一課永遠不會被看到)。雙向檢查抓得到兩邊。

還有一個累積式錯誤蒐集的細節:assert 是把訊息 push 進陣列,不是立刻 throw。一次跑完看到所有問題,而不是修一個跑一次。改課綱時常常一次壞五處,這個設計省下大量往返。

輸出:

Courses: 18 | Chapters: 83 | Lessons: 256
Lesson files: adb.js, aws.js, cda.js, ch01-04.js, …
Verification passed: syntax, IDs, mapping, map, and quizzes are valid.

有錯就 process.exit(1)——這樣才能接 CI(Day 22)。

為什麼第一層不夠

verify.js 對這兩個東西完全無感

<!-- 課文裡的裸 <:公式靜靜不渲染 -->
<p>條件是 \(m<p\),模型無法識別。</p>

<!-- runtime 雙反斜線:畫面印出裸 LaTeX -->
<div class="formula">\\[ \\frac{a}{b} \\]</div>

兩者的字串都合法、結構都完美、測驗都在。結構驗證只能保證資料形狀對,不能保證瀏覽器渲染對。

所以要第二層。

第二層:瀏覽器 smoke test

python3 -m http.server 8901 &
python3 scripts/smoke-test.py
#!/usr/bin/env python3
"""Browser smoke test: MathJax render, quiz interaction, nav, console errors."""
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

BASE = "http://localhost:8901"
opts = Options()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
opts.add_argument("--window-size=1400,1000")
opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})   # 為了抓 console

d = webdriver.Chrome(options=opts)
fails = []

它檢查什麼

1. 首頁:節點數正確、沒有未完成標記、不得出現禁字

body_txt = d.find_element(By.TAG_NAME, "body").text
for banned in [".reference", "備課中", "筆記"]:
    if banned in body_txt:
        fails.append(f"index: banned text present: {banned!r}")

這是 Day 8 那段黑歷史的自動化防線。當初樣板產生器把參考資料的檔名印到課文裡,我手動清乾淨之後,用這個斷言確保永遠不會再出現。有些規則是內容政策而不是技術正確性,一樣可以自動化。

2. 全部課程頁:都要有章節卡、都不能是空狀態。

3. 課文取樣(每門課至少一課,挑數學最密的):

def wait_mathjax(timeout=25):
    end = time.time() + timeout
    while time.time() < end:
        r = d.execute_script("""
          const b = document.getElementById('lesson-body');
          if (!b) return {done:false};
          const raw = b.textContent || '';
          const hasDelim = raw.includes('\\\\(') || raw.includes('\\\\[');
          return {
            done: !!(window.MathJax && window.MathJax.startup && window.MathJax.startup.document),
            mjx: b.querySelectorAll('mjx-container').length,
            leftover: hasDelim
          };
        """)
        if r.get("done"): return r
        time.sleep(0.4)
    return r

三個判斷合起來就是 Day 7 兩個 bug 的補獵網:

  • mjx > 0 → 有公式渲染出來(抓 bug 1:全都沒渲染)。
  • leftover == False → 課文的可見文字裡沒有殘留 \(\[(抓 bug 1 的另一半:印出裸 LaTeX)。
  • 兩者同時檢查,才能抓到 bug 2(部分公式消失:mjx 有值但數量少了)。

4. 真的點一個錯的選項

opts_el = d.find_elements(By.CSS_SELECTOR, ".quiz .quiz-opt")
answer = int(d.find_element(By.CSS_SELECTOR, ".quiz").get_attribute("data-answer"))
wrong = 0 if answer != 0 else 1
opts_el[wrong].click()
if "wrong" not in opts_el[wrong].get_attribute("class"): fails.append("…")
if "correct" not in opts_el[answer].get_attribute("class"): fails.append("…")
if not d.find_element(By.CSS_SELECTOR, ".quiz-exp").is_displayed(): fails.append("…")

刻意點錯的。只測「答對」路徑會漏掉一半邏輯——答錯時要同時標紅你選的、標綠正解、顯示詳解,三件事任一沒發生都是 bug。

5. 進度真的寫進去:點「完成本課」→ 讀 localStorage → 確認 URL 前進到 l=2

6. 每一頁的 console SEVERE

def severe(prefix):
    out = [e["message"] for e in d.get_log("browser")
           if e["level"] == "SEVERE" and "favicon" in e["message"] is False]
    if out: fails.append(f"{prefix}: console SEVERE: {out[:3]}")

任何一頁有 SEVERE 就算失敗(favicon 404 除外)。這條抓到了下面的 CSP bug。

7. file:// 直開:Day 1 的硬需求要有回歸測試,不然遲早會不小心破壞它。

成功基準:=== RESULT: 0 failures ===

順手收乾淨的三筆債

(1)嚴格 CSP,與一個難受的取捨

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self' https://cdn.jsdelivr.net;
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net;
  img-src 'self' data:; object-src 'none'; base-uri 'self'">

script-src 沒有 unsafe-inline,所以所有行內 script 都必須外部化。這就是 Day 4 的 controller 自我啟動、Day 7 的 mathjax-config.js 早早分檔的原因——那時候先付的成本,今天免費收割。

取捨(重要)script-src 'self'file:// 下,Chromium 會擋掉本地 js(file: origin 不符合 'self')。也就是說嚴格 CSP 與「雙擊 index.html」二者不可兼得

我的選擇:保留嚴格 CSP,開發改用 python3 -m http.server 8901。理由是這網站終究要上雲(Day 21+),CSP 的價值在線上;而 file:// 直開只是開發便利。smoke test 仍然保留 file:// 的檢查(節點數要正確),確保 HTML/CSS 結構本身沒有依賴伺服器。

另一個雷:我一開始在 meta CSP 裡放了 frame-ancestors 'none'。結果每一頁 console 都噴 SEVERE:

The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.

frame-ancestorsheader-only 指令,放在 meta 裡無效。而且它是 smoke test 的「console 無 SEVERE」抓出來的——如果沒有那條斷言,我可能永遠不會注意到。第 25 天上雲之後,我會用真正的 HTTP header 把它加回來。

(2)修掉假的分鐘數

Day 3 埋的雷:兩百多課的 min 全是預設值 10

解法是從已經手調過的課反推速率:

/* scripts/estimate-minutes.js */
const RATE = 61;      // 字/分鐘——由初級統計 49 課的手調值反推
const MIN = 8, MAX = 30;

const estimate = html => {
  const chars = (html || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length;
  return Math.min(MAX, Math.max(MIN, Math.round(chars / RATE)));
};

61 字/分鐘聽起來很慢——中文閱讀速度通常是三百字以上。但這是學習時間,包含盯著公式想、算例題、答測驗。用已經人工校準過的 49 課反推,比我憑感覺定一個數字可信。

腳本設計三個要求:可重跑(idempotent,跑兩次結果相同)、支援 --dry(先看會改什麼再真的改)、就地更新課綱。全站 min 從一律「10 分」變成 11–30 分的合理分布。

(3)git 與 .gitignore

git add -A
git status --short | grep reference    # 必須沒有輸出
git commit -m "feat: dual-layer verification, strict CSP, real minute estimates"
git tag day10

踩到的雷

smoke test 的等待策略。 一開始我用 time.sleep(3) 等 MathJax,結果 CI 上時快時慢,測試隨機失敗——比沒有測試更糟,因為你會開始習慣性忽略紅燈。

正解是輪詢真實條件wait_mathjax 那段):問瀏覽器「MathJax 啟動完成了嗎」,而不是猜它需要幾秒。任何固定 sleep 都是未來的 flaky test。

--no-sandbox--disable-dev-shm-usage 在容器裡是必需的,否則 Chrome 直接啟動失敗。(Day 22 上 CI 時還會遇到中文字型缺失,那時再處理。)

驗證

node scripts/verify.js                 # Verification passed
python3 -m http.server 8901 &
python3 scripts/smoke-test.py          # === RESULT: 0 failures ===

驗證器也要跟資料模型一起長大

第一版地圖只有有方向的先修 edge。後來我加入六條跨領域的雙向關聯:Python、機器學習、高等資料庫各自連到 AWS 與 GCP。這種線不是先修,不能硬塞進原本的 DAG,所以資料層新增 COURSE_MAP.related,畫面用低調虛線呈現。

資料模型多一種關係,驗證器也必須同一天跟上:

const relatedKeys = (map.related || [])
  .map(([a, b]) => [a, b].sort().join("::"));

assert(duplicates(relatedKeys).length === 0, "duplicate related edge");
for (const [a, b] of map.related || []) {
  assert(nodeIds.includes(a) && nodeIds.includes(b), `bad relation: ${a}<->${b}`);
  assert(a !== b, `self relation: ${a}`);
}

瀏覽器層再確認:SVG 真的有 6 條虛線、圖例有「跨領域關聯」、AWS/GCP 課卡各自列出 Python/ML/ADB。結構驗證只能證明資料合法,不能證明 View 有畫出來。

同一輪我也刪掉 smoke test 裡寫死的章課數。課程擴章後,全站從 83 章/256 課長到 111 章/340 課;如果測試仍寫死舊數字,正確改內容反而會讓測試失敗。現在預期值由 runtime curriculum 算出,hero、課卡、徽章門檻與測試共用同一個事實來源。

第一階段成果

十天的引擎完成後,內容可以持續長大而不必重寫頁面。

項目 數字
HTML 3 頁
JS Model 2、View 8、Controller 3、課文 20 檔
CSS 896 行、單一 :root 色票
內容 18 門 / 111 章 / 340 課 / 約 117 小時,每課恰一題測驗
課程尺寸 stat 14章;reg/cda/ts/mult 各8章;AWS/GCP 各10章;不再固定12課
關聯 先修 edge + 6 條跨領域 related edge
驗證 結構驗證器 + 瀏覽器 smoke test,兩層都 0 失敗
依賴 執行期:MathJax + Noto Sans TC(兩個 CDN)。建置期:

最有價值的產出不是那 340 課,是能隨內容規模一起成長的驗證契約。它讓我之後每一次擴章都有底氣。

小結與明天預告

第一階段的核心心得,如果只能留一句:

會靜默失敗的東西,一定要有自動檢查。 結構驗證保證形狀,瀏覽器測試保證渲染,兩層都要。

第二階段(Day 11–20)開始做功能深化:深色模式、全站搜尋、統計數值表、章末總測驗、離線化、學習儀表板。明天先做深色模式——你會看到 Day 2 那個「顏色集中在 :root」的決定,怎麼讓一個通常很痛的功能變成改一個區塊就好。


上一篇
Day 9 — 讓人想回來:遊戲化與互動元件
下一篇
Day 11 — 深色模式:設計 token 的回報
系列文
知識圖譜 : 技能樹式學習歷程11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言