test(02-05): playwright e2e for PIPE-07 — full Phase-2 loop

- tests/e2e/season1-loop.spec.ts: PIPE-07 smoke covering load → Begin
  → plant rosemary → fast-forward FakeClock 3min → harvest →
  fragment-reveal modal → close → journal-icon visible → open journal
  → fragment present → reload page → fragment persists. Sidesteps
  Phaser canvas pixel-clicking via window.__tlgStore command dispatch
  (test-only window slot, production-guarded by import.meta.env.PROD).
- playwright.config.ts: bumped webServer timeout 30s → 60s for cold
  Vite startup; pinned port 5273 + --strictPort to avoid collisions
  with other dev servers on the user's machine; reuseExistingServer
  false so the spec always starts a fresh Vite against this project.
- package.json: added test:e2e script (npx playwright test). Not
  added to npm run ci — Playwright is slower than Vitest; manual run
  before /gsd-verify-work + future v1 release pipeline.
- src/content/loader.ts (Rule 3 — Blocking): replaced gray-matter
  with a 15-line parseFrontmatter helper. gray-matter pulls in Node's
  Buffer global which is undefined in Vite's browser bundle; the
  build emits a 'Module "buffer" externalized' warning that masks
  the runtime ReferenceError. Surfaced under Vite dev mode while
  running the e2e — Plan 02-03's Markdown loader path (lura-first-
  letter.md + winter-rose-night.md) was effectively broken in real
  browsers since shipping. parseFrontmatter handles the strict
  '---<yaml>---<body>' shape the .md fragments use; bundle dropped
  from 2.2MB to 1.9MB as a side effect of dropping the unused dep.
- deferred-items.md: tracks the gray-matter package.json cleanup
  (the dep is now unused but kept in package.json for now, scoped
  out of this plan).
- npx playwright test exits 0 (1 spec, 1.5s test runtime); npm run
  ci exits 0; 308/308 vitest still green. PIPE-07 satisfied
  end-to-end.
This commit is contained in:
2026-05-09 11:04:32 -04:00
parent 5d58d6cc7b
commit dd486969a9
5 changed files with 304 additions and 16 deletions
+34 -7
View File
@@ -1,4 +1,3 @@
import grayMatter from 'gray-matter';
import { parse as parseYAML } from 'yaml';
import {
SeasonContentSchema,
@@ -8,6 +7,31 @@ import {
type UiStrings,
} from './schemas/index.ts';
/**
* Minimal frontmatter splitter — replaces gray-matter for the Markdown
* fragment loader. gray-matter pulls in `Buffer` (Node-only), which
* breaks under Vite's browser bundle (Plan 02-05 found this — gray-matter
* was working only at build time because Vite externalized buffer with
* a warning, but the runtime ReferenceError surfaced in dev mode).
*
* The Markdown fragments under /content/seasons/<slug>/fragments/*.md
* have a strict shape: `---\n<yaml>\n---\n<body>`. This parser handles
* exactly that shape; anything else throws so the build / module-eval
* fail loudly per PIPE-01.
*/
function parseFrontmatter(raw: string): { data: unknown; content: string } {
// Strip a leading UTF-8 BOM if present.
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
// Match `---\n<yaml>\n---\n<rest>` (allow CRLF line endings too).
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) {
return { data: {}, content: text };
}
const [, yamlBlock, body] = match;
const data = parseYAML(yamlBlock ?? '');
return { data: data ?? {}, content: body ?? '' };
}
/**
* Vite-native content pipeline (PIPE-01). The glob patterns MUST be
* string literals at the call site — Vite's plugin walks the AST at build
@@ -47,8 +71,8 @@ function loadYamlFragments(): Fragment[] {
function loadMdFragments(): Fragment[] {
return Object.entries(mdFiles).map(([path, raw]) => {
const { data, content } = grayMatter(raw);
const merged = { ...data, body: content.trim() };
const { data, content } = parseFrontmatter(raw);
const merged = { ...(data as Record<string, unknown>), body: content.trim() };
const parsed = FragmentSchema.safeParse(merged);
if (!parsed.success) {
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
@@ -108,8 +132,8 @@ export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]>
const mdOut: Fragment[] = [];
for (const [path, loader] of mdMatch) {
const raw = (await loader()) as string;
const { data, content } = grayMatter(raw);
const merged = { ...data, body: content.trim() };
const { data, content } = parseFrontmatter(raw);
const merged = { ...(data as Record<string, unknown>), body: content.trim() };
const parsed = FragmentSchema.safeParse(merged);
if (!parsed.success) {
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
@@ -165,8 +189,11 @@ export function loadFragmentsFromGlob(
return parsed.data.fragments;
});
const md = Object.entries(mdGlob).map(([path, raw]) => {
const { data, content } = grayMatter(raw);
const parsed = FragmentSchema.safeParse({ ...data, body: content.trim() });
const { data, content } = parseFrontmatter(raw);
const parsed = FragmentSchema.safeParse({
...(data as Record<string, unknown>),
body: content.trim(),
});
if (!parsed.success) {
throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`);
}