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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ flowchart LR
OSMLP["OSM land polygons"] --> MASK
ORC["ORC cert Salona 45"] --> POLARS["build_polars.mjs → polar-genoa/fock.json"]
CUR["curated harbor list"] --> HARB["build_harbors.mjs → harbors.json"]
PROTO["Protomaps extract"] --> PMT["basemap.pmtiles"]
PROTO["Protomaps extract"] --> PMT["basemap.pmtiles.png"]
end
MASK & POLARS & HARB & PMT --> ASSETS["committed static assets — app/public/data/"]
subgraph app ["Runtime — app/ (PWA, no backend)"]
Expand Down
162 changes: 162 additions & 0 deletions app/e2e/basemap-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { test, expect, type Locator, type Page } from '@playwright/test';
import { startPreview } from './helpers';

// #118: GitHub Pages/Fastly gzip-compresses application/octet-stream and
// answers Range requests with 206 slices OF THE COMPRESSED stream — the
// browser cannot inflate them, so first-load/no-SW visitors got a blank
// basemap. The fix ships the archive as `data/basemap.pmtiles.png`
// (gzip-exempt, Range-clean content type) plus an uncontrolled-page runtime
// net (src/services/basemapSource.ts): a Range preflight and, on failure, a
// full-body fetch into a Blob-backed pmtiles source.
//
// These specs run in a context with `serviceWorkers: 'block'` — the honest
// no-SW cohort #118 breaks for. The preview server (fixed port 4173,
// helpers.ts — serialize with the other e2e specs) serves real identity 206s,
// so the CDN's gzip-of-range corruption does NOT reproduce locally; the first
// spec simulates it exactly via page.route, fulfilling ranged requests with a
// 206 whose body starts with the gzip magic the live probe captured, while
// passing full-body GETs through to the real server. Without that simulation
// the fallback path would never fire in any environment while the CDN
// behaves — this spec is what keeps it from rotting.

const GERMAN_MAP_ERROR_BANNER =
'Kartendaten konnten nicht geladen werden — Anzeige evtl. unvollständig.';

const ARCHIVE_GLOB = '**/data/basemap.pmtiles.png';

// Same settle idiom as datalayers.spec.ts: polls until the canvas stops
// changing frame-to-frame (two consecutive byte-equal screenshots), then
// returns that settled frame — adaptive, no fixed-sleep synchronization.
// CI runners are 6-10x slower than dev machines, hence the generous cap.
async function settledCanvas(page: Page, canvas: Locator): Promise<Buffer> {
let prev = await canvas.screenshot();
for (let i = 0; i < 60; i++) {
await page.waitForTimeout(250);
const next = await canvas.screenshot();
if (next.equals(prev)) return next;
prev = next;
}
return prev; // best-effort: never stabilized within the cap
}

test('forced CDN corruption (#118 signature): preflight fails, exactly one full-body fetch, Blob-backed map paints real tiles', async ({
browser,
}) => {
const server = await startPreview();
const context = await browser.newContext({ serviceWorkers: 'block' });
try {
const page = await context.newPage();
const canvas = page.locator('canvas.maplibregl-canvas');

// ---- Phase 1: deterministic NO-TILES baseline. ----------------------
// MapLibre shows a canvas even with zero tiles (background layer only),
// so "canvas visible" alone proves nothing — capture what a tiles-less
// settled frame looks like at this exact viewport, to compare against.
// The preflight (bytes=0-15) passes through honestly so the map
// constructs on the 'range-ok' path; every OTHER archive read (pmtiles'
// header/directory/tile fetches) is aborted, so no tile can ever render.
// Console errors and the map-error banner are EXPECTED in this phase and
// deliberately not asserted on; the banner lives outside the canvas.
await page.route(ARCHIVE_GLOB, async (route) => {
if (route.request().headers()['range'] === 'bytes=0-15') {
await route.continue();
return;
}
await route.abort();
});
await page.goto(server.url);
await expect(canvas).toBeVisible({ timeout: 60_000 });
const blankBaseline = await settledCanvas(page, canvas);
await page.unroute(ARCHIVE_GLOB);

// ---- Phase 2: the #118 corruption — fallback must paint real tiles. --
const consoleMessages: string[] = [];
page.on('console', (msg) => consoleMessages.push(msg.text()));

let corruptedRangeRequests = 0;
let fullBodyRequests = 0;
await page.route(ARCHIVE_GLOB, async (route) => {
if (route.request().headers()['range'] !== undefined) {
// Simulate the live CDN failure: a "206" whose bytes are a slice of
// the COMPRESSED stream — gzip magic where 'PMTiles' should be. The
// total in content-range is the live probe's compressed length.
corruptedRangeRequests += 1;
await route.fulfill({
status: 206,
headers: { 'content-range': 'bytes 0-15/27192908' },
body: Buffer.from([
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xec, 0x9d, 0x07, 0x60,
0x1c, 0x55,
]),
});
return;
}
// The fallback's full-body GET: pass through to the real server (a
// COMPLETE body decodes fine — only ranged slices are broken).
fullBodyRequests += 1;
await route.continue();
});

await page.goto(server.url);

// The fallback full-body GET fired...
await expect.poll(() => fullBodyRequests, { timeout: 60_000 }).toBe(1);

// ...and the map paints REAL tiles from the Blob-backed source: the
// settled frame must differ from the tiles-less baseline of phase 1
// (byte-compare of settled frames, the datalayers.spec.ts idiom —
// canvas visibility alone would pass with zero tiles).
await expect(canvas).toBeVisible({ timeout: 60_000 });
const fallbackFrame = await settledCanvas(page, canvas);
expect(fallbackFrame.equals(blankBaseline)).toBe(false);

// Exact end-of-spec totals (pinned AFTER the paint proof): the preflight
// is the ONLY ranged request — a Protocol key drift would silently
// auto-create a lazy FetchSource whose reads show up as EXTRA ranged
// requests here — and the 27 MB full-body fetch ran exactly once.
expect(corruptedRangeRequests).toBe(1);
expect(fullBodyRequests).toBe(1);

// The one-line breadcrumb names #118; the decoding failure never happens
// (the whole point — the browser only ever decodes a COMPLETE stream).
expect(consoleMessages.some((m) => m.includes('[#118]'))).toBe(true);
expect(consoleMessages.filter((m) => m.includes('ERR_CONTENT_DECODING_FAILED'))).toEqual([]);

// The fallback is silent by design — the map-error banner must NOT show.
await expect(page.getByText(GERMAN_MAP_ERROR_BANNER)).toHaveCount(0);
} finally {
await context.close();
server.kill();
}
});

test('honest origin: preflight passes, ranged fast path stays, no full-body archive download', async ({
browser,
}) => {
const server = await startPreview();
const context = await browser.newContext({ serviceWorkers: 'block' });
try {
const page = await context.newPage();

const ranged206Responses: string[] = [];
const fullBodyRequests: string[] = [];
page.on('response', (res) => {
if (!res.url().includes('data/basemap.pmtiles.png')) return;
if (res.request().headers()['range'] === undefined) fullBodyRequests.push(res.url());
else if (res.status() === 206) ranged206Responses.push(res.url());
});

await page.goto(server.url);
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 60_000 });

// The preflight (and pmtiles' FetchSource after it) got true 206s...
await expect
.poll(() => ranged206Responses.length, { timeout: 30_000 })
.toBeGreaterThanOrEqual(1);
// ...and the passing preflight means the 27 MB full-body fetch NEVER ran.
expect(fullBodyRequests).toEqual([]);
} finally {
await context.close();
server.kill();
}
});
19 changes: 10 additions & 9 deletions app/e2e/offline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test('true offline reload: precached app shell renders and a saved plan reloads

// Resolves once this origin has an active worker. workbox-precaching's
// install-event handler (src/sw.ts) awaits the full precache download
// (~33 MB, including the 27 MB basemap.pmtiles — fonts are runtime-
// (~33 MB, including the 27 MB basemap.pmtiles.png — fonts are runtime-
// cached since #28) before the worker can reach 'installed'; since this
// is a brand-new registration with no prior controller to conflict
// with, it then auto-activates, and clientsClaim() (also sw.ts) hands
Expand Down Expand Up @@ -135,15 +135,16 @@ test('true offline reload: precached app shell renders and a saved plan reloads
page.getByText('Offline — Planung deaktiviert. Gespeicherte Routen bleiben verfügbar.'),
).toBeVisible();

// Proves sw.ts's dedicated Range-request route for .pmtiles is actually
// what's serving the basemap here, not just "some cached response that
// happens to render a canvas" — workbox's *default* precache route would
// instead replay a full 200 to a ranged request, which pmtiles'
// FetchSource rejects (see sw.ts's own comment). This is the only place
// in the offline pass that would catch that route silently regressing
// while the map still happens to render via some other fallback.
// Proves sw.ts's dedicated Range-request route for the basemap archive
// (basemap.pmtiles.png since the #118 rename) is actually what's serving
// it here, not just "some cached response that happens to render a
// canvas" — workbox's *default* precache route would instead replay a
// full 200 to a ranged request, which pmtiles' FetchSource rejects (see
// sw.ts's own comment). This is the only place in the offline pass that
// would catch that route silently regressing while the map still happens
// to render via some other fallback.
const rangeStatus = await page.evaluate(async () => {
const res = await fetch('data/basemap.pmtiles', { headers: { range: 'bytes=0-99' } });
const res = await fetch('data/basemap.pmtiles.png', { headers: { range: 'bytes=0-99' } });
return res.status;
});
expect(rangeStatus).toBe(206);
Expand Down
11 changes: 11 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,17 @@ function fetchMock() {
if (url.includes('harbors.json')) return Promise.resolve(jsonResponse(HARBORS));
if (url.includes('seamarks.json'))
return Promise.resolve(jsonResponse({ type: 'FeatureCollection', features: [] }));
if (url.includes('basemap.pmtiles.png')) {
// #118: MapView's uncontrolled-page preflight (Range bytes=0-15) runs
// on every mount now — answer like an honest ranged origin (true 206,
// body starting with the PMTiles magic 'PM') so the app tree takes the
// normal 'range-ok' path and never triggers the Blob fallback here.
return Promise.resolve(
new Response(Uint8Array.from([0x50, 0x4d, 0x54, 0x69, 0x6c, 0x65, 0x73]), {
status: 206,
}),
);
}
return Promise.reject(new Error(`unexpected fetch: ${url}`));
});
}
Expand Down
155 changes: 155 additions & 0 deletions app/src/components/MapView.mount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { StrictMode } from 'react';
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// #118 mount lifecycle of MapView's async effect: the basemap transport
// check (services/basemapSource.ts) resolves BEFORE map construction, and
// the `cancelled` flag must close the unmount-during-fetch window. These
// tests pin that window plus the error routing of post-await construction
// throws — none of it is observable through App.test.tsx's happy paths.

const hoisted = vi.hoisted(() => ({
mapCtorCalls: [] as unknown[],
// Set per-test to make the FakeMap constructor throw (WebGL init failure).
mapCtorError: { current: null as Error | null },
protocolAddCalls: [] as unknown[],
removeCalls: { count: 0 },
}));

vi.mock('maplibre-gl', () => {
class FakeMap {
constructor(options: unknown) {
hoisted.mapCtorCalls.push(options);
if (hoisted.mapCtorError.current) throw hoisted.mapCtorError.current;
}
on() {}
off() {}
addControl() {}
getContainer() {
// Detached, control-less div: collapseAttributionAtLoad no-ops.
return document.createElement('div');
}
remove() {
hoisted.removeCalls.count += 1;
}
}
class FakeAttributionControl {}
return { Map: FakeMap, AttributionControl: FakeAttributionControl, addProtocol: vi.fn() };
});

vi.mock('pmtiles', () => {
class FakeProtocol {
tile = () => {};
add(p: unknown) {
hoisted.protocolAddCalls.push(p);
}
}
class FakePMTiles {
source: unknown;
constructor(source: unknown) {
this.source = source;
}
}
return { Protocol: FakeProtocol, PMTiles: FakePMTiles };
});

import MapView from './MapView';

/** First 7 bytes of a real PMTiles archive — makes the preflight pass. */
const PM_MAGIC = Uint8Array.from([0x50, 0x4d, 0x54, 0x69, 0x6c, 0x65, 0x73]);

function ok206() {
return {
status: 206,
arrayBuffer: () => Promise.resolve(PM_MAGIC.slice().buffer),
body: null,
};
}

async function flushAsyncMount() {
// The mount IIFE awaits fetch → arrayBuffer before constructing; a couple
// of macrotask turns inside act() flushes the whole chain deterministically.
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}

beforeEach(() => {
hoisted.mapCtorCalls.length = 0;
hoisted.mapCtorError.current = null;
hoisted.protocolAddCalls.length = 0;
hoisted.removeCalls.count = 0;
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('MapView async mount (#118 cancelled-flag window)', () => {
it('unmount while the preflight hangs: no map construction, no protocol.add', async () => {
// A fetch that never settles — the honest "still in flight" state.
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {})),
);
const { unmount } = render(<MapView tapActive={false} onTap={() => {}} />);
await flushAsyncMount();
unmount();
await flushAsyncMount();
expect(hoisted.mapCtorCalls.length).toBe(0);
expect(hoisted.protocolAddCalls.length).toBe(0);
});

it('preflight resolving AFTER unmount: continuation is skipped, nothing constructed', async () => {
let resolveFetch: ((value: unknown) => void) | undefined;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
),
);
const { unmount } = render(<MapView tapActive={false} onTap={() => {}} />);
await flushAsyncMount();
unmount();
// Deliver a PASSING preflight only after the component is gone.
resolveFetch?.(ok206());
await flushAsyncMount();
expect(hoisted.mapCtorCalls.length).toBe(0);
expect(hoisted.protocolAddCalls.length).toBe(0);
});

it('post-await construction throw routes into the map-error path (console.error + one-shot onMapError)', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok206()));
hoisted.mapCtorError.current = new Error('WebGL context creation failed');
const onMapError = vi.fn();
render(<MapView tapActive={false} onTap={() => {}} onMapError={onMapError} />);
await flushAsyncMount();
// Pre-#118 a sync constructor throw crashed loudly; the async mount must
// not downgrade that to a silent floating rejection.
expect(onMapError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();
});

it('StrictMode double-mount: two preflights, but only the second mount constructs a map', async () => {
// Dev-only StrictMode remounts run the preflight twice by design (the
// accepted cost documented at the effect head); the first mount's
// continuation must be cancelled before it can construct.
const fetchMock = vi.fn().mockResolvedValue(ok206());
vi.stubGlobal('fetch', fetchMock);
render(
<StrictMode>
<MapView tapActive={false} onTap={() => {}} />
</StrictMode>,
);
await flushAsyncMount();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(hoisted.mapCtorCalls.length).toBe(1);
expect(hoisted.protocolAddCalls.length).toBe(0);
});
});
Loading