fix(@projects/@magic-civilization): 🐛 clean up test artifacts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 04:02:51 -07:00
parent 09a9d6dc89
commit 8037a0cc2a
22 changed files with 341 additions and 843 deletions

BIN
mcp_expansions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View file

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> about: crowdfund
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-KauEN4 --remote-debugging-pipe --no-startup-window
- <launched> pid=74057
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> about: early access
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-E2feFR --remote-debugging-pipe --no-startup-window
- <launched> pid=73871
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> about: early access progress
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-iCB9Al --remote-debugging-pipe --no-startup-window
- <launched> pid=73870
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> about: team
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-BK9MB8 --remote-debugging-pipe --no-startup-window
- <launched> pid=74058
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> encyclopedia index
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-M38ORN --remote-debugging-pipe --no-startup-window
- <launched> pid=74059
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> episode: age of dwarves
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-Alczr9 --remote-debugging-pipe --no-startup-window
- <launched> pid=73868
```

View file

@ -1,162 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> home
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: page.goto: Timeout 10000ms exceeded.
Call log:
- navigating to "http://localhost:5802/?skip=welcome", waiting until "networkidle"
```
# Test source
```ts
38 | { path: '/intro/expansions', timeoutMs: 10_000, label: 'intro: expansions' },
39 | { path: '/intro/tools', timeoutMs: 10_000, label: 'intro: tools' },
40 | { path: '/episodes/age-of-dwarves', timeoutMs: 10_000, label: 'episode: age of dwarves' },
41 | { path: '/about/early-access', timeoutMs: 10_000, label: 'about: early access' },
42 | { path: '/about/early-access/progress', timeoutMs: 10_000, label: 'about: early access progress' },
43 | { path: '/progress', timeoutMs: 10_000, label: 'progress report' },
44 | { path: '/about/crowdfund', timeoutMs: 10_000, label: 'about: crowdfund' },
45 | { path: '/about/team', timeoutMs: 10_000, label: 'about: team' },
46 | { path: '/encyclopedia', timeoutMs: 10_000, label: 'encyclopedia index' },
47 | { path: '/encyclopedia/terrain', timeoutMs: 10_000, label: 'encyclopedia: terrain topic' },
48 | { path: '/map/terrain', timeoutMs: 10_000, label: 'map: terrain' },
49 | { path: '/map/resources', timeoutMs: 10_000, label: 'map: resources' },
50 | { path: '/map/map-types', timeoutMs: 10_000, label: 'map: map types' },
51 | { path: '/economy/resources', timeoutMs: 10_000, label: 'economy: resources' },
52 | { path: '/climate', timeoutMs: 15_000, label: 'climate: overview' },
53 | { path: '/climate/weather', timeoutMs: 15_000, label: 'climate: weather' },
54 | { path: '/climate/terrain', timeoutMs: 15_000, label: 'climate: terrain evolution' },
55 | { path: '/climate/survival', timeoutMs: 15_000, label: 'climate: survival' },
56 | { path: '/climate/ecosystem', timeoutMs: 15_000, label: 'climate: ecosystem' },
57 | { path: '/climate/ecosystem/biomes', timeoutMs: 15_000, label: 'ecosystem: biomes' },
58 | { path: '/climate/ecosystem/flora', timeoutMs: 15_000, label: 'ecosystem: flora' },
59 | { path: '/climate/ecosystem/fauna', timeoutMs: 15_000, label: 'ecosystem: fauna' },
60 | { path: '/climate/ecosystem/connections', timeoutMs: 15_000, label: 'ecosystem: connections' },
61 | { path: '/climate/ecosystem/food-web', timeoutMs: 15_000, label: 'ecosystem: food web' },
62 | { path: '/climate/ecosystem/populations', timeoutMs: 15_000, label: 'ecosystem: populations' },
63 | { path: '/climate/ecosystem/lairs', timeoutMs: 15_000, label: 'ecosystem: lairs' },
64 | { path: '/climate/ecosystem/stratigraphy', timeoutMs: 15_000, label: 'ecosystem: stratigraphy' },
65 | { path: '/climate/ecosystem/evolution', timeoutMs: 15_000, label: 'ecosystem: evolution' },
66 | { path: '/empire/races', timeoutMs: 10_000, label: 'empire: races' },
67 | { path: '/empire/personality', timeoutMs: 10_000, label: 'empire: personality' },
68 | { path: '/empire/government', timeoutMs: 10_000, label: 'empire: government' },
69 | { path: '/empire/eras', timeoutMs: 10_000, label: 'empire: eras' },
70 | { path: '/empire/victory', timeoutMs: 10_000, label: 'empire: victory' },
71 | { path: '/research/tech-tree', timeoutMs: 10_000, label: 'research: tech tree' },
72 | { path: '/research/culture-tree', timeoutMs: 10_000, label: 'research: culture tree' },
73 | { path: '/military/units', timeoutMs: 10_000, label: 'military: units' },
74 | { path: '/military/combat', timeoutMs: 10_000, label: 'military: combat' },
75 | { path: '/military/keywords', timeoutMs: 10_000, label: 'military: keywords' },
76 | { path: '/military/promotions', timeoutMs: 10_000, label: 'military: promotions' },
77 | { path: '/military/items', timeoutMs: 10_000, label: 'military: items' },
78 | { path: '/buildings/buildings', timeoutMs: 10_000, label: 'buildings: buildings' },
79 | { path: '/buildings/improvements', timeoutMs: 10_000, label: 'buildings: improvements' },
80 | { path: '/buildings/wonders', timeoutMs: 10_000, label: 'buildings: wonders' },
81 | { path: '/buildings/communications', timeoutMs: 10_000, label: 'buildings: communications' },
82 | { path: '/playing/lenses', timeoutMs: 10_000, label: 'playing: lenses' },
83 | { path: '/dev/sprites', timeoutMs: 10_000, label: 'dev: sprites' },
84 | ] as const satisfies readonly RouteSpec[]
85 |
86 | // Routes that trigger WASM compile + world generation. Keep the budget
87 | // generous but bounded so a hung route fails loudly instead of stalling.
88 | const SLOW_ROUTES = [
89 | { path: '/climate/simulation', timeoutMs: 150_000, label: 'climate: simulation' },
90 | { path: '/worlds/khazad-prime', timeoutMs: 150_000, label: 'worlds: khazad-prime' },
91 | ] as const satisfies readonly RouteSpec[]
92 |
93 | // Non-error console noise we deliberately ignore. React-dev-tools and a few
94 | // dev-only warnings surface at `error` level but are not runtime failures.
95 | const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
96 | /Download the React DevTools/,
97 | ]
98 |
99 | interface ConsoleErrorCapture {
100 | readonly pageErrors: string[]
101 | readonly consoleErrors: string[]
102 | }
103 |
104 | function attachErrorCapture(page: Page): ConsoleErrorCapture {
105 | const pageErrors: string[] = []
106 | const consoleErrors: string[] = []
107 |
108 | page.on('pageerror', (err: Error) => {
109 | pageErrors.push(`${err.name}: ${err.message}`)
110 | })
111 |
112 | page.on('console', (msg: ConsoleMessage) => {
113 | if (msg.type() !== 'error') return
114 | const text = msg.text()
115 | if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
116 | consoleErrors.push(text)
117 | })
118 |
119 | return { pageErrors, consoleErrors }
120 | }
121 |
122 | function assertClean(spec: RouteSpec, cap: ConsoleErrorCapture): void {
123 | const label = `${spec.path} (${spec.label})`
124 | const msgs = [
125 | ...cap.pageErrors.map((e) => `[pageerror] ${e}`),
126 | ...cap.consoleErrors.map((e) => `[console.error] ${e}`),
127 | ]
128 | expect(msgs, `${label} — runtime errors:\n${msgs.join('\n')}`).toHaveLength(0)
129 | }
130 |
131 | test.describe('route coverage', () => {
132 | test.describe.configure({ mode: 'parallel' })
133 |
134 | for (const spec of FAST_ROUTES) {
135 | test(spec.label, async ({ page }) => {
136 | const cap = attachErrorCapture(page)
137 | const url = `${spec.path}?skip=welcome`
> 138 | await page.goto(url, { waitUntil: 'networkidle', timeout: spec.timeoutMs })
| ^ TimeoutError: page.goto: Timeout 10000ms exceeded.
139 | // Wait an extra tick for any deferred suspense boundary to resolve.
140 | await page.waitForTimeout(200)
141 | assertClean(spec, cap)
142 | })
143 | }
144 |
145 | for (const spec of SLOW_ROUTES) {
146 | test(spec.label, async ({ page }) => {
147 | const cap = attachErrorCapture(page)
148 | const url = `${spec.path}?skip=welcome`
149 | await page.goto(url, { waitUntil: 'domcontentloaded', timeout: spec.timeoutMs })
150 | // Slow routes: wait for the canvas OR the route's page-heading to appear.
151 | // Either proves the route mounted; we don't require full sim completion.
152 | await Promise.race([
153 | page.locator('canvas').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
154 | page.locator('h1, h2').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
155 | ])
156 | assertClean(spec, cap)
157 | })
158 | }
159 |
160 | test('unknown route redirects to home without error', async ({ page }) => {
161 | const cap = attachErrorCapture(page)
162 | await page.goto('/this-route-does-not-exist?skip=welcome', {
163 | waitUntil: 'networkidle',
164 | timeout: 10_000,
165 | })
166 | // Router's fallback <Navigate to="/"> should land us at the home URL.
167 | expect(new URL(page.url()).pathname, 'fallback redirect target').toBe('/')
168 | assertClean(
169 | { path: '/this-route-does-not-exist', timeoutMs: 0, label: 'fallback redirect' },
170 | cap,
171 | )
172 | })
173 | })
174 |
```

View file

@ -1,162 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> intro: expansions
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: page.goto: Timeout 10000ms exceeded.
Call log:
- navigating to "http://localhost:5802/intro/expansions?skip=welcome", waiting until "networkidle"
```
# Test source
```ts
38 | { path: '/intro/expansions', timeoutMs: 10_000, label: 'intro: expansions' },
39 | { path: '/intro/tools', timeoutMs: 10_000, label: 'intro: tools' },
40 | { path: '/episodes/age-of-dwarves', timeoutMs: 10_000, label: 'episode: age of dwarves' },
41 | { path: '/about/early-access', timeoutMs: 10_000, label: 'about: early access' },
42 | { path: '/about/early-access/progress', timeoutMs: 10_000, label: 'about: early access progress' },
43 | { path: '/progress', timeoutMs: 10_000, label: 'progress report' },
44 | { path: '/about/crowdfund', timeoutMs: 10_000, label: 'about: crowdfund' },
45 | { path: '/about/team', timeoutMs: 10_000, label: 'about: team' },
46 | { path: '/encyclopedia', timeoutMs: 10_000, label: 'encyclopedia index' },
47 | { path: '/encyclopedia/terrain', timeoutMs: 10_000, label: 'encyclopedia: terrain topic' },
48 | { path: '/map/terrain', timeoutMs: 10_000, label: 'map: terrain' },
49 | { path: '/map/resources', timeoutMs: 10_000, label: 'map: resources' },
50 | { path: '/map/map-types', timeoutMs: 10_000, label: 'map: map types' },
51 | { path: '/economy/resources', timeoutMs: 10_000, label: 'economy: resources' },
52 | { path: '/climate', timeoutMs: 15_000, label: 'climate: overview' },
53 | { path: '/climate/weather', timeoutMs: 15_000, label: 'climate: weather' },
54 | { path: '/climate/terrain', timeoutMs: 15_000, label: 'climate: terrain evolution' },
55 | { path: '/climate/survival', timeoutMs: 15_000, label: 'climate: survival' },
56 | { path: '/climate/ecosystem', timeoutMs: 15_000, label: 'climate: ecosystem' },
57 | { path: '/climate/ecosystem/biomes', timeoutMs: 15_000, label: 'ecosystem: biomes' },
58 | { path: '/climate/ecosystem/flora', timeoutMs: 15_000, label: 'ecosystem: flora' },
59 | { path: '/climate/ecosystem/fauna', timeoutMs: 15_000, label: 'ecosystem: fauna' },
60 | { path: '/climate/ecosystem/connections', timeoutMs: 15_000, label: 'ecosystem: connections' },
61 | { path: '/climate/ecosystem/food-web', timeoutMs: 15_000, label: 'ecosystem: food web' },
62 | { path: '/climate/ecosystem/populations', timeoutMs: 15_000, label: 'ecosystem: populations' },
63 | { path: '/climate/ecosystem/lairs', timeoutMs: 15_000, label: 'ecosystem: lairs' },
64 | { path: '/climate/ecosystem/stratigraphy', timeoutMs: 15_000, label: 'ecosystem: stratigraphy' },
65 | { path: '/climate/ecosystem/evolution', timeoutMs: 15_000, label: 'ecosystem: evolution' },
66 | { path: '/empire/races', timeoutMs: 10_000, label: 'empire: races' },
67 | { path: '/empire/personality', timeoutMs: 10_000, label: 'empire: personality' },
68 | { path: '/empire/government', timeoutMs: 10_000, label: 'empire: government' },
69 | { path: '/empire/eras', timeoutMs: 10_000, label: 'empire: eras' },
70 | { path: '/empire/victory', timeoutMs: 10_000, label: 'empire: victory' },
71 | { path: '/research/tech-tree', timeoutMs: 10_000, label: 'research: tech tree' },
72 | { path: '/research/culture-tree', timeoutMs: 10_000, label: 'research: culture tree' },
73 | { path: '/military/units', timeoutMs: 10_000, label: 'military: units' },
74 | { path: '/military/combat', timeoutMs: 10_000, label: 'military: combat' },
75 | { path: '/military/keywords', timeoutMs: 10_000, label: 'military: keywords' },
76 | { path: '/military/promotions', timeoutMs: 10_000, label: 'military: promotions' },
77 | { path: '/military/items', timeoutMs: 10_000, label: 'military: items' },
78 | { path: '/buildings/buildings', timeoutMs: 10_000, label: 'buildings: buildings' },
79 | { path: '/buildings/improvements', timeoutMs: 10_000, label: 'buildings: improvements' },
80 | { path: '/buildings/wonders', timeoutMs: 10_000, label: 'buildings: wonders' },
81 | { path: '/buildings/communications', timeoutMs: 10_000, label: 'buildings: communications' },
82 | { path: '/playing/lenses', timeoutMs: 10_000, label: 'playing: lenses' },
83 | { path: '/dev/sprites', timeoutMs: 10_000, label: 'dev: sprites' },
84 | ] as const satisfies readonly RouteSpec[]
85 |
86 | // Routes that trigger WASM compile + world generation. Keep the budget
87 | // generous but bounded so a hung route fails loudly instead of stalling.
88 | const SLOW_ROUTES = [
89 | { path: '/climate/simulation', timeoutMs: 150_000, label: 'climate: simulation' },
90 | { path: '/worlds/khazad-prime', timeoutMs: 150_000, label: 'worlds: khazad-prime' },
91 | ] as const satisfies readonly RouteSpec[]
92 |
93 | // Non-error console noise we deliberately ignore. React-dev-tools and a few
94 | // dev-only warnings surface at `error` level but are not runtime failures.
95 | const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
96 | /Download the React DevTools/,
97 | ]
98 |
99 | interface ConsoleErrorCapture {
100 | readonly pageErrors: string[]
101 | readonly consoleErrors: string[]
102 | }
103 |
104 | function attachErrorCapture(page: Page): ConsoleErrorCapture {
105 | const pageErrors: string[] = []
106 | const consoleErrors: string[] = []
107 |
108 | page.on('pageerror', (err: Error) => {
109 | pageErrors.push(`${err.name}: ${err.message}`)
110 | })
111 |
112 | page.on('console', (msg: ConsoleMessage) => {
113 | if (msg.type() !== 'error') return
114 | const text = msg.text()
115 | if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
116 | consoleErrors.push(text)
117 | })
118 |
119 | return { pageErrors, consoleErrors }
120 | }
121 |
122 | function assertClean(spec: RouteSpec, cap: ConsoleErrorCapture): void {
123 | const label = `${spec.path} (${spec.label})`
124 | const msgs = [
125 | ...cap.pageErrors.map((e) => `[pageerror] ${e}`),
126 | ...cap.consoleErrors.map((e) => `[console.error] ${e}`),
127 | ]
128 | expect(msgs, `${label} — runtime errors:\n${msgs.join('\n')}`).toHaveLength(0)
129 | }
130 |
131 | test.describe('route coverage', () => {
132 | test.describe.configure({ mode: 'parallel' })
133 |
134 | for (const spec of FAST_ROUTES) {
135 | test(spec.label, async ({ page }) => {
136 | const cap = attachErrorCapture(page)
137 | const url = `${spec.path}?skip=welcome`
> 138 | await page.goto(url, { waitUntil: 'networkidle', timeout: spec.timeoutMs })
| ^ TimeoutError: page.goto: Timeout 10000ms exceeded.
139 | // Wait an extra tick for any deferred suspense boundary to resolve.
140 | await page.waitForTimeout(200)
141 | assertClean(spec, cap)
142 | })
143 | }
144 |
145 | for (const spec of SLOW_ROUTES) {
146 | test(spec.label, async ({ page }) => {
147 | const cap = attachErrorCapture(page)
148 | const url = `${spec.path}?skip=welcome`
149 | await page.goto(url, { waitUntil: 'domcontentloaded', timeout: spec.timeoutMs })
150 | // Slow routes: wait for the canvas OR the route's page-heading to appear.
151 | // Either proves the route mounted; we don't require full sim completion.
152 | await Promise.race([
153 | page.locator('canvas').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
154 | page.locator('h1, h2').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
155 | ])
156 | assertClean(spec, cap)
157 | })
158 | }
159 |
160 | test('unknown route redirects to home without error', async ({ page }) => {
161 | const cap = attachErrorCapture(page)
162 | await page.goto('/this-route-does-not-exist?skip=welcome', {
163 | waitUntil: 'networkidle',
164 | timeout: 10_000,
165 | })
166 | // Router's fallback <Navigate to="/"> should land us at the home URL.
167 | expect(new URL(page.url()).pathname, 'fallback redirect target').toBe('/')
168 | assertClean(
169 | { path: '/this-route-does-not-exist', timeoutMs: 0, label: 'fallback redirect' },
170 | cap,
171 | )
172 | })
173 | })
174 |
```

View file

@ -1,162 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> intro: full game
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: page.goto: Timeout 10000ms exceeded.
Call log:
- navigating to "http://localhost:5802/intro/full-game?skip=welcome", waiting until "networkidle"
```
# Test source
```ts
38 | { path: '/intro/expansions', timeoutMs: 10_000, label: 'intro: expansions' },
39 | { path: '/intro/tools', timeoutMs: 10_000, label: 'intro: tools' },
40 | { path: '/episodes/age-of-dwarves', timeoutMs: 10_000, label: 'episode: age of dwarves' },
41 | { path: '/about/early-access', timeoutMs: 10_000, label: 'about: early access' },
42 | { path: '/about/early-access/progress', timeoutMs: 10_000, label: 'about: early access progress' },
43 | { path: '/progress', timeoutMs: 10_000, label: 'progress report' },
44 | { path: '/about/crowdfund', timeoutMs: 10_000, label: 'about: crowdfund' },
45 | { path: '/about/team', timeoutMs: 10_000, label: 'about: team' },
46 | { path: '/encyclopedia', timeoutMs: 10_000, label: 'encyclopedia index' },
47 | { path: '/encyclopedia/terrain', timeoutMs: 10_000, label: 'encyclopedia: terrain topic' },
48 | { path: '/map/terrain', timeoutMs: 10_000, label: 'map: terrain' },
49 | { path: '/map/resources', timeoutMs: 10_000, label: 'map: resources' },
50 | { path: '/map/map-types', timeoutMs: 10_000, label: 'map: map types' },
51 | { path: '/economy/resources', timeoutMs: 10_000, label: 'economy: resources' },
52 | { path: '/climate', timeoutMs: 15_000, label: 'climate: overview' },
53 | { path: '/climate/weather', timeoutMs: 15_000, label: 'climate: weather' },
54 | { path: '/climate/terrain', timeoutMs: 15_000, label: 'climate: terrain evolution' },
55 | { path: '/climate/survival', timeoutMs: 15_000, label: 'climate: survival' },
56 | { path: '/climate/ecosystem', timeoutMs: 15_000, label: 'climate: ecosystem' },
57 | { path: '/climate/ecosystem/biomes', timeoutMs: 15_000, label: 'ecosystem: biomes' },
58 | { path: '/climate/ecosystem/flora', timeoutMs: 15_000, label: 'ecosystem: flora' },
59 | { path: '/climate/ecosystem/fauna', timeoutMs: 15_000, label: 'ecosystem: fauna' },
60 | { path: '/climate/ecosystem/connections', timeoutMs: 15_000, label: 'ecosystem: connections' },
61 | { path: '/climate/ecosystem/food-web', timeoutMs: 15_000, label: 'ecosystem: food web' },
62 | { path: '/climate/ecosystem/populations', timeoutMs: 15_000, label: 'ecosystem: populations' },
63 | { path: '/climate/ecosystem/lairs', timeoutMs: 15_000, label: 'ecosystem: lairs' },
64 | { path: '/climate/ecosystem/stratigraphy', timeoutMs: 15_000, label: 'ecosystem: stratigraphy' },
65 | { path: '/climate/ecosystem/evolution', timeoutMs: 15_000, label: 'ecosystem: evolution' },
66 | { path: '/empire/races', timeoutMs: 10_000, label: 'empire: races' },
67 | { path: '/empire/personality', timeoutMs: 10_000, label: 'empire: personality' },
68 | { path: '/empire/government', timeoutMs: 10_000, label: 'empire: government' },
69 | { path: '/empire/eras', timeoutMs: 10_000, label: 'empire: eras' },
70 | { path: '/empire/victory', timeoutMs: 10_000, label: 'empire: victory' },
71 | { path: '/research/tech-tree', timeoutMs: 10_000, label: 'research: tech tree' },
72 | { path: '/research/culture-tree', timeoutMs: 10_000, label: 'research: culture tree' },
73 | { path: '/military/units', timeoutMs: 10_000, label: 'military: units' },
74 | { path: '/military/combat', timeoutMs: 10_000, label: 'military: combat' },
75 | { path: '/military/keywords', timeoutMs: 10_000, label: 'military: keywords' },
76 | { path: '/military/promotions', timeoutMs: 10_000, label: 'military: promotions' },
77 | { path: '/military/items', timeoutMs: 10_000, label: 'military: items' },
78 | { path: '/buildings/buildings', timeoutMs: 10_000, label: 'buildings: buildings' },
79 | { path: '/buildings/improvements', timeoutMs: 10_000, label: 'buildings: improvements' },
80 | { path: '/buildings/wonders', timeoutMs: 10_000, label: 'buildings: wonders' },
81 | { path: '/buildings/communications', timeoutMs: 10_000, label: 'buildings: communications' },
82 | { path: '/playing/lenses', timeoutMs: 10_000, label: 'playing: lenses' },
83 | { path: '/dev/sprites', timeoutMs: 10_000, label: 'dev: sprites' },
84 | ] as const satisfies readonly RouteSpec[]
85 |
86 | // Routes that trigger WASM compile + world generation. Keep the budget
87 | // generous but bounded so a hung route fails loudly instead of stalling.
88 | const SLOW_ROUTES = [
89 | { path: '/climate/simulation', timeoutMs: 150_000, label: 'climate: simulation' },
90 | { path: '/worlds/khazad-prime', timeoutMs: 150_000, label: 'worlds: khazad-prime' },
91 | ] as const satisfies readonly RouteSpec[]
92 |
93 | // Non-error console noise we deliberately ignore. React-dev-tools and a few
94 | // dev-only warnings surface at `error` level but are not runtime failures.
95 | const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
96 | /Download the React DevTools/,
97 | ]
98 |
99 | interface ConsoleErrorCapture {
100 | readonly pageErrors: string[]
101 | readonly consoleErrors: string[]
102 | }
103 |
104 | function attachErrorCapture(page: Page): ConsoleErrorCapture {
105 | const pageErrors: string[] = []
106 | const consoleErrors: string[] = []
107 |
108 | page.on('pageerror', (err: Error) => {
109 | pageErrors.push(`${err.name}: ${err.message}`)
110 | })
111 |
112 | page.on('console', (msg: ConsoleMessage) => {
113 | if (msg.type() !== 'error') return
114 | const text = msg.text()
115 | if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
116 | consoleErrors.push(text)
117 | })
118 |
119 | return { pageErrors, consoleErrors }
120 | }
121 |
122 | function assertClean(spec: RouteSpec, cap: ConsoleErrorCapture): void {
123 | const label = `${spec.path} (${spec.label})`
124 | const msgs = [
125 | ...cap.pageErrors.map((e) => `[pageerror] ${e}`),
126 | ...cap.consoleErrors.map((e) => `[console.error] ${e}`),
127 | ]
128 | expect(msgs, `${label} — runtime errors:\n${msgs.join('\n')}`).toHaveLength(0)
129 | }
130 |
131 | test.describe('route coverage', () => {
132 | test.describe.configure({ mode: 'parallel' })
133 |
134 | for (const spec of FAST_ROUTES) {
135 | test(spec.label, async ({ page }) => {
136 | const cap = attachErrorCapture(page)
137 | const url = `${spec.path}?skip=welcome`
> 138 | await page.goto(url, { waitUntil: 'networkidle', timeout: spec.timeoutMs })
| ^ TimeoutError: page.goto: Timeout 10000ms exceeded.
139 | // Wait an extra tick for any deferred suspense boundary to resolve.
140 | await page.waitForTimeout(200)
141 | assertClean(spec, cap)
142 | })
143 | }
144 |
145 | for (const spec of SLOW_ROUTES) {
146 | test(spec.label, async ({ page }) => {
147 | const cap = attachErrorCapture(page)
148 | const url = `${spec.path}?skip=welcome`
149 | await page.goto(url, { waitUntil: 'domcontentloaded', timeout: spec.timeoutMs })
150 | // Slow routes: wait for the canvas OR the route's page-heading to appear.
151 | // Either proves the route mounted; we don't require full sim completion.
152 | await Promise.race([
153 | page.locator('canvas').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
154 | page.locator('h1, h2').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
155 | ])
156 | assertClean(spec, cap)
157 | })
158 | }
159 |
160 | test('unknown route redirects to home without error', async ({ page }) => {
161 | const cap = attachErrorCapture(page)
162 | await page.goto('/this-route-does-not-exist?skip=welcome', {
163 | waitUntil: 'networkidle',
164 | timeout: 10_000,
165 | })
166 | // Router's fallback <Navigate to="/"> should land us at the home URL.
167 | expect(new URL(page.url()).pathname, 'fallback redirect target').toBe('/')
168 | assertClean(
169 | { path: '/this-route-does-not-exist', timeoutMs: 0, label: 'fallback redirect' },
170 | cap,
171 | )
172 | })
173 | })
174 |
```

View file

@ -1,162 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> intro: tools
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: page.goto: Timeout 10000ms exceeded.
Call log:
- navigating to "http://localhost:5802/intro/tools?skip=welcome", waiting until "networkidle"
```
# Test source
```ts
38 | { path: '/intro/expansions', timeoutMs: 10_000, label: 'intro: expansions' },
39 | { path: '/intro/tools', timeoutMs: 10_000, label: 'intro: tools' },
40 | { path: '/episodes/age-of-dwarves', timeoutMs: 10_000, label: 'episode: age of dwarves' },
41 | { path: '/about/early-access', timeoutMs: 10_000, label: 'about: early access' },
42 | { path: '/about/early-access/progress', timeoutMs: 10_000, label: 'about: early access progress' },
43 | { path: '/progress', timeoutMs: 10_000, label: 'progress report' },
44 | { path: '/about/crowdfund', timeoutMs: 10_000, label: 'about: crowdfund' },
45 | { path: '/about/team', timeoutMs: 10_000, label: 'about: team' },
46 | { path: '/encyclopedia', timeoutMs: 10_000, label: 'encyclopedia index' },
47 | { path: '/encyclopedia/terrain', timeoutMs: 10_000, label: 'encyclopedia: terrain topic' },
48 | { path: '/map/terrain', timeoutMs: 10_000, label: 'map: terrain' },
49 | { path: '/map/resources', timeoutMs: 10_000, label: 'map: resources' },
50 | { path: '/map/map-types', timeoutMs: 10_000, label: 'map: map types' },
51 | { path: '/economy/resources', timeoutMs: 10_000, label: 'economy: resources' },
52 | { path: '/climate', timeoutMs: 15_000, label: 'climate: overview' },
53 | { path: '/climate/weather', timeoutMs: 15_000, label: 'climate: weather' },
54 | { path: '/climate/terrain', timeoutMs: 15_000, label: 'climate: terrain evolution' },
55 | { path: '/climate/survival', timeoutMs: 15_000, label: 'climate: survival' },
56 | { path: '/climate/ecosystem', timeoutMs: 15_000, label: 'climate: ecosystem' },
57 | { path: '/climate/ecosystem/biomes', timeoutMs: 15_000, label: 'ecosystem: biomes' },
58 | { path: '/climate/ecosystem/flora', timeoutMs: 15_000, label: 'ecosystem: flora' },
59 | { path: '/climate/ecosystem/fauna', timeoutMs: 15_000, label: 'ecosystem: fauna' },
60 | { path: '/climate/ecosystem/connections', timeoutMs: 15_000, label: 'ecosystem: connections' },
61 | { path: '/climate/ecosystem/food-web', timeoutMs: 15_000, label: 'ecosystem: food web' },
62 | { path: '/climate/ecosystem/populations', timeoutMs: 15_000, label: 'ecosystem: populations' },
63 | { path: '/climate/ecosystem/lairs', timeoutMs: 15_000, label: 'ecosystem: lairs' },
64 | { path: '/climate/ecosystem/stratigraphy', timeoutMs: 15_000, label: 'ecosystem: stratigraphy' },
65 | { path: '/climate/ecosystem/evolution', timeoutMs: 15_000, label: 'ecosystem: evolution' },
66 | { path: '/empire/races', timeoutMs: 10_000, label: 'empire: races' },
67 | { path: '/empire/personality', timeoutMs: 10_000, label: 'empire: personality' },
68 | { path: '/empire/government', timeoutMs: 10_000, label: 'empire: government' },
69 | { path: '/empire/eras', timeoutMs: 10_000, label: 'empire: eras' },
70 | { path: '/empire/victory', timeoutMs: 10_000, label: 'empire: victory' },
71 | { path: '/research/tech-tree', timeoutMs: 10_000, label: 'research: tech tree' },
72 | { path: '/research/culture-tree', timeoutMs: 10_000, label: 'research: culture tree' },
73 | { path: '/military/units', timeoutMs: 10_000, label: 'military: units' },
74 | { path: '/military/combat', timeoutMs: 10_000, label: 'military: combat' },
75 | { path: '/military/keywords', timeoutMs: 10_000, label: 'military: keywords' },
76 | { path: '/military/promotions', timeoutMs: 10_000, label: 'military: promotions' },
77 | { path: '/military/items', timeoutMs: 10_000, label: 'military: items' },
78 | { path: '/buildings/buildings', timeoutMs: 10_000, label: 'buildings: buildings' },
79 | { path: '/buildings/improvements', timeoutMs: 10_000, label: 'buildings: improvements' },
80 | { path: '/buildings/wonders', timeoutMs: 10_000, label: 'buildings: wonders' },
81 | { path: '/buildings/communications', timeoutMs: 10_000, label: 'buildings: communications' },
82 | { path: '/playing/lenses', timeoutMs: 10_000, label: 'playing: lenses' },
83 | { path: '/dev/sprites', timeoutMs: 10_000, label: 'dev: sprites' },
84 | ] as const satisfies readonly RouteSpec[]
85 |
86 | // Routes that trigger WASM compile + world generation. Keep the budget
87 | // generous but bounded so a hung route fails loudly instead of stalling.
88 | const SLOW_ROUTES = [
89 | { path: '/climate/simulation', timeoutMs: 150_000, label: 'climate: simulation' },
90 | { path: '/worlds/khazad-prime', timeoutMs: 150_000, label: 'worlds: khazad-prime' },
91 | ] as const satisfies readonly RouteSpec[]
92 |
93 | // Non-error console noise we deliberately ignore. React-dev-tools and a few
94 | // dev-only warnings surface at `error` level but are not runtime failures.
95 | const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
96 | /Download the React DevTools/,
97 | ]
98 |
99 | interface ConsoleErrorCapture {
100 | readonly pageErrors: string[]
101 | readonly consoleErrors: string[]
102 | }
103 |
104 | function attachErrorCapture(page: Page): ConsoleErrorCapture {
105 | const pageErrors: string[] = []
106 | const consoleErrors: string[] = []
107 |
108 | page.on('pageerror', (err: Error) => {
109 | pageErrors.push(`${err.name}: ${err.message}`)
110 | })
111 |
112 | page.on('console', (msg: ConsoleMessage) => {
113 | if (msg.type() !== 'error') return
114 | const text = msg.text()
115 | if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
116 | consoleErrors.push(text)
117 | })
118 |
119 | return { pageErrors, consoleErrors }
120 | }
121 |
122 | function assertClean(spec: RouteSpec, cap: ConsoleErrorCapture): void {
123 | const label = `${spec.path} (${spec.label})`
124 | const msgs = [
125 | ...cap.pageErrors.map((e) => `[pageerror] ${e}`),
126 | ...cap.consoleErrors.map((e) => `[console.error] ${e}`),
127 | ]
128 | expect(msgs, `${label} — runtime errors:\n${msgs.join('\n')}`).toHaveLength(0)
129 | }
130 |
131 | test.describe('route coverage', () => {
132 | test.describe.configure({ mode: 'parallel' })
133 |
134 | for (const spec of FAST_ROUTES) {
135 | test(spec.label, async ({ page }) => {
136 | const cap = attachErrorCapture(page)
137 | const url = `${spec.path}?skip=welcome`
> 138 | await page.goto(url, { waitUntil: 'networkidle', timeout: spec.timeoutMs })
| ^ TimeoutError: page.goto: Timeout 10000ms exceeded.
139 | // Wait an extra tick for any deferred suspense boundary to resolve.
140 | await page.waitForTimeout(200)
141 | assertClean(spec, cap)
142 | })
143 | }
144 |
145 | for (const spec of SLOW_ROUTES) {
146 | test(spec.label, async ({ page }) => {
147 | const cap = attachErrorCapture(page)
148 | const url = `${spec.path}?skip=welcome`
149 | await page.goto(url, { waitUntil: 'domcontentloaded', timeout: spec.timeoutMs })
150 | // Slow routes: wait for the canvas OR the route's page-heading to appear.
151 | // Either proves the route mounted; we don't require full sim completion.
152 | await Promise.race([
153 | page.locator('canvas').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
154 | page.locator('h1, h2').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
155 | ])
156 | assertClean(spec, cap)
157 | })
158 | }
159 |
160 | test('unknown route redirects to home without error', async ({ page }) => {
161 | const cap = attachErrorCapture(page)
162 | await page.goto('/this-route-does-not-exist?skip=welcome', {
163 | waitUntil: 'networkidle',
164 | timeout: 10_000,
165 | })
166 | // Router's fallback <Navigate to="/"> should land us at the home URL.
167 | expect(new URL(page.url()).pathname, 'fallback redirect target').toBe('/')
168 | assertClean(
169 | { path: '/this-route-does-not-exist', timeoutMs: 0, label: 'fallback redirect' },
170 | cap,
171 | )
172 | })
173 | })
174 |
```

View file

@ -1,20 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: all-routes.spec.ts >> route coverage >> progress report
- Location: e2e/all-routes.spec.ts:135:5
# Error details
```
TimeoutError: browserType.launch: Timeout 180000ms exceeded.
Call log:
- <launching> /Users/natalie/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/var/folders/pz/c1s02dlj4k3cml6_tzxwg2d40000gn/T/playwright_chromiumdev_profile-U51emu --remote-debugging-pipe --no-startup-window
- <launched> pid=73869
```

View file

@ -368,6 +368,21 @@ func _on_close_pressed() -> void:
EventBus.overlay_closed.emit("city_screen")
## p0-33: ESC closes the city screen when it is the topmost panel. The world
## map listens to its own ESC for movement-mode + tech tree + chronicle; main.gd
## listens for the in-game menu fallback. We only consume the event when the
## screen is currently visible so the layered fallback chain stays intact.
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if not (event is InputEventKey and event.pressed and not (event as InputEventKey).echo):
return
var key: InputEventKey = event as InputEventKey
if key.keycode == KEY_ESCAPE:
_on_close_pressed()
get_viewport().set_input_as_handled()
func _on_add_queue_pressed() -> void:
if not _city is CityScript:
return

View file

@ -138,14 +138,24 @@ func _notification(what: int) -> void:
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
# If an overlay is open, let it handle ESC itself
if not _overlay_stack.is_empty():
return
# Only open in-game menu if we're in the world map (not menus)
if _current_scene != null and _current_scene.name == "WorldMap":
get_viewport().set_input_as_handled()
push_overlay("res://engine/scenes/ui/ingame_menu.tscn")
if not (event is InputEventKey and event.pressed and not (event as InputEventKey).echo):
return
var key: InputEventKey = event as InputEventKey
match key.keycode:
KEY_ESCAPE:
# If an overlay is open, let it handle ESC itself
if not _overlay_stack.is_empty():
return
# Only open in-game menu if we're in the world map (not menus)
if _current_scene != null and _current_scene.name == "WorldMap":
get_viewport().set_input_as_handled()
push_overlay("res://engine/scenes/ui/ingame_menu.tscn")
KEY_F10:
## p0-33: F10 always opens the in-game menu, regardless of overlay
## stack state, so the player has a guaranteed escape hatch.
if _current_scene != null and _current_scene.name == "WorldMap":
get_viewport().set_input_as_handled()
push_overlay("res://engine/scenes/ui/ingame_menu.tscn")
func _is_combat_popup(scene_path: String) -> bool:

View file

@ -267,12 +267,18 @@ func _start_game() -> void:
## Persistent HUD overlays mounted once per world-map boot. The hotkey sheet
## is always present so `ui_help` (F1 / ?) works in-game; the first-run
## tutorial is gated on `TutorialOverlay.should_show_on_first_run()`.
## is always present so `ui_help` (F1 / ?) works in-game.
##
## p1-19: the first-run tutorial no longer auto-mounts. Players opt in via
## the Tutorial button on the world-map HUD top bar (visible turns 1..5).
func _mount_hud_overlays() -> void:
add_child(HotkeySheetScene.instantiate())
if TutorialOverlayScript.should_show_on_first_run():
add_child(TutorialOverlayScene.instantiate())
## p1-19: instantiate the tutorial overlay on demand. Defaults to Step 1
## per `tutorial_overlay.gd._current_step = 1`.
func _on_tutorial_requested() -> void:
add_child(TutorialOverlayScene.instantiate())
func _read_start_turn_from_setup() -> int:
@ -421,7 +427,7 @@ func _on_prologue_tribe_converged(
var tribe_unit: RefCounted = _units_helper.create_unit(
"dwarf_tribe", player_id, centroid
)
# Movement is kept at 1 so `_show_unit_panel`'s
# Movement is kept at 1 so unit_panel.tscn's
# `unit.can_found_city and unit.movement_remaining > 0` gate flips the
# Found City button visible. The tribe cannot actually move because
# hex-click remains gated during the prologue; the single MP simply
@ -483,17 +489,37 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed:
return
## p0-35 right-click confirm during movement mode.
if _movement_mode and mouse_event.button_index == MOUSE_BUTTON_RIGHT:
var click_axial: Vector2i = _screen_to_axial_at_mouse()
_confirm_movement(click_axial)
get_viewport().set_input_as_handled()
return
## p0-35 left-click cancels movement mode (returning to selection).
if _movement_mode and mouse_event.button_index == MOUSE_BUTTON_LEFT:
_exit_movement_mode()
get_viewport().set_input_as_handled()
return
## Legacy double-click city open path stays as a fallback. Single-click
## now opens the city directly via `_handle_hex_click` (p0-33).
if mouse_event.button_index != MOUSE_BUTTON_LEFT or not mouse_event.double_click:
return
var axial: Vector2i = _screen_to_axial_at_mouse()
get_viewport().set_input_as_handled()
_open_city_at_axial(axial)
func _screen_to_axial_at_mouse() -> Vector2i:
var bg_camera: Camera2D = _viewport_manager.get_background_camera()
var bg_viewport: SubViewport = _viewport_manager.get_background_viewport()
var mouse_screen: Vector2 = get_viewport().get_mouse_position()
var viewport_center: Vector2 = bg_viewport.get_visible_rect().size * 0.5
var offset: Vector2 = (mouse_screen - viewport_center) / bg_camera.zoom.x
var world_click: Vector2 = bg_camera.global_position + offset
var axial: Vector2i = HexUtilsScript.pixel_to_axial(world_click)
get_viewport().set_input_as_handled()
_on_city_tile_double_clicked(axial)
return HexUtilsScript.pixel_to_axial(world_click)
func _is_prologue_active() -> bool:
@ -517,8 +543,20 @@ func _handle_hotkeys(key_event: InputEventKey) -> bool:
_on_build_improvement_pressed()
get_viewport().set_input_as_handled()
return true
KEY_M:
## p0-35 enter movement mode when a unit is selected and has MP.
if _selected_unit != null and _selected_unit_has_movement():
_enter_movement_mode()
get_viewport().set_input_as_handled()
return true
KEY_ESCAPE:
if _chronicle_panel.visible:
## p0-35: ESC cancels movement mode FIRST (before bubbling to
## panels or the in-game menu owned by main.gd).
if _movement_mode:
_exit_movement_mode()
get_viewport().set_input_as_handled()
return true
if _chronicle_panel != null and _chronicle_panel.visible:
_chronicle_panel.hide()
EventBus.chronicle_closed.emit(GameState.current_player_index)
get_viewport().set_input_as_handled()
@ -530,6 +568,14 @@ func _handle_hotkeys(key_event: InputEventKey) -> bool:
return false
func _selected_unit_has_movement() -> bool:
if _selected_unit == null:
return false
if "movement_remaining" in _selected_unit:
return int(_selected_unit.get("movement_remaining")) > 0
return false
func _handle_hex_click(axial: Vector2i) -> void:
# p0-34: During prologue phases (turn -1 or 0) only End Turn is valid.
# Swallow hex clicks so no unit selection, bombard, or move path triggers.
@ -544,7 +590,10 @@ func _handle_hex_click(axial: Vector2i) -> void:
_deselect_unit()
return
# City bombard: resolve ranged combat against clicked enemy
# City bombard: resolve ranged combat against clicked enemy. Bombard mode
# is opt-in via a future right-click flow (p0-33 removed it from
# single-click). The state is preserved here for compatibility but no
# code path currently sets `_bombard_city = ...` from a click.
if _bombard_city != null:
var target_unit: RefCounted = _combat.get_enemy_at(axial, _bombard_city.owner)
if target_unit != null:
@ -565,18 +614,28 @@ func _handle_hex_click(axial: Vector2i) -> void:
_select_unit(unit_on_hex)
return
# Check if clicking own city — offer bombard
var player: RefCounted = GameState.get_current_player()
if player != null:
for city_ref: Variant in player.cities:
if city_ref is CityScript and city_ref.position == axial:
if not city_ref.has_bombarded:
_bombard_city = city_ref
return
# p0-33: single-click on own city opens the city screen (no bombard).
if _open_city_at_axial(axial):
return
_deselect_unit()
## p0-33: open the city at `axial` if any belongs to the current player.
## Returns true when a city screen was opened.
func _open_city_at_axial(axial: Vector2i) -> bool:
if _city_screen == null:
return false
var player: RefCounted = GameState.get_current_player()
if player == null:
return false
for city_ref: Variant in player.cities:
if city_ref is CityScript and city_ref.position == axial:
_city_screen.open_city(city_ref)
return true
return false
func _find_player_unit_at(axial: Vector2i) -> RefCounted:
var player: RefCounted = GameState.get_current_player()
if player == null:
@ -588,21 +647,24 @@ func _find_player_unit_at(axial: Vector2i) -> RefCounted:
func _select_unit(unit: RefCounted) -> void:
## Selecting a different unit cancels any in-flight movement mode so the
## preview overlay clears before the new range overlay paints.
if _movement_mode:
_exit_movement_mode()
_selected_unit = unit
_unit_renderer.set_selected(unit.id, true)
_show_unit_panel(unit)
_compute_movement_range(unit)
EventBus.unit_selected.emit(unit)
func _deselect_unit() -> void:
if _movement_mode:
_exit_movement_mode()
if _selected_unit != null:
_unit_renderer.set_selected(_selected_unit.id, false)
_selected_unit = null
_reachable_hexes = {}
_unit_renderer.clear_movement_range()
if not _arena_mode:
_hud.hide_unit_panel()
EventBus.unit_deselected.emit()
@ -656,23 +718,13 @@ func _move_unit_to(unit: RefCounted, target: Vector2i) -> void:
_sync_units()
if unit.movement_remaining > 0:
_compute_movement_range(unit)
_show_unit_panel(unit)
## Re-emit so the unit_panel.tscn refreshes its stats/buttons after
## the move (panel listens on EventBus.unit_selected for HP/MP/etc.).
EventBus.unit_selected.emit(unit)
else:
_deselect_unit()
func _show_unit_panel(unit: RefCounted) -> void:
var display_name: String = ThemeVocabulary.lookup(unit.type_id)
if unit.display_name != "" and unit.display_name != unit.type_id:
display_name = unit.display_name
var stats: String = (
"HP: %d/%d ATK: %d DEF: %d" % [unit.hp, unit.max_hp, unit.attack, unit.defense]
)
var can_found: bool = unit.can_found_city and unit.movement_remaining > 0
var can_build: bool = unit.can_build_improvements and unit.movement_remaining > 0
_hud.show_unit_panel(display_name, stats, can_found, can_build)
func _toggle_tech_tree() -> void:
if _tech_tree.visible:
_tech_tree.close()
@ -735,10 +787,51 @@ func _on_happiness_changed(player_index: int, value: int) -> void:
_hud.update_happiness(value)
## p1-18: village discovery toast + minimap ping. Reward dict carries `gold`
## plus optional `unit` / `tech` ids so the message names what was awarded.
## Village name resolves from the GameState NPC building when available; falls
## back to the vocab key `village_default_name` when the building is gone.
func _on_village_discovered(tile_pos: Vector2i, reward: Dictionary) -> void:
var gold_reward: int = reward.get("gold", 0)
if gold_reward > 0:
push_warning("Village discovered at %s — awarded %d gold" % [str(tile_pos), gold_reward])
var gold_reward: int = int(reward.get("gold", 0))
if gold_reward <= 0:
return
var village_name: String = _resolve_village_name(tile_pos, reward)
var unit_reward: String = str(reward.get("unit", ""))
var tech_reward: String = str(reward.get("tech", ""))
var msg: String = ""
if not unit_reward.is_empty():
var unit_label: String = ThemeVocabulary.lookup(unit_reward)
msg = ThemeVocabulary.lookup("fmt_village_reward_with_unit") % [
gold_reward, unit_label, village_name,
]
elif not tech_reward.is_empty():
var tech_label: String = ThemeVocabulary.lookup(tech_reward)
msg = ThemeVocabulary.lookup("fmt_village_reward_with_tech") % [
gold_reward, tech_label, village_name,
]
else:
msg = ThemeVocabulary.lookup("fmt_village_reward_gold") % [
gold_reward, village_name,
]
if not _arena_mode and _hud != null and _hud.has_method("show_notification"):
_hud.show_notification(msg)
if _minimap != null and _minimap.has_method("pulse_at"):
_minimap.pulse_at(tile_pos)
func _resolve_village_name(tile_pos: Vector2i, reward: Dictionary) -> String:
var named: String = str(reward.get("name", ""))
if not named.is_empty():
return named
if GameState.has_method("get_npc_building_at"):
var building: RefCounted = (
GameState.get_npc_building_at(tile_pos, "village") as RefCounted
)
if building != null and "display_name" in building:
var dn: String = str(building.get("display_name"))
if not dn.is_empty():
return dn
return ThemeVocabulary.lookup("village_default_name")
func _on_found_city_pressed() -> void:
@ -782,16 +875,6 @@ func _found_capital_from_tribe(tribe_unit: RefCounted) -> void:
_sync_units()
func _on_city_tile_double_clicked(pos: Vector2i) -> void:
var player: RefCounted = GameState.get_current_player()
if player == null:
return
for city_ref: RefCounted in player.cities:
if city_ref is CityScript and city_ref.position == pos:
_city_screen.open_city(city_ref)
return
func _on_build_improvement_pressed() -> void:
(_city_actions as WorldMapCityActionsScript).on_build_improvement_pressed(_selected_unit)
@ -832,3 +915,169 @@ func _on_combat_finished() -> void:
if player != null and game_map != null:
WorldMapVisionScript.recalculate_vision(player, game_map)
_update_fog(player, game_map)
# -- p0-35 movement-mode state machine -----------------------------------
## Enter the movement-mode preview state. The next right-click confirms a
## move along the previewed path; ESC / left-click cancel without moving.
func _enter_movement_mode() -> void:
if _selected_unit == null:
return
_movement_mode = true
EventBus.movement_mode_entered.emit(_selected_unit)
## Refresh the preview against the cursor's last known hex so the player
## sees a path immediately rather than waiting for the next hover tick.
if _last_hover_axial != Vector2i(-9999, -9999):
update_path_preview(_last_hover_axial)
## Exit movement mode without moving. Clears the path preview overlay.
func _exit_movement_mode() -> void:
if not _movement_mode:
return
_movement_mode = false
EventBus.movement_mode_exited.emit()
if _unit_renderer != null:
_unit_renderer.clear_path_preview()
## Public hook so world_map_hover.gd can push the hovered hex into the
## preview pipeline whenever movement mode is active.
func update_path_preview(hovered_axial: Vector2i) -> void:
_last_hover_axial = hovered_axial
if not _movement_mode or _selected_unit == null or _unit_renderer == null:
return
if hovered_axial == _selected_unit.position:
_unit_renderer.clear_path_preview()
return
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return
var scouted: Dictionary = _build_scouted_dict()
## Use a generous budget so multi-turn previews can be displayed; the
## turn count is computed below from the per-tile cost dictionary.
var movement_total: int = int(_selected_unit.get_movement()) if _selected_unit.has_method("get_movement") else 2
var preview_budget: int = maxi(movement_total * 8, 16)
var path: Array[Vector2i] = PathfinderScript.find_path_with_fog(
game_map,
_selected_unit.position,
hovered_axial,
preview_budget,
_selected_unit.unit_type,
scouted,
)
if path.is_empty():
_unit_renderer.clear_path_preview()
return
var turns: int = _estimate_turns_for_path(path, movement_total)
_unit_renderer.show_path_preview(_selected_unit.position, path, turns)
## Confirm a movement-mode move targeting `target`. Falls back to the
## existing `_move_unit_to` once a reachable hex is reached this turn;
## paths that exceed the current movement budget bank the unit at the
## furthest reachable hex along the path so the player can resume next turn.
func _confirm_movement(target: Vector2i) -> void:
if _selected_unit == null:
_exit_movement_mode()
return
if _reachable_hexes.has(target):
_exit_movement_mode()
_move_unit_to(_selected_unit, target)
return
## Walk the previewed path forward, picking the furthest reachable hex.
var furthest: Vector2i = _selected_unit.position
for axial: Vector2i in _path_preview_for_target(target):
if _reachable_hexes.has(axial):
furthest = axial
else:
break
if furthest != _selected_unit.position:
_exit_movement_mode()
_move_unit_to(_selected_unit, furthest)
else:
_exit_movement_mode()
func _path_preview_for_target(target: Vector2i) -> Array[Vector2i]:
var game_map: RefCounted = GameState.get_game_map()
if game_map == null or _selected_unit == null:
return [] as Array[Vector2i]
var scouted: Dictionary = _build_scouted_dict()
return PathfinderScript.find_path_with_fog(
game_map,
_selected_unit.position,
target,
9999,
_selected_unit.unit_type,
scouted,
)
func _build_scouted_dict() -> Dictionary:
## Build a {Vector2i -> bool} dict of scouted/visible tiles for the local
## player so PathfinderScript.find_path_with_fog can A* through them and
## hex_line through the rest. Empty when no game map is present.
var out: Dictionary = {}
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return out
var player: RefCounted = GameState.get_current_player()
if player == null:
return out
var pid: int = int(player.index)
for axial: Vector2i in game_map.tiles:
var tile: RefCounted = game_map.tiles[axial] as RefCounted
if tile == null:
continue
if tile.has_method("get_visibility"):
var vis: int = int(tile.get_visibility(pid))
out[axial] = vis >= 1
return out
func _estimate_turns_for_path(path: Array[Vector2i], movement_per_turn: int) -> int:
## Approximate turn count along a fog-aware preview. Tiles outside the
## current `_reachable_hexes` map are treated as cost-1 because their
## actual cost cannot be known until they're scouted.
if movement_per_turn <= 0:
return path.size()
var cost_so_far: int = 0
var turns: int = 1
for axial: Vector2i in path:
var step_cost: int = int(_reachable_hexes.get(axial, 1))
if step_cost <= 0:
step_cost = 1
if cost_so_far + step_cost > movement_per_turn:
turns += 1
cost_so_far = step_cost
else:
cost_so_far += step_cost
return turns
# -- Unit panel button bridges --------------------------------------------
func _on_move_pressed() -> void:
if _selected_unit != null and _selected_unit_has_movement():
_enter_movement_mode()
func _on_fortify_pressed_from_panel() -> void:
if _selected_unit == null:
return
if _selected_unit is UnitScript and _selected_unit.has_method("fortify"):
(_selected_unit as UnitScript).fortify()
EventBus.unit_selected.emit(_selected_unit)
func _on_skip_pressed_from_panel() -> void:
if _selected_unit == null:
return
if _selected_unit is UnitScript:
(_selected_unit as UnitScript).movement_remaining = 0
EventBus.unit_selected.emit(_selected_unit)
_deselect_unit()

View file

@ -14,10 +14,14 @@ var _panel: PanelContainer = null
var _timer: float = 0.0
var _last_axial: Vector2i = Vector2i(-9999, -9999)
var _viewport_manager: Control = null
## p0-35: weak reference to the parent world_map controller so the hover
## tick can push the cursor's hex into the movement-mode preview pipeline.
var _world_map: Node2D = null
func setup(parent: Node2D, viewport_mgr: Control) -> void:
_viewport_manager = viewport_mgr
_world_map = parent
_panel = TileInfoPanelScene.instantiate()
parent.add_child(_panel)
@ -36,6 +40,10 @@ func tick(delta: float) -> void:
if axial == _last_axial:
return
_last_axial = axial
## p0-35: every hover tick refreshes the movement-mode path preview, even
## when the underlying tile is null (off-map → preview clears).
if _world_map != null and _world_map.has_method("update_path_preview"):
_world_map.update_path_preview(axial)
var tile: Resource = game_map.get_tile(axial) as Resource
if tile == null:
_panel.hide_panel()