| name | testing-seatmark |
| description | How to end-to-end test SeatMark in production (www.seatmark.cn) via CDP — response-header/security checks, Excel import, PNG/ZIP export, print capture, analytics timeline, Lighthouse, and the PWA service-worker caching pitfall. |
Deployment & 545 classification (r296+)
Every normal API response now carries X-SeatMark-Rev — verify the expected revision via curl -sI https://www.seatmark.cn/api/auth/me before measuring, instead of only bundle-hash grepping (edge-function-only changes don't change bundle hashes). Registration is 20/day/IP; if register returns 429, switch to the standing probe accounts seatmark295probe1..10@example.com / probepass{N}{N} for login coverage. Gateway 545 responses carry neither rev nor storage headers — use header absence to classify gateway-layer failures. Root-cause status (post #302–#305): 545 correlates with Blob writes on the response critical path; auth writes now run as a serialized background chain via waitUntil (150ms delayed), which brought curl-probed login raw 545 from ~39% to ~7.5% and register 8/10→0/10, with frontend retry (≤5) absorbing the rest.
545 reproducibility (r297)
Production gateway 545 on auth POSTs is strongly time-varying — 3/3 within one curl batch, then 0/3 via UI half an hour later. Do not design a test that requires a natural 545 to pass; report the raw rate observed and mark the fallback path untested if no 545 occurs (CDP Fetch.fulfillRequest with responseCode 545 can force the UI path if needed). Registration quota accounting: track per-day usage across curl probes and UI runs (20/day/IP, UTC-keyed). Login fail limit is approximate: deferred background writes can drop an increment (observed 12 tries before 429 with limit 10); lockout also blocks the correct password until the 15-min window expires.
Auth retry testing (r294, post-#301)
The frontend silently retries login on gateway 5xx (≤5 attempts, 600ms·n backoff) and register falls back to login on 5xx — so a raw 545 no longer implies a user-visible failure. To measure true user impact, drive the UI and count form-level errors, while an async CDP listener counts raw 545s; server-side login counters include 545'd attempts. Root-cause isolation (r295 probes): 545 only hits requests that do Blob WRITES (write-only POST ≈18%, wrong-pass login write+PBKDF2 ≈33%); read-only GET/POST paths were 0/90. Deployment check: fetch the entry /assets/index-.js, follow its referenced index-.js chunk (entry is a small loader), and grep for a code marker (e.g. the backoff literal).
Password auth testing (r293)
/account is now email+password (register/login toggle via 免费注册/去登录; short passwords are blocked client-side by input minlength=8 — assert zero network request). Registration needs no email delivery, so any syntactically valid address works. Gotcha: production auth POSTs (register/login) intermittently return gateway 545 but the server-side handler HAS completed (account created / login counted) — a 545'd register followed by retry returns 409 已注册, so switch to login mode with the same password. To passively capture request payloads/responses while driving the visible CDP browser, attach a playwright async listener over connect_over_cdp (sync API + time.sleep does not dispatch events).
html2canvas-pro 2.3.2 baseline (r192/#200) — OBSOLETE as of #202/r194
html2canvas-pro was reverted 2.3.2→2.0.4 — the 2.3.2 baselines (demo ef6b69ad…, 009 cec2aac0…) are void; the canonical baselines are again demo whole-page watermarked 3e8fdf3e… (r170) and per-label 009 eb4eb7bd… (r188), name-alignment ratio ≈0.635. If a future engine upgrade is attempted, rerun the r192 4x-ratio method — upstream #222 (fontBoundingBoxAscent) draws large text ~12px@300dpi higher than the DOM preview and will break WYSIWYG again unless compensated. To judge preview-vs-export alignment objectively: capture the first label with Page.captureScreenshot clip {x:rect.x+scrollX, y:rect.y+scrollY, scale:4} (rect from getBoundingClientRect; forgetting scrollY clips the toolbar instead), then compare the ratio (学号-row center − name center)/name ink height across preview and both exports. PNG dialog mode selector is the input-field button containing 按标签逐张/按整页 — NOT the first input-field button (that's the 姓名 field).
Guides audit (r196)
The 76 guides live in app/src/data/guides.ts + guidesRound2–6.ts (dump with npx tsx importing guides + defaultTemplates for the 222 template ids). Guide pages are prerendered static HTML — curl suffices for title/description/canonical/JSON-LD, but the JSON-LD script tags carry a data-route-jsonld attribute (regex must allow attributes). All guides have quickStart, but 8 point to /seating or /papers, not /studio. CTA landing can be asserted via pinia: document.querySelector('#app').__vue_app__.config.globalProperties.$pinia.state.value.workspace (template.id / excel.rows / isDemoData). Ground truths: 222 templates (/templates page), 17 paper specs (labelPapers.ts), anon quota QUOTA_ANON_DAILY=1 → badge 今日剩余 1 次.
Composite-field & WeChat overlay testing (r199)
Composite editor opens via the mapping row SelectField option 自定义组合… (sentinel __composite__); the input is input[placeholder*="考场"] — set value via the native setter + dispatch input event or Vue won't see it. Objective export check: export a control page then the composite page in the same tab and numpy-diff — changes must be confined to narrow per-label bands of the remapped field. OCR evidence: apt install tesseract-ocr tesseract-ocr-chi-sim + pip pytesseract works on exported PNGs at full resolution (thumbnails/preview screenshots OCR poorly). WeChat overlay: Emulation.setUserAgentOverride with MicroMessenger UA before nav; dialog is [role=dialog][aria-label=微信内浏览提示], session key seatmark.wechat-guide-seen.v1. Watch item (r200 stress test): the blank whole-page export reproduced 2/70 (~2.9%) — page 1 renders with ink confined to the left edge (x<1063; page1 md5 deterministically 52e62a47bb…, non-white px 163768 vs normal 661794), correlating with preview-container scrolling right before export but not deterministic. Detection in tests: always check per-page non-white pixel count (<300k on the demo deskName page = bad), not just md5. Product-side mitigation landed in r201: isCanvasTruncated (right 40% blank while left has ink) triggers the existing retry path; the final lenient render only applies the blank check so sparse legit pages still pass.
Truncated-export interception (#208/r204)
The final render now throws 「页面渲染不完整(右侧内容未绘出),请重新导出」 when a truncated canvas has DOM right-side content (domExpectsRightInk: non-empty .label-box or .sheet-watermark extending past 70% width). Legit sparse tail pages never enter this branch because the watermark leaves right-side ink (right-40% non-white ≈116k on the 25-row fixture p2). Known-good 25-row fixture md5s: WM 3625e3196e…/8e8750ee5a…, clean 9d50e34475…/31a4695840…; steady-state export ≈1.8–2.0s, +~1s indicates the retry chain fired. The interception path still cannot be force-driven in production (devForcedExportFailure is DEV-only).
Anonymous quota tamper clamp (#211/r207)
The negative-used clamp is live — loadLocalUsage now applies Number.isFinite + Math.max(0, used), so tampered used=-5 in seatmark.clean-export-usage.v1 shows 今日剩余 1 次, not 6; null/"abc"/garbage/missing keys all fall back to 0 without pageerror. Remember QUOTA_ANON_DAILY=1 (anonymous) vs QUOTA_USER_DAILY=3 (logged-in) — task descriptions sometimes conflate them. Cancel-export never deducts (toast 本次未扣除无水印次数); a stale {date:昨日,used:99} record resets to today's full quota on load.
Keyboard-only testing (r208)
Drive real key events via CDP Input.dispatchKeyEvent (rawKeyDown + char '\r' for Enter + keyUp; Shift = modifiers:8) — element.click()/focus() shortcuts don't exercise ModalDialog's Tab-trap (ModalDialog.vue:36-85) or SelectField arrow nav (SelectField.vue:62-82). Prove focus visibility with focused-vs-blurred crop pixel diffs, not activeElement alone (skip-link is sr-only until :focus). Tab-trap check: 20×Tab asserting dialog.contains(document.activeElement); Esc must return focus to the opener button. Hero CTA text is 开始生成标签 (not 开始制作). When a download event seems missed, drain the Browser websocket and retry once before calling it a product failure.
Export-mode memory gotcha (r191)
The 图片 PNG dialog remembers the last export mode (整页 vs 逐张) across exports in the same tab — always read the mode field before clicking 带水印导出, or a per-label export silently yields a whole-page zip. Also: uncommitted test-report.md sections can be lost if the working tree is updated to a newer main between rounds — commit report sections before pulling.
Environment
- Chromium:
/opt/.devin/playwright_browsers/chromium-1097/chrome-linux/chrome, CDP on 9222.
- Helpers on box:
/home/ubuntu/cdp51.py (get_ws/send/js/nav/click), /home/ubuntu/r99.py (shot/viewport/CLEAN).
CLEAN clears localStorage/sessionStorage/caches and unregisters service workers — run it after a page load, then reload, for a true clean state.
- Viewports: desktop 1280×900, mobile 390×844 (
Emulation.setDeviceMetricsOverride).
Verifying a deploy
- EdgeOne auto-deploys
main; a code change is confirmed by a new /assets/index-*.js hash in the HTML.
- A change that only touches
edgeone.json (platform config) does NOT change the bundle hash. Confirm such deploys by polling response headers instead, e.g. curl -sSD - -o /dev/null https://www.seatmark.cn/ | grep -i strict-transport every 30 s (took ~1 min in practice).
Response-header / security checks
curl -sSD - -o /dev/null https://www.seatmark.cn/<path> for /, /studio, /templates, /assets/<js>, /api/quota.
- EdgeOne merges the
/* and /assets/* headers blocks (only same-key entries are overridden, e.g. Cache-Control). Do not expect a more specific source to replace the whole header set.
/api/* is served by edge functions (edge-functions/api/[[default]].js json() builds its own Response), so edgeone.json headers never apply there. Report as platform behaviour, not a bug.
- Cache buster (
?cb=$RANDOM) forces eo-cache-status: Cache Miss; plain requests can serve Cache Hit copies with pre-deploy headers for a few minutes.
PWA service-worker pitfall (very important for header/security testing)
The site registers a workbox service worker whose precache contains index.html (caches.keys() → workbox-precache-v2-https://www.seatmark.cn/). Any SPA navigation (including a cross-origin <iframe src=...>) in a profile that has visited the site before can be answered by the SW with the precached response captured before the deploy (Network.responseReceived shows fromServiceWorker: true, stale age), so new response headers (X-Frame-Options / CSP) are not enforced. CLEAN in one tab is not enough — the SW re-registers on the next site load.
→ For any header/security assertion, test in a brand-new browser profile:
rm -rf /tmp/prof && chrome --headless=new --no-sandbox --remote-debugging-port=9223 --user-data-dir=/tmp/prof about:blank
Then connect to 9223 and enable Network+Log to read the real headers and security errors (e.g. Refused to frame ... frame-ancestors 'self').
Also worth reporting: because precache entries are revision-pinned to file content, a headers-only deploy never invalidates the cached index.html.
SW takeover in old profiles: log state, never assume
Historically (before PR #126) injectRegister: false + a bare navigator.serviceWorker.register('/sw.js') in app/src/main.ts meant a new sw.js stayed waiting: true no matter how many reloads, and only closing all origin tabs / restarting with the same --user-data-dir handed over control. Since PR #126 added skipWaiting: true + clientsClaim: true to the workbox config, one top-level reload is enough (measured: new SW activating ~6s after a single reload, waiting never true, tab stayed open).
Critical: navigator.serviceWorker.controller being truthy does NOT tell you which SW is in control — the old one is a controller too. Use a cache fingerprint as the discriminator, e.g. precache entry count and whether index.html is present (#125: 51 entries / 0 html; #126: 52 entries / 1 html). Always log registration.active.state + !!registration.waiting + per-cache keys().length and html count, sampled over ~12s, before concluding anything.
Also beware: the deployed precache manifest count can differ from what a PR description claims (PR #126 said 55→56, production showed 51→52) — report the production numbers. Note: closing the last tab via Target.closeTarget kills the whole browser — that is fine (relaunch with the same user-data-dir), but re-launch on a fresh debugging port.
Offline testing: page-level offline does NOT apply to the service worker
Network.emulateNetworkConditions {offline:true} only affects the page target; the SW's own fetch still hits the real network (symptom of a false pass: an un-cached route still loads fresh content offline, and the runtime cache gains new entries). headless chromium 121 does not even expose a service_worker target via Target.getTargets, so you cannot scope offline to it.
→ Cut the network at the browser level instead: relaunch Chrome with the same --user-data-dir plus --host-resolver-rules="MAP www.seatmark.cn 127.0.0.1" (nothing listens on local :443 → connections refused for page and SW). Always add a control proving the disconnect is real (see next paragraph for which control is valid under the current SW config).
Current expected offline behaviour (after PR #126): pages NetworkFirst cache serves previously visited routes offline, and never-visited SPA routes also work via precacheFallback: {fallbackURL: '/index.html'} (shell + client-side routing). So the old "uncached route must show ERR_FAILED" control no longer proves the network is really cut — instead prove it with (a) an in-page fetch('https://www.seatmark.cn/api/quota') that must reject with Failed to fetch, and (b) the pages cache gaining no new entries during the offline run.
Good offline assertions: /guides must render 「教程中心」/「共 N 篇教程」, /pricing must render 「定价方案」/「定价常见问题」, / must render hero 「上传 Excel,批量生成」. Note /tutorials is not a router route (app/src/router/index.ts) — offline it renders the app's own 「404 NOT FOUND」 view, which is still a pass for shell fallback.
Root path / and the precache directoryIndex trap
workbox's precache route maps / to the precached index.html via directoryIndex (default 'index.html') and is registered before runtimeCaching, so while that default was in effect (PR #126) navigations to / never entered the pages cache even though /studio, /templates did. PR #127 set directoryIndex: '', after which / goes through the navigate NetworkFirst rule and does appear in pages.
Useful assertions: navigate /studio → / → /templates in order and diff caches.open('pages').keys() after each step — https://www.seatmark.cn/ must be added by the / step. Also check the stored entry's date header is the current run time (proves a network fetch, not an old copy).
Reading response headers: in headless chromium 121 the main-frame Document Network.responseReceived event is not delivered to the page session, so read what the SW would actually serve via caches.open(k).match(req) → res.headers.get('x-frame-options'/'content-security-policy'/'strict-transport-security'), and use a cross-origin iframe navigation as the second source.
SW update semantics: the triggering navigation is still served by the OLD worker
Even with skipWaiting+clientsClaim, the reload that discovers a new sw.js is answered by the old worker; the new one activates during/after it (active: 'activating' shows up ~4-6s later) and handles the next navigation. So any cache-write fingerprint (e.g. pages / entry date refreshing) must be checked on a second navigation — checking only the triggering reload looks like a failure when behaviour is actually correct. When #N and #N+1 builds have identical precache fingerprints, use these behavioural fingerprints instead: presence of a new active: 'activating' transition, and cache entries being rewritten with fresh dates.
Clickjacking test harness
Local attacker page at /tmp/r103web/frame.html + frame2.html, served by python3 -m http.server 8099 in that dir; it embeds https://www.seatmark.cn/studio in an iframe. Pass = blank iframe + security console error; always add a control (top-level navigation to the same URL must render).
Studio flows via CDP
- Import: pick the
input[type=file] whose accept contains xlsx (index it from document.querySelectorAll), then DOM.setFileInputFiles. Success signals in page text: 共 N 条数据, N 个标签 (the transient toast is often gone by the time you screenshot).
- PNG export: click the
图片 PNG toolbar button → dialog. There is no separate "confirm/export" button: clicking the export-mode card (无水印导出(今日剩余 N 次) or 带水印导出(不限次数)) starts the export immediately. Unauthenticated quota is 1 watermark-free export/day, so use 带水印导出 for repeatable tests. Set Page.setDownloadBehavior first; multi-label exports arrive as a .zip (verify with zipfile, extract a PNG and eyeball it).
- Print: the print host mounts only briefly. Stub
window.print = () => { window.__printed = 1 }, click 打印 / 矢量 PDF, then click an export-mode card in the resulting dialog (the button alone does not print), poll window.__printed, and call Page.printToPDF({preferCSSPageSize: true}) the moment it flips to 1. Inspect with pdfinfo / pdftoppm.
Stress / weak-network testing (round 109 learnings)
-
Scale: 签到桌牌版 lays out 10 labels/page, so N rows → N/10 pages; 640 rows = 64 pages. Full image-PDF export of 64 pages ≈ 68 s, 320-label PNG zip ≈ 33 s. During export the loading overlay shows 正在渲染第 N/M 页... and a 取消导出 button; cancel fires toast 已取消导出 and must leave zero files in the download dir. UI responsiveness can be proven by timing Runtime.evaluate heartbeats (expect < 1 s each) while the export runs.
-
Long-text pitfall: the Studio preview truncates overlong names/cells with a single-line ellipsis, but exported PNG/PDF may render them as two overlapping lines (WYSIWYG divergence) — always compare preview pixels vs export pixels for 20+ char inputs.
-
Weak-net first visit: use a fresh --user-data-dir + Network.emulateNetworkConditions (Chrome Slow 3G = latency 2000 ms, throughput 5001024/80.8). The page is prerendered: static HTML is visible ~2.5 s, then hydration may blank <main> for ~2–3 s while the lazy-loaded route chunk (e.g. HomeView) downloads. Sample document.querySelector('main').innerText.length every ~0.5 s to catch the blank window; a single late screenshot will miss it. (Fixed in #131: mount now waits for router.isReady(), so no blank window should appear.)
-
To prove absence of a transient blank window, CDP round-trip sampling (~0.8 s) has gaps — inject an in-page Page.addScriptToEvaluateOnNewDocument with setInterval(200ms) recording main.innerText.length from document start, then read window.__samples at the end for gap-free evidence.
-
Headless Chrome on this box can take ~10+ s to open its debug port; retry curl /json/version a few times before assuming failure.
-
Toasts are short-lived — poll the toast container every ≤0.3s from the moment of the triggering action (draining events first, then reading, misses them). When asserting glyph/font behavior, first probe the test browser's own font coverage with the same canvas method as the product; beware that one astral char in a text run can make the entire line render as tofu on font-poor Linux/headless environments.
-
Export-dialog dropdowns (e.g. #png-unit) are custom SelectField components (button + option list), not native <select> — setting .value via JS is a silent no-op; click the trigger then the option text. Transient loading-overlay phases can last <0.5s; sample/screenshot at ≤50ms intervals to capture the final phase pixels.
Analytics & console checks
Analytics are injected after load via requestIdleCallback. Collect Network.requestWillBeSent for ≥12 s and assert hits for gtag/js, hm.baidu.com/hm.js, zz.bdstatic.com. Microsoft Clarity was removed in round 177: its internal node Map retained every detached DOM tree it had observed (root cause of the round-176 "template picker modal leaks 3.3k nodes/cycle" P3 — heap-snapshot retainer path ended at window.clarity → closure → Map → <h3>). If a clarity.ms request appears, that's a regression. Ignore Chrome's Blocked third-party cookie warnings — they are pre-existing noise.
When auditing memory: Memory.getDOMCounters + HeapProfiler.collectGarbage alone can't tell you the retainer — take HeapProfiler.takeHeapSnapshot and walk reverse edges (skip weak) from a leaked element to a GC root; third-party analytics scripts are prime suspects for detached-tree retention.
Leak-retest baseline (r178, #186): with Clarity removed, 30 open/close cycles of the 全部模板 modal hold FLAT at ~8.1k nodes / 1.73k listeners after GC (a one-time ~+5.3k/+1.36k jump on FIRST open is lazy-init, not a leak — always compare round-10 vs round-30, not round-0). index.html-only deploys do NOT change the entry bundle hash — judge the flip by HTML content (e.g. grep count) + sw.js md5. To capture analytics/network requests, run a dedicated recv loop right after Page.navigate: helper wrappers that issue CDP commands consume Network events in their round-trips and will falsely report zero requests.
Lighthouse
npx -y lighthouse@13.4.1 <url> --only-categories=performance,best-practices --form-factor=mobile --screenEmulation.mobile --throttling-method=simulate --output=json --output=html --output-path=<dir>/<name> --chrome-flags="--headless=new --no-sandbox".
Baselines (r179, post-Clarity-removal, lighthouse 13.4.1 mobile medians of 3): Perf / 98 · /studio 78 · /templates 93; CLS 0 everywhere; LCP home 1.83s / studio 4.71s / templates 2.48s. First run per page is a cold outlier (home run1 was 80) — always take ≥3-run medians. Best Practices is still 58 on all three pages and is pinned by Baidu Tongji alone: (1) third-party-cookies — its HMACCOUNT_BFESS cookie zeroes the audit; (2) inspector-issues — CookieIssue from the same cookie; (3) deprecations — hm.js registers two window unload listeners; Lighthouse attributes them to our bundle because Sentry's breadcrumb integration wraps addEventListener (the app bundle itself contains no unload listener — the only unload string is Sentry's navigation-span name array). Removing Clarity did not move BP; upgrading Sentry won't either. BP cannot rise while Baidu Tongji remains — treat BP=58 as expected, not a regression. Root-document response time fluctuates (60 ms – 3.2 s); re-sample before calling it a regression.
Known production limitations
x-seatmark-storage: memory (KV unbound) → rate limits ineffective, short share codes unreadable, and /api/auth/code returns 502 (SES unconfigured) so login-gated flows cannot be tested online. Report them as untested rather than guessing.
Devin Secrets Needed
None for production read-only/UI testing.
Excel import fidelity testing
Craft workbooks with openpyxl using real cell types + number formats (openpyxl can't cache formula values — inject <v> into xl/worksheets/sheet1.xml and add t="str" on the <c>, removing any empty <v/>), then pre-read with the product's own app/node_modules/xlsx (sheet_to_json({header:1, raw:false}) gives the Excel-visible text) to establish exact expectations before UI testing. Studio field-mapping dropdowns are custom SelectFields: click the trigger button next to the field label, then click the option text. When reusing prior-round scripts, change output paths to the current round number first to avoid overwriting old screenshot evidence.
Rare-CJK font testing
Assert with CSS.getPlatformFontsForNode (expected Plangothic P1 for ext-B chars, template font for common chars) plus pixel screenshots — document.fonts.check / computed font-family alone can pass while the element still renders .notdef. When counting font-pack network requests, use a cold Playwright browser with a page.on('request') listener — a reused 9222 tab's performance entries can contain cache re-fetches from earlier sessions and overcount.
Print-channel physical accuracy testing
The print host (.offscreen-host) unmounts 1.5s after window.print (printing.ts fallback) — to capture output, stub window.print=()=>{} and wrap setTimeout to stretch the 1500ms delay before clicking, then call CDP Page.printToPDF with preferCSSPageSize:true and dispatch afterprint to release the flow. Firefox can print silently to PDF via prefs print.always_print_silent + print_printer='Mozilla Save to PDF' + print.printer_Mozilla_Save_to_PDF.print_to_filename. Measure grids by rendering with pypdfium2 at 600dpi and taking row/column darkness profiles; expect ~0.1mm pt-quantization on page size (Chromium 594.96pt, Firefox integer pt) — don't flag it as a product bug. The paper-type picker is a custom [role=combobox] (text 「不使用纸型」), not a native select.
A11y audits
axe-core 4.10.2 lives in /home/ubuntu/a11y/node_modules/axe-core/axe.min.js — inject via CDP Runtime.evaluate then axe.run(document,{resultTypes:['violations']}) with awaitPromise. Dedupe by rule id + root component. Historical a11y baselines (rounds 35–37) are not in the repo; classify new-vs-preexisting via git log -S. Keyboard checks: use Input.dispatchKeyEvent rawKeyDown/keyUp (modifiers=8 for Shift+Tab); the designer only opens after clicking 打开可视化设计器 which may need scrollIntoView + retry.
Designer (390 viewport) specifics
Layer-list rows are small buttons that JS text-matching can mis-target after renames — screenshot the open 字段列表 panel and click by pixel coordinates instead. The 示例内容 input only renders for text fields whose 内容来源 is an Excel column (v-else-if branch), so select e.g. 姓名 rather than the fixed-text 提示语 before asserting it. Always exit the designer via 取消 to discard draft edits made during testing.
Paper picker DOM & release-lock behavior (since #150)
As of the #150 build the paper-type picker is a button[aria-haspopup=listbox] (no longer [role=combobox]) — update CDP selectors accordingly. 「不使用纸型(自由排版)」 now releases an active paper lock (toast 「已取消纸型锁定」, restores design-template page/label and scales fields back); it is a guarded no-op when nothing is locked. CAUTION: never run git checkout origin/main -- . in the repo — it clobbers the accumulated test-report.md; if that happens, the history may be recoverable from git stash entries.
eink/PNG-export & designer automation notes
Open the PNG panel via the 「图片 PNG」 button first — the 分辨率预设 select only exists inside it; export downloads may land in ~/Downloads even after Page.setDownloadBehavior (also set Browser.setDownloadBehavior at the browser WS, and diff-glob Downloads before/after). DOM.setFileInputFiles needs a nodeId from DOM.getDocument+DOM.querySelectorAll — a nodeId via DOM.requestNode from a Runtime objectId may silently not fire the change handler. Designer HEX text inputs ignore synthetic input events; set the sibling input[type=color] instead. For share-link restore tests use Target.createBrowserContext for a truly clean localStorage — but the new tab has the same /studio URL, so match tabs by exact URL/query. Verify glyph correctness without a display by rendering PNG ink regions to ASCII art with PIL/numpy.
PNG panel exact-width input & naming (since #151)
In the PNG panel the exact-pixel custom width input is #png-exact-width — do NOT set "the first visible input[type=number]", that is the template label width (mm) and mutating it silently changes the template aspect ratio and all derived export sizes. The PNG panel closes after each export; re-click 「图片 PNG」 before the next one. Since #151, exact-pixel zip/png names carry a -{w}x{h}- suffix reflecting the actual derived output size; standard-clarity names have no suffix.
Designer HEX input & template panel pinning (since #152)
Designer HEX text inputs DO accept real keyboard input — send CDP Input.dispatchKeyEvent char events (put text only on the char event, not on keyDown, or characters double) then Enter; the invisible sibling is an input[type=color] overlay with the same #value, so select the hex field with input[type=text] + /^#/ filter. Since #152, custom (non-builtin) templates are pinned first in the collapsed 3-card template area; the delete confirm modal's confirm button is a second visible 「删除」 next to 「取消」.
Multi-tab storage testing & quota-full toasts (since #153)
Layout-panel number inputs need BOTH input and change events dispatched (or real keyboard + blur) for the Vue model to update — an input-only synthetic event leaves the model stale and fakes a "silent revert". When testing quota-full workspace persistence, note localStorage same-size overwrites of an existing key succeed even when full; delete seatmark.workspace-template.v1 first so the debounced write genuinely throws. Since #153 the custom-template store syncs from storage before each mutation and listens to storage events, so multi-tab panels update live. On the 29229 headless Chromium, Page.captureScreenshot on a background tab hangs — Page.bringToFront first. Since #154, quota-full saves show only the danger toast (no success toast).
Toast-absence assertions (since #154)
Save-success toasts (designer save, shared-template save, JSON import, cloud restore) are gated on templateLibrary.lastPersistOk — quota-full saves show only the danger toast. When asserting "toast X absent", match the full document.body.innerText against all toast strings AND pixel-verify the fixed toast area (crop right-3/bottom-20, max-w-80 ≈ x 1040-1440 of a 1440-wide full-page shot); expect a concurrent workspace warning toast in quota-full states, which is intended #153 behavior.
Photo re-upload reminder & photo-match controls (since #155)
The roster sessionStorage payload carries hadPhotos/photoColumn; reload with hadPhotos=true fires an info toast 「照片需重新上传」 ~0.5s after load that auto-dismisses in a few seconds — poll for it immediately after Page.reload (0.4-0.5s intervals), don't wait for full page settle or you'll miss it; grab the toast element's getBoundingClientRect at detection time to crop pixel evidence. The photo-match column is a button[aria-haspopup=listbox] labeled 「请选择 Excel 中的一列」 next to the 「照片匹配」 label; photo upload is the input[type=file][accept="image/*"]; photo filenames must equal/contain the selected column's cell values to match.
Large-roster export testing (round 141)
For large-roster export tests always use 「带水印导出」 (free & unlimited) — the watermark-free path burns the anon 1/day quota (seatmark.clean-export-usage.v1). The PNG zip naming mode is a button[aria-haspopup=listbox] labeled 「序号命名(前缀-001.png)」 (not radios); switching to 按名单字段命名 auto-fills {姓名}. Field-mapping selects are the button[aria-haspopup=listbox] elements reading 「未映射」. Export progress lives in document.body.innerText as 「正在渲染第 i/N 页...」 with a visible 「取消导出」 button; downloads may land in ~/Downloads despite Browser.setDownloadBehavior — always diff both dirs. 1000 rows ≈ 47KB roster payload, far below sessionStorage quota, so the over-quota skip branch can't be triggered naturally.
Anon clean-export quota & site search (round 142)
Anon clean-export quota is 1/day in localStorage seatmark.clean-export-usage.v1, consumed only AFTER a successful export (cancel/fail don't deduct) — test cancel-not-deducted BEFORE spending the quota. When exhausted, clicking 无水印导出 opens QuotaLimitDialog instead of exporting; the export-button badge switches to 「带水印免费」. Watermark pixel-proof: export clean then watermarked with identical params and diff — the watermark sits in the bottom band (~y 96% of an A4 3509px page). /templates & /guides search accept full pinyin and initials via utils/pinyin.ts; empty states are 「没有匹配“q”的模板…」 and 「该条件下暂无教程…」, both followed by recommendation cards (so count visible cards excluding the fallback section).
WebKit mobile testing (round 143)
WebKit mobile testing: sudo apt-get install libgles2 gstreamer1.0-libav + python3 -m playwright install webkit, then launch with PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1 (host validation false-positives after deps are installed). On the 390px mobile layout the export buttons only exist in the 「预览」 tab — tap 预览 first. Single-page PNG exports download a bare .png, not a zip. The rare-CJK Plangothic fallback toast only fires on devices whose fonts lack the glyphs — Linux Noto CJK covers ext-B, so that branch can't be triggered locally; verify rendering (no tofu) instead.
Full template-library sweeps (round 144)
Full-library sweeps: extract expected values (mappable count, demo dataset via resolveDemoDataset, paper fits via evaluatePaperFit) with npx vite-node importing the app's TS data files directly (export is labelPapers, not LABEL_PAPERS). Deep-link /studio?template=<id>&demo=1 applies template + scenario demo data; the preview page element is .sheet-page. Don't trust seatmark.workspace-template.v1 read ~1s after navigation for the template name — the debounced persist can still hold the previous template (false mismatch); recheck individually or read the rendered preview. Run sweeps in ONE sequential tab: parallel CDP tabs starve background tabs and every screenshot times out. New tab endpoint needs PUT /json/new, not GET.
Paper-type testing (round 145)
Paper-type testing: the studio paper SelectField auto-displays a slug when the template's default page/label dims match a spec (matchLabelPaper) — a template can appear "locked" to a new paper with zero clicks, so to exercise the lock toast select 「不使用纸型」 first then re-select. Options are [role=option] buttons inside the SelectField dropdown (custom component, no native select); re-read the button's bounding box AFTER scrollIntoView settles or CDP clicks land off-target. /studio?paper=<slug> deep link auto-switches to the best-fitting template with toast 「已换用适配该纸型的模板」 when current template doesn't fit. Standard's native paper is a4-24up (not 21up). Export grid geometry can be pixel-verified at 2481px/210mm ≈ 11.81 px/mm using the spec's margins/gaps.
Malformed-input testing (round 147, hardened in #159)
CDP file injection: pass objectId straight to DOM.setFileInputFiles — routing through DOM.requestNode→nodeId silently fails to fire the change handler. Share-hash (#tpl=) testing MUST use a fresh tab: same-tab navigation from /studio to /studio#tpl=… is a hash-only change that never remounts StudioView, so handleShareHash won't run (false negatives). Since #159, renamed/garbage .xlsx without a ZIP PK header is rejected with 「不是有效的 .xlsx 工作簿」 (SheetJS CSV/text fallback no longer fires for .xlsx; .xls/.csv unaffected), and photos are magic-byte sniffed (JPEG/PNG/GIF/WebP/BMP/ftyp/SVG) — fake images land in the photo-error list, truncated-but-valid-header JPEGs still pass. Password-protected xlsx gets the generic 文件解析失败 message.
Cut-line rendering & export forensics (rounds 150–154)
html2canvas-pro 2.0.4 rasterizes solid backgrounds and inline SVG (incl. <line stroke-dasharray>), but NOT dashed borders on zero-width elements and NOT repeating-linear-gradient backgrounds — decorations relying on those silently vanish from PNG/图片版 PDF exports while preview looks fine; always verify exports at pixel level, never by preview. Since #162 cut lines are an inline <svg viewBox="0 0 paperW paperH"> with line.cut-line children. To capture REAL browser-print output headlessly: override window.print to delay afterprint ~10s (keeps the print host mounted), then CDP Page.printToPDF with printBackground:true. Export zips are named 模板名-YYYYMMDD-HHMM.zip — same-minute exports collide and CDP allow download mode silently overwrites; whole-page exports stall in backgrounded tabs (rAF throttling), so run exports in ONE foreground tab sequentially. Right after an EdgeOne deploy flip there's a ~20-min edge-propagation window where exports can still show OLD behavior (even mixed JS/CSS layouts) despite the new bundle — wait and re-run before judging a fix failed. The PNG export dialog defaults to 按标签逐张导出; switch the first listbox to 按整页导出 for whole-page tests. Template deep-link ids must be exact (?template=aurora silently falls back to standard; the aurora tent is deluxeConfAurora).
Deploy-flip polling addendum (round 168)
sw.js and the entry bundle may flip at DIFFERENT times (observed sw.js ~30s before index-*.js in r168) — require BOTH indicators changed AND a confirming re-sample before testing, never just one. Current /seating seat-no baseline (r168, #177): color #475569 / rgb(71,85,105), contrast 7.58:1 white, ~6.95:1 on blue-50/pink-50 gender-tinted cells.
Contrast/visual checks on /seating (round 166)
Assert computed styles only AFTER clicking 「用演示名单」 and waiting for .seating-seat-name to show real names — the empty pre-roster grid returns inherited styles (16px/oklch) and will false-fail. Demo roster includes gender, so seat cells default to blue-50/pink-50 tinted backgrounds (seating-seat--boy/--girl) — compute contrast against the EFFECTIVE ancestor background, not just white; the sheet (.seating-sheet) itself is pure white.
SW takeover (#126 verified r164)
sw.js uses skipWaiting+clientsClaim — after a JS-touching deploy, a tab controlled by the old SW loads the NEW entry bundle after a SINGLE reload (waiting=null). Judge takeover by "loaded entry hash = new + registration.waiting=null"; the workbox precache cleans OLD entries ASYNCHRONOUSLY after activate (you may transiently see old+new entries, e.g. 57→61→57, and hasNew=false right after reload) — poll a few seconds before asserting on precache contents.
Guides QA & sample Excel (round 162)
Guide content lives in app/src/data/guides*.ts (6 files, currently 76 slugs across 13 categories — recount before sampling, the total grows). quickStart CTAs are {label,to} RouterLinks (一键载入…/打开…); valid template deep-link ids seen in guides: standard, meetingTent, weddingPlace. Sample-Excel download (导入面板「下载样例 Excel」) is client-side XLSX.writeFile named <sheetName>样例.xlsx, headers follow the CURRENT template's scenario — capture via Browser.setDownloadBehavior at the browser WS. npm audit highs are all transitive via vite-plugin-pwa→workbox-build/vite (build-time only, not shipped); don't panic-upgrade.
Perf auditing (round 161)
Use npx lighthouse 13.x with default mobile emulation / --preset=desktop (same rig as rounds 98/117/118/161); /studio mobile LCP jitters ±40% and cold first runs of any page score low — always take ≥3-run medians and discard cold outliers before judging regressions. Current medians (r161, index-DTLWjJ7n): / mobile Perf 91·CLS 0, desktop 100·CLS 0; /templates mobile 75; /studio mobile 82.5·LCP 3.69s; /vs & /desk-card-generator mobile 98-100. BP is stuck at 58 from analytics third-party cookies (expected, not a regression). /studio has TWO file inputs — the first is JSON import; inject Excel into the one whose accept includes .xlsx or the import silently no-ops.
/seating testing (round 160)
Seat cells are [data-seat-no] with name in .seating-seat-name (first innerText line is the seat NUMBER — don't compare by line 0). Drag-swap sets an internal suppressClick that swallows the NEXT Enter/click, so keyboard-swap (#123) tests must run from a clean state (reload first). Persistence key seatmark.seating-state.v1 (incl. arranged order), handoff key seatmark.seating-handoff.v1 → /studio?from=seating auto-switches to deskName only when <half the mappable fields match. In headless Chromium navigator.clipboard.writeText fails silently WITHOUT the failure toast — shim writeText to capture the share URL before clicking 复制当前模板分享链接.
Deploy-flip detection (round 159)
Don't poll only the entry bundle hash — manifest-only (or other non-precached asset) changes flip WITHOUT changing index-*.js or sw.js (manifest.webmanifest is not in the precache globPatterns). Poll the artifact that actually changed (e.g. curl manifest.webmanifest | jq .lang). A new SW version only exists if sw.js content (md5) changes; SW-takeover tests require a deploy that touches js/css/html.
Multi-sheet & CSV encoding (round 157)
Multi-sheet workbooks import the FIRST sheet immediately (toast「文件含 N 个工作表,可在导入面板切换」); the sheet switcher is a native <select> in the import panel listing sheet NAMES (not a dialog/button) — drive it with select.value + change event. CSV encoding after #165: UTF-8 (BOM optional) and GB18030 both work; only .csv non-ZIP files take the string-decode path, so the #159 PK-magic rejection for renamed .xlsx still applies.
PWA / offline testing (round 156)
Precache is workbox-precache-v2-https://www.seatmark.cn/ (~57 entries; plangothic fonts intentionally excluded); navigations are NetworkFirst(4s) with precacheFallback to index.html, so ANY route opens offline and the SPA renders full content client-side (guides data is in the bundle) — don't expect a bare shell. Whole-page PNG export of a SINGLE page downloads a bare .png, not a .zip — don't filter downloads by .zip. Anonymous clean-export quota is pure localStorage (seatmark.clean-export-usage.v1), so offline doesn't change quota behavior. Rare-glyph fallback warnings may be untestable headlessly: Noto CJK covers most Ext-B/C chars so the extension-font download path never triggers. Since #165, CSV import decodes UTF-8 (strict, BOM stripped) then falls back to GB18030 — plain UTF-8 and GBK CSVs both parse; write test CSVs in any of the three encodings.
SEO acceptance (round 155)
Production pages are prerendered static HTML, so curl is authoritative for title/canonical/OG/JSON-LD (script tags carry data-route-jsonld attrs). /vs detail pages emit Article+FAQPage+Breadcrumb; topic landing pages emit SoftwareApplication+HowTo+FAQPage+Breadcrumb (seo.ts ~420-465). Factuality source of truth for competitor claims: docs/competitive-round3.md + docs/competitive-analysis.md.
SEO / link audits (round 146)
SEO/link audits can be pure-curl: every route is prerendered static HTML with title/description/canonical/JSON-LD/OG inline, and unknown routes return a REAL HTTP 404 (noindex, canonical /404) — no SPA-200 fallback, so curl status codes are authoritative for dead-link checks. JSON-LD script tags carry a data-route-jsonld attribute (regex needs [^>]* after the type attr). llms.txt embeds a bare-domain URL immediately followed by a fullwidth paren — restrict URL regex to legal URL chars or you'll get a fake unreachable URL. CDP websocket connections now require suppress_origin=True (Chromium rejects 127.0.0.1 origin with 403 otherwise).
PNG artifact facts (round 169/170)
Exported PNGs historically carried NO pHYs chunk — since the round-170 fix, standard-mode whole-page and per-label PNGs embed pHYs (A4@300dpi = 11811 px/m; verify with Pillow img.info['dpi'] or zlib-free chunk scan), while exact-pixel (eink) exports intentionally omit it. Pixel size stays authoritative (A4@300dpi = 2481×3509). Per-label export of a 24-label template downloads a .zip even in "each" mode; the export-mode picker is a listbox opened via the button containing 按标签逐张导出. Router scrollBehavior restores savedPosition via double-rAF — back/forward scroll assertions need a short settle wait. /templates thumbs are all aria-hidden decorative; their tiny gray glyphs (~2.6:1) are exempt from WCAG 1.4.3.
PNG pHYs baseline & CDP download gotcha (round 170, #180)
Standard-mode PNGs (whole-page & per-label) now carry pHYs=11811 px/m (unit=meter, ~300dpi); exact-pixel/eink exports intentionally have NO pHYs and stay pure 2-color. CDP gotcha: Browser.setDownloadBehavior dies when its browser-level WS closes — re-connect the browser WS and re-set downloadPath inside EVERY export script, or exports toast success but nothing lands on disk. Deep-links /studio?template=eink800&demo=1 / ?template=deskName&demo=1 are the fastest way to switch templates; the export dialog's actual trigger is the 「带水印导出」/「无水印导出」 quota buttons, not a generic 导出 button.
Small-label pHYs baseline (round 171)
Templates narrower than ~84.7mm trigger pngRasterScale upscaling (min output width 1000px, max 8x) — per-label pHYs then equals round(output.width/labelWidthMm×1000), e.g. drinkCup 36mm → 1000px @ 27778 px/m (~706dpi); whole-page export of the SAME template stays at 2481×3509 @ 11811 px/m. Handy small templates: drinkCup/libraryCall/mailboxLabel/spaHook/weddingCandy (36mm), kidsCup (40mm) — deep-link via /studio?template=&demo=1.
Display-settings robustness (round 172/173)
PNG/PDF export scale is fully decoupled from page zoom and devicePixelRatio (pngExport.ts uses pngRasterScale only) — exports under Emulation.setPageScaleFactor 0.8-1.5 and DPR 1-3 are pixel-identical to baseline (md5 may differ at high DPR from PNG encoding; always compare with a numpy pixel diff, not md5). Since round 173 the offscreen export/print host sets forced-color-adjust: none, so exports keep design colors even while forced-colors is active — brand teal rgb(13,148,136) surviving in the artifact is the pass criterion. High-DPR exports render slower — extend download-wait and re-set downloadPath per script.
forced-colors export gotcha (round 174)
html2canvas renders a CLONE inside its own same-origin iframe — forced-colors still matches there and any inheritable exemption on an ancestor OUTSIDE the captured root (e.g. .offscreen-host) is lost in the clone. The exemption must live on the captured root itself (.sheet-page, added round 175). Judge the fix by counting brand-teal rgb(13,148,136) pixels in the artifact (baseline ~114k on deskName A4) plus numpy diff vs a same-tab non-forced export. Stale window.print hooks die on tab reload — always re-hook in a fresh script before asserting print invocation.
forced-colors export baseline (r175, #184)
With forced-color-adjust:none on the capture root .sheet-page, a forced-colors export is byte-identical to the normal baseline (deskName A4: md5 3e8fdf3e…, teal px 113,898). CDP gotcha: Emulation.setEmulatedMedia is SESSION-scoped — a freshly attached session reads matchMedia False even if another session set it earlier; always set the emulation and export within the same WS session, and assert matchMedia True both before AND after the export to rule out false passes.
Export baseline gotcha (r182): the r170 whole-page PNG md5 baseline (3e8fdf3e…, deskName demo A4) is a WATERMARKED export — always use the 带水印导出 button when reproducing it; the watermark-free path differs only in the per-label seatmark.cn badge area (~170k px) and burns the 1/day quota. RTL export fix (#189, rasterizeRtlText in pdfExport.ts) pre-rasterizes .label-field__content containing U+0590-08FF etc. to via Canvas 2D before html2canvas — judge it by per-label artifact eyeball vs preview plus ink-bbox aspect ratio (±5%), and confirm non-RTL labels' dark-pixel counts stay byte-stable as a no-touch control.
Minority-glyph warning (#191, r184): findUnsupportedMinorityChars scans Arabic/Tibetan/Mongolian/Yi ranges incl. Mongolian Supplement U+11660-1167F. To positive-test the「名单含 N 个无法显示的字符」toast on this box WITHOUT font blocking, import a name containing U+1166F — fc-list :charset=1166f is 0 fonts so it reliably tofus (also exercises astral codePointAt). U+31350 remains the CJK positive control (triggers the separate 遍黑体 toast). The two toasts are independent chains — a minority-script miss must NOT trigger the extension-font toast.
font-synthesis gotcha (r186, #193): CSS font-synthesis: none on .sheet-page fixes DOM preview only — html2canvas draws text via canvas fillText and re-synthesizes bold for boldless fonts (Plangothic), so exports stay fragmented; always verify BOTH preview crop AND exported artifact, and byte-compare the per-label PNG against the known-bad baseline (identical md5 = export path untouched). To crop a preview field: scrollIntoView with behavior:'instant' in one JS call, sleep ~1s, THEN measure getBoundingClientRect in a second call (rects measured in the same call as the scroll are stale). r147_lib Tab.cmd returns the CDP result payload directly (use r['data'], not r['result']['data']).
Synthetic-bold export fix (#194, r188): neutralizeSyntheticBoldRareGlyphs (pdfExport.ts, called after truncateClampedText, BEFORE rasterizeRtlText) wraps rare-codepoint runs in weight-400 spans inside ≥600-weight fields on the export clone only. Verify it by md5-flipping the per-label PNG against the known-bad baseline (d241c042… = untouched export path) plus eyeballing the 𱁬 crop; the deskName demo whole-page md5 (3e8fdf3e…, watermarked) must stay byte-identical as the no-touch control since common chars are outside isRareCodePoint.
Print-channel capture (r189): the 打印 / 矢量 PDF button first opens the watermark choice modal (click 带水印导出), then doPrint mounts the print host and calls window.print — in headless Chrome window.print is a no-op and the host unmounts after the 1.5s afterprint fallback, so naive Page.printToPDF captures a BLANK 995-byte PDF. Reliable capture: pre-override window.print=()=>{throw new Error('hold')} — doPrint has no finally so unmountHost is skipped and the host stays mounted indefinitely; then Page.printToPDF (A4, printBackground:true, zero margins) and close the tab to clean up. Side effects: quota not consumed, no 已调起打印 toast, no pageerror observed. Print output uses browser-native shaping, so RTL is naturally correct there (html2canvas RTL/synthetic-bold bugs never applied to the print path).
AI-design testing (r209/r212): entry is /studio?design=new → button AI 自动设计; free channel tries /api/ai-design then browser-direct pollinations openai/openai-fast. After #214/#215 the no-key failure mode is 502 (server-side Pollinations proxy also 402-rejected) with the upstream error text surfaced in the response body. Pollinations legacy /openai returns 402 "API key budget too low (0.0000 pollen)" from both EdgeOne egress and this VM — occasional 200s are not reliable; always capture the full 402 body (it distinguishes anonymous-deprecation vs zero-budget-key attribution). CRITICAL trap: saveAiConfig persists provider to localStorage seatmark.ai-config; after touching 自定义 API remove that key or subsequent free-channel tests silently go through the custom channel. To test the apply pipeline without a live LLM, stub window.fetch in-page for /api/ai-design. Dialog error selector: [role=dialog] p.text-red-700. Free-channel success path remains unverifiable until a real AI key is configured.
[r214 update] #216 downgraded the free-channel copy — current canonical strings: dialog intro 免费通道依赖公共模型服务…推荐「自定义 API」更稳定; channel button 免费通道 · 繁忙时限量; free-mode hint 公共服务限量且不保证可用…. Copy-residue checks should grep both document.body.textContent and the deployed bundle for 开箱即用/无需配置/无需注册. For mobile-layout checks, Emulation.setDeviceMetricsOverride width=390 mobile=true then assert documentElement.scrollWidth<=innerWidth plus per-element scrollWidth<=clientWidth+1 inside [role=dialog].
Feedback channel testing (r215): entry is the global floating button [aria-label=反馈] (App.vue mounts FeedbackButton on every page). POST /api/feedback always returns {ok:true} even when archive/rate-limit storage is memory — success toast 感谢反馈 proves webhook push only (after r216 a built-in default WeCom webhook guarantees delivery). The 10/day IP rate limit (rl:fb: keys) does NOT work while x-seatmark-storage is memory (15 straight 200s observed). Frontend maps any non-200 to 提交失败 请稍后重试, so server error texts (429/400) never surface. Overlong test must bypass textarea maxlength via JS value setter; empty-content is blocked by button disabled state, not a request.
[r217 update] #218 added FEEDBACK_WEBHOOK_DEFAULT (built-in WeCom bot, same constant as ai-design alerts) — feedback now pushes server-side even without env config, but delivery is only verifiable in the WeCom group (mark untested-externally and submit a clearly-labelled test feedback for the owner to confirm). The rate-limit gap from r215 is unchanged.
[r218 privacy/network audit] Capture ALL traffic by overriding Tab._ev to store Network.requestWillBeSent (page-level fetch wrappers miss beacons/analytics/pixels). Analytics = GA4 G-5MKTF5XDYQ + Baidu hm.js (index.html:71-87, lazy-injected); router only sends page_path/page_title. Share long-link is pure frontend #tpl=v1.<deflate+base64url(template JSON)> (share.ts) — zero requests; QR short-code POSTs /api/share/tpl with the encoded template only (decode with zlib.decompress(b64,-15) to verify no roster). Roster persists in sessionStorage seatmark.workspace-roster.v1 (session-only); photos are in-memory data:URLs, never persisted. Photo-match UI: SelectField 匹配列 → [role=option] 姓名 → image file input; verify via 已导入 N 张照片 text — the option click can silently miss, always re-assert trigger text.
[r219 seating testing] /seating roster entry is the textarea (paste 姓名 性别 per line, no Excel import); NumberField rows/cols do NOT respond to plain JS input events — drive them via UI clicks or accept defaults. Swap channels: click-two-seats, Enter on tabindex=0 seat divs, and pointer-event drag (5px threshold, elementFromPoint drop — dispatch real Input.dispatchMouseEvent moves). Print host .offscreen-host unmounts 1.5s after window.print via afterprint fallback (printing.ts:23) — run Page.printToPDF within ~1.35s of clicking 打印. Persistence: localStorage seatmark.seating-state.v1 (namesText+arranged); seating→studio handoff via seatmark.seating-handoff.v1, consumed-and-deleted on studio load. /seating makes zero API calls.
[r220 update] NumberField commits on the change event, not input — drive it by real typing (focus→select→per-char key events→Tab) or by clicking the hover nudge buttons [aria-label=增大/减小]; a plain input-event injection silently does nothing. Seating rows clamp to 1-20, cols to 1-16. Shrinking the grid drops overflow students from grid/print/handoff consistently but shows the amber 超出 N 人排不下 hint (not silent). Print-PDF name cells are CSS-ellipsized (全名 assertions must use grid DOM or the handoff sessionStorage instead). Studio's data table shows only a 共 N 条 summary — verify roster contents via sessionStorage seatmark.workspace-roster.v1.
[r221 history/navigation testing] Dialogs have NO popstate integration — back while a dialog is open does a route-level navigation (dialog unmounts, roster survives via sessionStorage/pinia). /templates syncs cat/sub/q to route.query and restores scroll via router scrollBehavior savedPosition (double-rAF). Category pill buttons include a count suffix (会议活动 54) — match with startsWith, not equality. PNG/PDF export starts by clicking 带水印导出/无水印导出 (the mode rows are just options); export progress overlay text is 已完成 N/M 张标签. Template selection only writes localStorage seatmark.workspace-template.v1 after the first explicit switch (null on default).
[r223 back-button dialog-guard testing] To test browser-back against modals, use BOTH history.back() and CDP Page.getNavigationHistory + Page.navigateToHistoryEntry (native back channel). Timestamp-based popstate guards (listener registered after vue-router's own) are subject to a microtask-ordering race — beforeEach can run before the guard's popstate listener updates its timestamp, so verify at runtime, never trust the bundle containing the code. Quota dialog is triggered by setting localStorage seatmark.clean-export-usage.v1 to {date:today,used:1} (anon daily limit=1) then clicking 无水印导出.
[r225 update] #226 sentinel guard works (pushState same-URL entry with a sentinel key in history.state; assert via history.state). Known pitfalls to retest after changes: (a) quota dialog transition (chooseClean closes export dialog then opens quota dialog) — sentinel consumption must not race router.push, or in-dialog RouterLink navigation gets cancelled; (b) in-app link nav while a dialog is open can leave the sentinel entry in history (check for a dead back press from the target page). Native-back testing: Page.getNavigationHistory shows the sentinel as a duplicate same-URL entry — use currentIndex-1.
[r226 update] Final sentinel impl (#228): state key is seatmarkModalSentinel (unique serial, not depth) — assert via history.state.seatmarkModalSentinel. consumeSentinel is delayed 50ms with href+serial recheck, so wait ≥200ms before asserting recycle after Esc/overlay close. Orphan sentinel entries are auto-skipped on popstate (one back from a page reached with a dialog open lands directly on the underlying entry). Quota dialog now carries its own sentinel and its /account RouterLink works. Stacked two-ModalDialog paths still don't exist in the UI.
Auth testing (r227): /account login is email-code via POST /api/auth/code (payload exactly {email}); with SES unauthenticated it returns 502 {"error":"验证码发送失败,请稍后再试"} and AccountView surfaces it as formError (no fake success, codeSent stays false, button re-enabled). Frontend blocks invalid emails with 请输入正确的邮箱地址 and zero requests. devCode stub is dev-only ([[default]].js ~:535). Rate-limit on /api/auth/code is ineffective under memory storage (two rapid sends both hit SES path). Note: r147_lib Tab._ev takes a single raw event dict r (not (m,p)) — wrap accordingly when overriding for network capture.
Excel import edge testing (r228): parser is excel.ts (raw:false formatted text; empty headers auto-named 列N; <2 rows throws 至少需要包含表头行和一行数据; duplicate header names auto-suffixed 姓名2 since r229 fix). A 200-char header used to break the sidebar layout once selected in the mapping dropdown (fixed with min-w-0 on SelectField root). Formula cells need cached values in the xlsx — openpyxl writes none (column reads empty), use xlsxwriter write_formula(..., cached_value) or real Excel files. Verify imports via sessionStorage seatmark.workspace-roster.v1 (top-level headers/rows keys). Mapping selects are BUTTONs inside the 字段映射 section; options are [role=option].
[r229 update] #232 fixed both r228 P3s: duplicate headers now get numeric suffixes (姓名→姓名2, conflict-safe up to 姓名22 — verify via sessionStorage headers/rows), and SelectField/MappingPanel have min-w-0+truncate so long headers no longer break layout once selected (assert scrollWidth<=innerWidth at 1280/768/390 via Emulation.setDeviceMetricsOverride). Export success evidence: poll toasts every 1s right after clicking 带水印导出 (the toast auto-dismisses within ~8s) and set Page.setDownloadBehavior to capture the zip.
Import-entry testing (r230): the /studio drop zone (DataImportPanel onDrop) only renders in the empty state — test drag by constructing a File from base64 bytes + DataTransfer in-page and dispatching dragover+drop DragEvents (needs awaitPromise for async IIFE). Every import (same file, new file, sheet switch) runs applyExcel which fully resets mapping/photos/overrides then re-runs autoMap — manual mappings are never preserved. Composite mappings referencing missing columns are blocked in MappingPanel (应用组合 disabled + amber hint). Sheet switch UI is a <select> shown only when sheetNames.length>1. clearData toast is 数据已清空 with no confirm dialog.
Photo testing (r231): use template 照片核验版 via /studio?template=withPhoto (photo section only renders when template has a column-matched image field). Photo file input has accept="image/*"; select the match column via the 匹配列 label's sibling button (NOT the first button in the section — that grabs a mapping select). Photos are memory-only: any applyExcel (reimport/sheet switch/new file) silently clears them (no toast), while page reload shows 照片需重新上传 toast — poll toasts every 400ms right after reload. Matching: exact-over-fuzzy priority only holds within one upload batch (exactKeys is per-call); later batches overwrite. Use solid-color JPEG/PNG fixtures so card rendering and exported zip PNGs can be pixel-asserted with canvas/PIL.
eink exact-pixel testing (r234): reach via /studio?template=eink800 (PNG dialog then defaults to 精确像素 + 纯黑白 + preset 800×480). The 分辨率预设 SelectField button is best located by text /英寸|自定义宽度/ inside [role=dialog] — the for=png-preset label's parentElement grabs the wrong (unit) select. 6 presets: 800×480/1280×720/648×480/640×384/400×300/296×128; filenames get a -{w}x{h} suffix; outputs must be exactly 2-color with no pHYs. Custom width valid range is [100,4096]; widths >2048 now render at 1× (supersample disabled to keep render width ≤4096, fixing the r234 hang at ≥~3600). Invalid widths are blocked inline (red border + 请输入 100–4096 之间的整数), no toast fires.
[r236 update] #239 fixed the large-width hang (exactPixelSupersample: 2× only when w*2<=4096); widths up to 4096 now export in seconds. Pixel-baseline comparisons across rounds: long-name rows may show ~0.1% single-glyph diffs from Plangothic font load timing — compare per-page and attribute glyph-sized bboxes to font nondeterminism, not regressions.
[r237 correction] The r236 "width-independent intermittent export hang" was a test-harness artifact, NOT an app bug. Two pitfalls: (1) the AppHeader CTA permanently reads 正在制作中 on the studio route — never use that string to detect the export loading overlay; the real overlay text is 正在渲染第 N/M 页... with a working 取消导出 button. (2) Export filenames historically had minute-resolution timestamps, so Chrome silently dropped duplicate same-name downloads within the same minute — file-based success detection false-negatives on repeated exports (fixed later: timestamps now include seconds, -YYYYMMDD-HHMMSS). Always judge export outcomes by toast (install a MutationObserver logging toast texts+timestamps before exporting; toasts live ~8s so late polling misses them). Every export resolves in 1-3s with either a success toast or a fast failure toast (e.g. 页面渲染不完整 from the blank/truncation detector, ~1/25 frequency, retry succeeds).
[r271 update] Template discovery: /templates search supports Chinese substring, full pinyin (lazy pinyin-pro loads ~2s after a ≥2-letter query - wait before asserting), and initials (简拼, e.g. jkz→监考证); matching is a substring over name+scenario+description initials so loose hits are possible. Filter state syncs to route.query (?cat=&sub=&q=), which means search keywords appear in GA/Baidu page-URL telemetry - scan third-party request URLs, not just bodies, in privacy checks. Category chip counts must equal rendered card count; in-category search shows「在「X」分类中找到 N 款」and cross-category fallback shows「…已在全部分类中找到 N 款」. Invalid template slugs return real HTTP 404 with the NotFound view.
[r270 update] Chunk-reload fallback (#273): router.onError full-page-assigns on chunk-load errors with a chunk-reload:<fullPath> sessionStorage guard (cleared in afterEach on success). Testing pitfalls: in a fresh offline context the assign lands on chrome-error:// (sessionStorage unreadable → SecurityError; Chrome auto-retries on reconnect, which is the recovery path). Deterministic anti-loop repro: context.route('**/TemplatesView*', abort) online - marker gets set, exactly one assign, and the prerendered /templates static HTML still shows content. Warm sessions may satisfy offline navigation entirely from HTTP cache, so chunk-failure tests need a fresh context.
[r268 update] xlsx load-failure guidance (#271): after a failed vendor-xlsx prefetch, importing shows the Chinese toast「表格组件加载失败(可能是网络异常),请刷新页面后重试」(excel.ts loadXlsx) - assert this text, not the raw English module error. Known residual behavior: ANY lazy route chunk whose import fails while offline stays broken after network recovery (browser caches the failed module URL; nav retries silently no-op) - reload the page before retrying navigation in offline tests.
[r266 update] xlsx idle prefetch (#270): /studio prefetches vendor-xlsx-*.js ~0.4s after load via requestIdleCallback - first import is warm (~0.13s). Testing pitfall & product hazard: if the prefetch fetch fails (offline/blocked), Chrome caches the failed dynamic-import by URL - later import('xlsx') rejects with zero network retries until page reload; to simulate prefetch failure deterministically use context.route('**/vendor-xlsx*', abort) before goto rather than CDP offline (offline also breaks the router's un-caught import('@/utils/seo') and pollutes pageerror).
[r264 update] Mobile medians (index-C2ENcB-P): / 97 · /studio 80 · /templates 96 · /seating 99 · /account 83; desktop /studio improved to 99. Main chunk gzip ≈106KB (baseline ~107KB unchanged after #259/#261/#265/#267). Interactive import timing pitfall: the FIRST xlsx import in a fresh context lazily fetches vendor-xlsx-*.js over the network (0.17–1.5s); the 0.08–0.13s baseline is the warm path - always measure a second import before calling regression. Paste-import of 100 rows parses/imports in <100ms each.
[r263 update] Paste-roster header switch (#267): the「首行是表头」CheckboxField in the paste dialog appears only when the textarea has non-blank text; it reflects the current parse result and toggling sets an explicit override (reset to auto each time the dialog opens). Playwright pitfall: CheckboxField's native input is sr-only (visual is a custom-drawn box), so check()/uncheck() times out on visibility - click the surrounding label:has-text(...) instead, and read state via the hidden input's is_checked().
[r261 update] Paste-roster import (#265): trigger via「没有文件?粘贴名单」button in the empty-data state of /studio import panel; the「重新上传」button opens the file chooser (does NOT clear) - use「清空」(no confirm dialog) to reset to the empty state. Header detection is a substring match against HEADER_KEYWORDS (excel.ts, includes 手机/学号/座位/序号 etc.), so test fixture names must avoid these substrings or the first data row will be misdetected as a header (P4, r261). Live hint text 识别到 N 条数据、M 列(…) and the success toast both state the header-detection result - assert them verbatim.
[r259 update] Appearance-preference testing: the site is light-only - no dark: variants and no color-scheme declaration, so under prefers-color-scheme:dark everything (including native form controls) must stay pixel-identical to light; assert via matchMedia=true + full-page pixel diff = 0. This Playwright version's emulate_media lacks the contrast kwarg - emulate prefers-contrast via CDP Emulation.setEmulatedMedia {features:[{name:'prefers-contrast',value:'more'}]}. Always prove emulation took effect with in-page matchMedia before asserting. reduced-motion has two degrade paths (main.css .reveal-init media query + HomeView v-reveal matchMedia guard) - positive/negative control: no-preference leaves most .reveal-init without .reveal-in on load, reduce makes it 0. Residual animate-pulse skeleton loaders are expected and acceptable under reduce.
[r258 update] Zoom testing: emulate browser Ctrl+/- zoom via context-level options new_context(viewport={w:round(W/z),h:round(H/z)}, device_scale_factor=z) - do NOT use raw CDP Emulation.setDeviceMetricsOverride on Playwright-managed pages: Playwright silently resets it after actions (screenshots/downloads), so assertions may unknowingly run at 100%. At 200% on a 1280 window the CSS viewport is 640px, which drops /studio into its mobile two-tab layout (设置/预览) - export buttons live behind the 预览 tab. PNG export output is content-identical across zoom levels but not byte-identical: ~40% of files differ in glyph anti-aliasing only (pipeline reads page DPR); compare per-index pixels, not md5 keyed by timestamped filenames.
[r257 update] Deploy detection for lazy-route changes: route views like SeatingView are lazy chunks not referenced in the page HTML - you cannot confirm deployment via curl of the page; open the route in a browser and check the DOM for the new marker text instead. For pixel-evidence screenshots of a specific element on mobile viewports, scroll via getBoundingClientRect().top + scrollY + window.scrollTo - scroll_into_view_if_needed often leaves the element at the viewport edge or under a toast.
[r255 update] Boot-splash (#259): shell pages (/studio, /account, /admin) keep an inline .boot-splash skeleton inside <div id="app">; prerendered content pages and 404.html must have zero boot-splash occurrences (check via curl). Deploy detection for HTML-only changes: the JS entry hash may NOT change - poll the page HTML for the new marker instead. Valid guide slug for checks: /guides/exam-seat-label-batch-print (how-to-make-seat-cards does not exist).
[r253 update] Weak-network/offline testing: throttle via per-page CDP session ctx.new_cdp_session(page) -> Network.enable + Network.emulateNetworkConditions (400kbps=50000 B/s, latency 400) - affects only that page, resident Chrome untouched; full offline via context.set_offline(True) works on CDP-connected contexts. SW is workbox: navigations are NetworkFirst (4s timeout) with precache-shell fallback, so offline route switches render the cached full page (main.ts registers /sw.js; give it ~3s after load to install before going offline). Roster persistence is sessionStorage seatmark.workspace-roster.v1 (NOT localStorage) - assert recovery via visible 共 N 条 + preview names. Analytics (gtag/hm.baidu/zz.bdstatic) inject on requestIdleCallback from index.html with a buffering queue - hang them via context.route never fulfilling; UI must stay responsive. Toast-observer pitfall: init-script runs before documentElement exists - attach the MutationObserver inside a DOMContentLoaded listener on document.body or it silently never fires. r253 verdict: slow-3G first visit usable in 10s, import/export under weak net/full offline/mid-export offline all pass, offline route switch SW-served, recovery intact.
[r250 update] Import fault-tolerance testing: all import errors surface as toast.danger('Excel 导入失败', msg) from workspace.ts importExcel - never uncaught pageerrors; expected msgs live in utils/excel.ts (PK-magic check -> 不是有效的 .xlsx 工作簿; XLSX.read throw -> 文件解析失败; <header+1 rows -> 至少需要包含表头行和一行数据; CFB magic D0CF11E0 -> 可能被密码保护, added after r250). Fixture recipes: truncate good xlsx to 60% bytes for zip-corruption; msoffcrypto.format.ooxml.OOXMLFile(f).encrypt(pwd, out) for encrypted; xlwt for legacy .xls (SheetJS reads it fine); openpyxl refuses XML-illegal control chars (\x0b) - use Tab/newline instead. Multi-sheet success toast appends 文件含 N 个工作表,可在导入面板切换. r250 verdict: 8 fault classes all clean-toast, zero pageerror, no state pollution. #255 fixed the CFB wording - encrypted xlsx now gets the 密码保护 toast (r252 verified).
[r247 update] Large-roster / cancel testing: standard template paginates at 24 rows/page (300 rows=13 pages, 1000=42); preview pager is input[aria-label="跳转到页码"] (max attr = totalPages) + button[aria-label="上一页"/"下一页"]. Image-PDF dpi auto-downscales with roster size (300dpi@40 rows -> 192@300 -> 168@1000) - by design, don't flag as regression. Export progress text is 正在渲染第 N/M 页... in the LoadingOverlay; match that exact prefix (the persistent header 正在制作中 will false-match a generic /正在/ regex). Cancel button is 取消导出 in LoadingOverlay, only present while loading.onCancel is set; cancel toast = 已取消导出 本次未扣除无水印次数. Quota key seatmark.clean-export-usage.v1 is only written on successful clean export (absent = 0 used). Don't sample CDP Performance.getMetrics from a Python thread with sync Playwright (greenlet cross-thread error) - inject an in-page performance.memory setInterval sampler instead. r247 verdict: 300/1000-row import, three export flows, mid-export cancel (no file, no quota, re-export ok), memory peak 147MB->31MB all pass.
[r246 update] Mobile WebKit testing: use pw.devices['iPhone 13'] (390x664 on this box, not 844) with webkit-1967 + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1. Pitfalls: /studio mobile is a 设置/预览 two-tab single-column layout - tap the 预览 tab before export buttons are reachable; there are TWO file inputs (JSON first, Excel second) - target input[type=file][accept*="xlsx"] or imports silently no-op; all dropdowns (zoom, PNG 成图单位) are custom SelectField buttons with aria-haspopup="listbox" (no native select) - tap the button then a [role="option"]. Mobile PNG dialog defaults to per-label; switch 成图单位 to 整页 for full-page. Seating grid on mobile: document has no h-overflow but an ancestor container is horizontally scrollable; there is no swipe-hint text in /seating (only VsDetailView has one). r246 verdict: mobile WebKit golden path (nav/import/4 export flows/touch/#228 sentinel/seating swap) all pass.
[r245 update] Print-path testing: there is no real X desktop on this box (DISPLAY=:0 doesn't exist; the resident CDP-29229 Chrome is headless - never kill it). Start your own Xvfb :99 and run headed Firefox on it. Firefox silent print-to-file recipe (firefox_user_prefs): print.always_print_silent=true, print_printer='Mozilla Save to PDF', print.printer_Mozilla_Save_to_PDF.print_to_file=true, ...print_to_filename= - window.print then writes a vector PDF (validate with pypdfium2 text layer; demo studio print = 2 A4 pages, seating print = 1 page with roster names selectable). Hook window.print via evaluate to count invocations. WPE WebKit has no print-to-file/print dialog - you can only prove window.print was invoked; mark file output blocked. Chromium print baseline: stub window.print, click the print button so the app mounts the print host, then CDP Page.printToPDF (r128 method). Kill your Xvfb when done.
[r244 update] Secondary-flow testing: /seating seats are div.seating-seat (name in .seating-seat-name span), not buttons - click via locator.filter(has_text) after scroll_into_view; judge swaps/persistence by the ordered name list, not pixel coords. In headless Firefox, Playwright page.url may not update after Vue router.push - read location.href via evaluate instead. Share-link capture: override navigator.clipboard.writeText (and execCommand fallback) before clicking 复制当前模板分享链接. Designer entry button is 新建模板 (not 从空白新建); export buttons are disabled until roster data exists - click 先用演示数据看看效果 first. Install the toast MutationObserver via context.add_init_script so it survives navigations (share-hash toasts fire on mount, before any post-load evaluate). WebKit emits benign ResizeObserver loop completed with undelivered notifications. pageerrors - whitelist that string when asserting pageerror=0. r244 verdict: /seating, share links, designer, #228 back-sentinel all pass in Firefox+WebKit.
[r243 update] WebKit testing: Playwright webkit (webkit-1967) works on this box but the host-requirements validator false-positives on libjxl.so.0.8 (bundled under the webkit dir's sys/lib) - launch with PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1; jammy apt has no libjxl package. Pitfall: in WebKit, download.save_as inside a page.on('download') callback can race with ctx.close() and lose the file (TargetClosedError) - for PDFs/large files use with page.expect_download(): then save_as before closing the context. r243 verdict: all main flows incl. eink exact-pixel per-label (800 & 4096) pass in WebKit; no Firefox-style misclamp - genuine 21-char overflow still ellipsizes, single-line names untouched. Chromium/Firefox/WebKit main flows all verified.
[r242 update] The r240 Firefox eink per-label P2 is fixed by #244 (truncateClampedText half-line slack + skipTruncationCheck on the per-label path) - per-label eink exports now pass in Firefox up to 4096 width. Pitfall: /studio remembers the last template via localStorage, so after visiting /studio?template=eink800 a plain /studio in the same context still loads the eink template - use a fresh browser context (or clear storage) before standard-template tests; verify by toast wording (eink toasts say 精确 800×480 像素、纯黑白). To prove ellipsis truncation at runtime, poll .label-field__content textContent during export for the ellipsis character (the export host is live DOM); works in both engines.
[r240/r241 update] Firefox testing: use Playwright firefox (~/.cache/ms-playwright/firefox-1438); judge exports by toast via in-page MutationObserver + page.on('download') (Firefox needs download.save_as). Glyph comparisons vs Chromium: use column-blob segmentation + ink ratios, not md5 (engines differ); Uyghur naturally splits into many runs (non-joining letters), so segment-count differences alone are not defects. The r240 Firefox-only P2 (eink exact-pixel per-label export failing 100% with 页面渲染不完整(右侧内容未绘出)) was fixed in #244: root cause was Firefox scrollHeight counting glyph ink overflow → truncateClampedText ate single-line names to 「…」, and the full-page right-blank truncation heuristic misfiring on 1-up centered-text pages (per-label suppresses cut lines, so no right-side ink); per-label export now skips the page-level truncation check (it has its own per-label blank rerender/error guard) — recheck Firefox eink per-label after deploy.
[r239 update] #241 fixed the minute-collision download loss: PNG/PDF default filenames now end -YYYYMMDD-HHMMSS, so file-based detection of repeated same-minute exports works again. The only PDF path that downloads a file is 图片版 PDF (toolbar button 图片版 PDF(推荐)); 打印 / 矢量 PDF opens the browser print dialog and never writes a file — don't wait for a download/toast on that path in headless.
[r233 update] Current mobile medians (index-EbJxTvBJ, lighthouse 13.4.1, medians of 3): / 91 · /studio 79 (LCP 4.2s, TBT ~205ms) · /templates 96 · /seating 99 · /account 84; CLS 0 everywhere; raw JSONs /home/ubuntu/r233_lighthouse/. Home fluctuates 91–98 between rounds (r161=91, r179=98) — treat ±7 pts as sampling noise, compare against the band not a single round. /account SEO scores 66 by design (is-crawlable fails: account pages are noindex,nofollow per seo.ts). BP=58 stays pinned by Baidu Tongji; an occasional run where the Baidu cookie fails to set scores BP=100, confirming attribution. 40-row import toast baseline now 0.08–0.11s.
[r232 update] applyExcel now toasts 照片已清除 when photos existed before a reimport/sheet-switch (#236); clearData still only toasts 数据已清空. Toast-stack pitfall: toasts live ~8s and stack, so negative assertions ("toast X must NOT appear") must run in an isolated session or after polling until the toast container is empty — otherwise a lingering toast from the previous step false-positives the check.
Telemetry privacy testing (r273)