Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions playwright/shard-weights.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"default:webgpu-buildings-smoke.spec.ts": 45,
"default:webgpu-boat-smoke.spec.ts": 45,
"default:webgpu-postfx-smoke.spec.ts": 45,
"default:webgpu-unsupported-row.spec.ts": 20,
"default:intervals-login.spec.ts": 20,
"default:intervals-login-failure.spec.ts": 45,
"default:kml-projection.spec.ts": 23,
Expand Down
62 changes: 62 additions & 0 deletions playwright/tests/webgpu-unsupported-row.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, expect } from '../fixtures/crash-watch';

// #542 AC11 / FR11 — when the browser cannot render through WebGPU (no
// `navigator.gpu`, software-only adapter, or an `attach` throw), the ride
// view shows a "needs WebGPU" screen and the rest of the site stays
// reachable. We force the first branch by stubbing `navigator.gpu` to
// `undefined` before the page scripts run; the engine's `requestDevice`
// then rejects and `attach` returns null, which is the AC11 path.
test.describe('WebGPU unsupported — ride view', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
try {
Object.defineProperty(window.navigator, 'gpu', {
configurable: true,
get() {
return undefined;
},
});
} catch {
// Browsers that don't let the property be redefined still show the
// unsupported screen when the engine declines; the smoke suite covers
// the attach-returns-null branch directly.
}
});
});

test('shows the "WebGPU required" screen on /row when no adapter is available', async ({ page }) => {
// Start a demo so the ride view mounts the engine (/row's active-session
// branch). The "try demo" CTA lives on the Row screen (`/`).
await page.goto('./');
const demoCta = page.locator('.btn-try-demo').first();
await expect(demoCta).toBeVisible({ timeout: 15_000 });
await demoCta.click();

// `/row` renders the stage, Canvas3DHost calls `attach`, the engine
// declines, Canvas3DHost raises `backend='unavailable'` to the page, and
// the page swaps the stage for `<WebGpuUnsupported>`.
await page.waitForURL(/\/row\/?$/, { timeout: 15_000 });
const screen = page.locator('.webgpu-unsupported');
await expect(screen).toBeVisible({ timeout: 15_000 });
await expect(screen.getByRole('heading')).toContainText(/WebGPU/i);
// The rest of the site stays reachable through the "Back to routes" link.
const back = screen.getByRole('link', { name: /routes/i });
await expect(back).toBeVisible();
});

test('navigating back to routes from the unsupported screen keeps the site usable', async ({ page }) => {
await page.goto('./');
const demoCta = page.locator('.btn-try-demo').first();
await expect(demoCta).toBeVisible({ timeout: 15_000 });
await demoCta.click();

await page.waitForURL(/\/row\/?$/, { timeout: 15_000 });
const screen = page.locator('.webgpu-unsupported');
await expect(screen).toBeVisible({ timeout: 15_000 });

await screen.getByRole('link', { name: /routes/i }).click();
// We're back on the Row screen; chrome works, no "WebGPU required"
// dialog persists on the home.
await expect(page.locator('.webgpu-unsupported')).toHaveCount(0);
});
});
65 changes: 65 additions & 0 deletions src/__tests__/canvas3dHost.renderer-unavailable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

import Canvas3DHost from '../lib/ui/Canvas3DHost.svelte';
import type { RideAttach } from '../lib/ui/rideCanvas';
import { crashReporterService } from '../lib/services/crashReporterService';
import type { CrashPayload } from '../lib/types/crashPayload';

// #542 AC11 / FR11: when the WebGPU engine declines (software adapter, no
// `navigator.gpu`, or an attach throw), the host reports exactly one
// `renderer-unavailable` crash so the triage pipeline can see it.
describe('Canvas3DHost renderer-unavailable telemetry', () => {
// vi.spyOn's inferred shape and the ReportResult generic don't line up
// under this tsconfig, so widen the spy to its mock-API surface.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let spy: any;

beforeEach(() => {
spy = vi
.spyOn(crashReporterService, 'reportCrash')
.mockResolvedValue({ submitted: true });
});

afterEach(() => {
spy.mockRestore();
});

const kindOf = (call: unknown[]): CrashKindOrNull => {
const payload = call[0] as Partial<CrashPayload> | undefined;
return (payload?.kind ?? null) as CrashKindOrNull;
};

it('reports a renderer-unavailable crash when attach returns null', async () => {
const attach = vi.fn<RideAttach>(() => null);
render(Canvas3DHost, { attach });

await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1);
});
expect(kindOf(spy.mock.calls[0] as unknown[])).toBe('renderer-unavailable');
});

it('reports a renderer-unavailable crash when attach throws', async () => {
const attach = vi.fn<RideAttach>(() => {
throw new Error('no adapter');
});
render(Canvas3DHost, { attach });

await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1);
});
expect(kindOf(spy.mock.calls[0] as unknown[])).toBe('renderer-unavailable');
});

it('does not report when the engine attaches successfully', async () => {
const attach = vi.fn<RideAttach>(() => ({ dispose: () => {} }));
render(Canvas3DHost, { attach });
await Promise.resolve();
await Promise.resolve();

expect(spy).not.toHaveBeenCalled();
});
});

type CrashKindOrNull = CrashPayload['kind'] | null;
25 changes: 25 additions & 0 deletions src/__tests__/crashRedactor.renderer-unavailable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';

import { redactCrashPayload } from '../lib/utils/crashRedactor';

// #542 AC11 (FR11): the "needs WebGPU" path records a `renderer-unavailable`
// crash so the triage pipeline sees how often the engine declines.
describe('crashRedactor renderer-unavailable kind', () => {
it('preserves `kind: renderer-unavailable` through the allowlist', () => {
const out = redactCrashPayload({ kind: 'renderer-unavailable' });
expect(out.kind).toBe('renderer-unavailable');
});

it('keeps authFailure null for a renderer-unavailable payload', () => {
const out = redactCrashPayload({ kind: 'renderer-unavailable' });
expect(out.authFailure).toBeNull();
});

it('carries the verdict reason through for a renderer-unavailable payload', () => {
const out = redactCrashPayload({
kind: 'renderer-unavailable',
verdict: { crashed: true, reason: 'software-adapter' },
});
expect(out.verdict.reason).toBe('software-adapter');
});
});
28 changes: 28 additions & 0 deletions src/__tests__/webGpuUnsupported.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';

import WebGpuUnsupported from '../lib/ui/WebGpuUnsupported.svelte';

// #542 AC11 (FR11, D11-a/c): "needs WebGPU" screen in the ride only; rest of
// the site stays usable. The React/WebGL legacy app lives at a separate path,
// so from here we only point the viewer away from the ride.
describe('WebGpuUnsupported', () => {
it('names the WebGPU requirement in the heading', () => {
const { container } = render(WebGpuUnsupported);
const heading = container.querySelector('h1,h2');
expect(heading?.textContent).toMatch(/WebGPU/i);
});

it('offers a link back to the routes screen so the rest of the site stays reachable', () => {
const { container } = render(WebGpuUnsupported);
const links = Array.from(container.querySelectorAll('a[href]')) as HTMLAnchorElement[];
const back = links.find((a) => /routes|home|back/i.test(a.textContent ?? ''));
expect(back).toBeDefined();
expect(back!.getAttribute('href')).toMatch(/\//);
});

it('names at least one supported browser so the viewer knows how to proceed', () => {
const { container } = render(WebGpuUnsupported);
expect(container.textContent).toMatch(/Chrome|Chromium|Edge|Safari|Firefox/);
});
});
12 changes: 11 additions & 1 deletion src/lib/types/crashPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,17 @@ export interface CrashErrorSummary {
* carries its structured detail in `authFailure` and leaves `errorSummary`
* as the deduplication signature source.
*/
export type CrashKind = 'crash' | 'oauth-token-exchange' | 'oauth-profile-fetch';
export type CrashKind =
| 'crash'
| 'oauth-token-exchange'
| 'oauth-profile-fetch'
/**
* #542 FR11/AC11: the hand-written WebGPU engine declined to render
* (no `navigator.gpu`, software adapter only, or an attach throw). The
* ride view falls back to the "needs WebGPU" screen (D11); the rest of
* the site stays usable.
*/
| 'renderer-unavailable';

/**
* Structured intervals.icu OAuth failure context (#486 FR1). Written at the
Expand Down
40 changes: 34 additions & 6 deletions src/lib/ui/Canvas3DHost.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { RideAttach, RideRenderer, RideFrame } from './rideCanvas';
import { crashReporterService } from '../services/crashReporterService';

export type CanvasBackend = 'stub' | 'live' | 'unavailable';

interface Props {
/** The engine's `attach` entrypoint. Omitted in P1 → bare canvas. */
Expand All @@ -35,31 +38,56 @@
* values default to `male`, matching the engine's pre-seam behaviour.
*/
crew?: 'male' | 'female';
/** #542 AC11: notify the ride view when the backend state changes so it
* can swap in the "needs WebGPU" screen on 'unavailable'. */
onBackend?: (backend: CanvasBackend) => void;
}

let { attach, tier, route, testMode = false, getFrame, crew }: Props = $props();
let { attach, tier, route, testMode = false, getFrame, crew, onBackend }: Props = $props();

let container = $state<HTMLDivElement | undefined>(undefined);
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
// 'stub' until the engine attaches; 'live' once it does; 'unavailable' if the
// engine declined (returned null).
let backend = $state<'stub' | 'live' | 'unavailable'>('stub');
let backend = $state<CanvasBackend>('stub');

const setBackend = (next: CanvasBackend, reason?: string): void => {
const changed = backend !== next;
backend = next;
if (changed) {
onBackend?.(next);
// #542 FR11 / AC11: one `renderer-unavailable` telemetry per attach
// decline so the triage pipeline sees how often users hit this path.
// The reporter's own dedupe window keeps a repeat visit quiet. Fire
// and forget — the reporter never throws (NFR2).
if (next === 'unavailable') {
void crashReporterService.reportCrash({
kind: 'renderer-unavailable',
verdict: { crashed: true, reason: reason ?? 'engine-declined' },
});
}
}
};

onMount(() => {
if (!attach || !canvas) return;
let handle: RideRenderer | null = null;
let disposed = false;
Promise.resolve(attach(canvas, { tier, route, testMode, getFrame, crew }))
// Wrap in Promise.resolve(fn).then so a sync throw inside `attach` lands
// in the catch path alongside a rejected promise, instead of escaping the
// effect — AC11 wants the renderer-unavailable telemetry either way.
Promise.resolve()
.then(() => attach(canvas!, { tier, route, testMode, getFrame, crew }))
.then((r) => {
if (disposed) {
r?.dispose();
return;
}
handle = r;
backend = r ? 'live' : 'unavailable';
setBackend(r ? 'live' : 'unavailable', r ? undefined : 'attach-returned-null');
})
.catch(() => {
backend = 'unavailable';
.catch((err: unknown) => {
setBackend('unavailable', err instanceof Error ? err.message : 'attach-threw');
});
return () => {
disposed = true;
Expand Down
70 changes: 70 additions & 0 deletions src/lib/ui/WebGpuUnsupported.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!--
#542 FR11 / AC11 (D11-a/c): the "needs WebGPU" screen the ride view falls
back to when the engine's `attach` declines (no `navigator.gpu`, software
adapter only, or an attach throw). Only the ride needs WebGPU; the rest of
the site (routes, settings, summary, import) keeps working from this
screen's "Back to routes" link.

Copy and markup are deliberately plain so no visual baseline is wired up to
it yet; the baseline work waits for the GPU runner (D5-b).
-->
<script lang="ts">
/**
* `backHref` is the path back to the Routes landing. The ride page passes
* SvelteKit's `base + '/'` so the link honours the deploy sub-path; the
* default keeps the component unit-testable without `$app/paths`.
*/
interface Props {
backHref?: string;
}
let { backHref = '/' }: Props = $props();
</script>

<section class="webgpu-unsupported" role="alert">
<h1>WebGPU is required to row</h1>
<p>
This simulator renders the river in a WebGPU graphics engine. Your browser
either does not expose <code>navigator.gpu</code> or only has a software
adapter, so the ride view cannot start.
</p>
<p>
Open the app in a recent build of Chrome, Chromium, Edge, Safari or
Firefox with hardware WebGPU enabled, then come back.
</p>
<p class="webgpu-unsupported__actions">
<a class="webgpu-unsupported__link" href={backHref}>Back to routes</a>
</p>
</section>

<style>
.webgpu-unsupported {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 42rem;
margin: 2rem auto;
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
color: #f5f5f5;
font: inherit;
}
.webgpu-unsupported h1 {
margin: 0;
font-size: 1.5rem;
}
.webgpu-unsupported__link {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 999px;
background: #2f6feb;
color: #fff;
text-decoration: none;
font-weight: 600;
}
.webgpu-unsupported__link:hover,
.webgpu-unsupported__link:focus {
background: #4c88ff;
}
</style>
1 change: 1 addition & 0 deletions src/lib/utils/crashRedactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const CRASH_KINDS: readonly CrashKind[] = [
'crash',
'oauth-token-exchange',
'oauth-profile-fetch',
'renderer-unavailable',
];

/**
Expand Down
Loading
Loading