From d9009b323e4bb45f4c45b89529213b77e6e582bb Mon Sep 17 00:00:00 2001 From: Babissimo Date: Fri, 25 Sep 2026 14:56:17 +0100 Subject: [PATCH 1/4] Type-check the e2e specs strictly, and reach nullable hosts only through one helper The e2e specs gate production: a failed production E2E rolls production back. A spec that uses a host that is null on the environment it runs against (hosts.app, hosts.admin and hosts.testmap are null on production, and one or another on staging and the dev server) throws at module scope while Playwright collects the file, since test.skip stops the tests but not the file's own top-level code. That crashes the whole run, not the one spec. tsconfig.base.json turns strictNullChecks off, and the only guard was a comment beside each cast. e2e/tsconfig.json now extends a new root tsconfig.strict.json, which turns strict, noImplicitAny and strictNullChecks on by name (the base sets the last two to false outright, which strict alone does not override). The specs compiled clean under it already. What changes in them is how they reach a nullable host. app-surface paired a test.skip with an `as string` cast, live-map built `${TESTMAP}/sim` at module scope, and dashboard used `ADMIN!` assertions. The exported `hosts` now carries only the roles every environment has (api, map, dash); app, admin and testmap are reached through hostOrSkip(role, reason) alone, so a spec that reads one any other way does not compile. hostOrSkip skips the file, group or test it is called in where the role is null and returns an unroutable .invalid stand-in there, so it is safe at module scope and a spec's skip and host cannot disagree. It has to be called from the spec itself, since a shared module runs once and only its first importer would skip; its docstring says so. The config also refuses an E2E_ENV it has no hosts for, by name, where the old cast left the table undefined to fail later as a TypeError. tsconfig.strict.json lists strict-probe.ts in its files, so every workspace that extends it compiles the probe: one line per setting that has to stay a type error, which fails tsc as an unused @ts-expect-error the day that setting stops applying. Checked: tsc is clean; switching off any of the three flags fails it, and so does a nullable role read off hosts; hostOrSkip at module scope, even parsed as a URL, and inside a describe collects cleanly on every environment; app-surface passes against staging, and it, live-map and the dashboard's admin group skip on prod. ClickUp 3.2 (123zgec4my9). Co-Authored-By: Claude Opus 5.5 --- e2e/playwright.config.ts | 29 +++++++++++++++--- e2e/specs/app-surface.spec.ts | 28 ++++++----------- e2e/specs/dashboard.spec.ts | 17 +++++------ e2e/specs/live-map.spec.ts | 57 +++++++++++++++-------------------- e2e/tsconfig.json | 2 +- strict-probe.ts | 21 +++++++++++++ tsconfig.strict.json | 14 +++++++++ 7 files changed, 103 insertions(+), 65 deletions(-) create mode 100644 strict-probe.ts create mode 100644 tsconfig.strict.json diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index b733df384..e93ff862b 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices, test } from "@playwright/test"; /** * Playwright E2E test configuration. @@ -31,8 +31,6 @@ import { defineConfig, devices } from "@playwright/test"; * server's admin console. */ -const ENV = (process.env.E2E_ENV ?? "staging") as "staging" | "prod" | "local"; - const HOSTS = { staging: { api: "https://staging-api.retina.fm", @@ -84,8 +82,31 @@ const HOSTS = { }, } as const; +const isEnv = (name: string): name is keyof typeof HOSTS => Object.keys(HOSTS).includes(name); +const ENV = process.env.E2E_ENV ?? "staging"; +if (!isEnv(ENV)) throw new Error(`E2E_ENV=${ENV} is none of ${Object.keys(HOSTS).join(", ")}`); +const TABLE = HOSTS[ENV]; + export const env = ENV; -export const hosts = HOSTS[ENV]; +// The roles every environment has. The rest are reached through hostOrSkip. +export const hosts: Record<"api" | "map" | "dash", string> = { + api: TABLE.api, + map: TABLE.map, + dash: TABLE.dash, +}; + +/** + * The host for a role that is null on some environment, skipping where it is: + * the file at a spec's top level, the group in a describe or beforeAll, the test + * in a test or beforeEach. Call it from the spec itself: a shared module runs + * once, so only the first spec to import it would skip. Where it skips it + * returns an unroutable stand-in, so top-level code that parses it cannot throw. + */ +export function hostOrSkip(role: Exclude, reason: string): string { + const host = TABLE[role]; + test.skip(host === null, reason); + return host ?? `https://${role}.skipped.invalid`; +} /** * Cloudflare Access service-token headers, empty unless CI supplies both. diff --git a/e2e/specs/app-surface.spec.ts b/e2e/specs/app-surface.spec.ts index f4e537890..2f8dcdb32 100644 --- a/e2e/specs/app-surface.spec.ts +++ b/e2e/specs/app-surface.spec.ts @@ -6,42 +6,32 @@ * router lands where it should. The arrival at /map is react-router's own * redirect, so it needs the JavaScript to have loaded and run. * - * Skipped where hosts.app is null — see the table in playwright.config.ts for + * Skipped where the app role is null — see the table in playwright.config.ts for * why production and the dev server are. */ import { test, expect } from "@playwright/test"; -import { hosts } from "../playwright.config"; +import { hostOrSkip } from "../playwright.config"; -const APP = hosts.app; - -test.skip(APP === null, "no consolidated app surface in this environment"); - -// Only ever read inside a test body. test.skip aborts the tests, not this -// module: every top-level statement here still runs while the file is being -// collected, so touching this at module scope throws on the environments where -// it is null and takes the whole run down with it. -const BASE = APP as string; +const APP = hostOrSkip("app", "no consolidated app surface in this environment"); // A hostname is mostly dots, and an unescaped one matches any character. -function originPattern(): string { - return BASE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +const ORIGIN = APP.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); test.describe("the consolidated app surface", () => { test("opens on the map", async ({ page }) => { - await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL(new RegExp(`^${originPattern()}/map`)); + await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(new RegExp(`^${ORIGIN}/map`)); await expect(page.locator(".connection-badge")).toBeVisible({ timeout: 30_000 }); }); test("sends a private page to the login card", async ({ page }) => { - await page.goto(`${BASE}/overview`, { waitUntil: "domcontentloaded" }); + await page.goto(`${APP}/overview`, { waitUntil: "domcontentloaded" }); await expect(page.locator(".login-card")).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(new RegExp(`^${originPattern()}/login/?$`)); + await expect(page).toHaveURL(new RegExp(`^${ORIGIN}/login/?$`)); }); test("opens the explorer on the filters its link carries", async ({ page }) => { - await page.goto(`${BASE}/data?from=2026-09-01&to=2026-09-03`, { waitUntil: "domcontentloaded" }); + await page.goto(`${APP}/data?from=2026-09-01&to=2026-09-03`, { waitUntil: "domcontentloaded" }); // The page renders its shareable link from the filters it read, so this // needs the bundle to have run. await expect(page.getByTestId("de-share")).toContainText("from=2026-09-01&to=2026-09-03", { diff --git a/e2e/specs/dashboard.spec.ts b/e2e/specs/dashboard.spec.ts index a5f7c5114..702a90f8c 100644 --- a/e2e/specs/dashboard.spec.ts +++ b/e2e/specs/dashboard.spec.ts @@ -29,7 +29,7 @@ * Authenticated flows are covered via API-level assumptions (see api.spec.ts). */ import { test, expect, request as playwrightRequest, type Page } from "@playwright/test"; -import { hosts } from "../playwright.config"; +import { hosts, hostOrSkip } from "../playwright.config"; // The origin the dashboard is served from, at its root. const DASH = hosts.dash; @@ -37,7 +37,6 @@ const DASH_PAGE = DASH; const LOGIN_PATH = "/login"; // A page that needs a session. The index does not: it forwards to the map. const PRIVATE_PAGE = `${DASH_PAGE}/overview`; -const ADMIN = hosts.admin; const API = hosts.api; type AuthMode = "oauth" | "bypass"; @@ -177,18 +176,18 @@ test.describe("Admin surface selection", () => { // serves, with a different route table, chosen client-side from the // hostname. Null on prod (see playwright.config.ts) so a wobble here cannot // roll production back. - test.skip(!ADMIN, "no admin surface on this environment"); + const admin = hostOrSkip("admin", "no admin surface on this environment"); // The mode is a property of the deployment, not of either test. beforeEach - // runs per test, so cache it; the skip itself has to stay in beforeEach, - // which is where Playwright accepts it. The value is cached rather than the + // runs per test, so cache it; the resolution skip stays in beforeEach, since + // it has to await a request. The value is cached rather than the // promise, so a request that fails leaves the next test free to try again // instead of inheriting a rejection. let authMode: AuthMode | undefined; let adminResolves: boolean | undefined; test.beforeEach(async () => { - adminResolves ??= await resolves(ADMIN!); - test.skip(!adminResolves, `${ADMIN} does not resolve`); + adminResolves ??= await resolves(admin); + test.skip(!adminResolves, `${admin} does not resolve`); authMode ??= await serverAuthMode(); }); @@ -201,8 +200,8 @@ test.describe("Admin surface selection", () => { // run can show is that the vhost is reachable and serving this bundle, which // is why these stay here once enforced auth puts a login card in the way. test("the admin vhost serves the admin console", async ({ page }) => { - await page.goto(ADMIN!); - await expectSurface(page, ADMIN!, authMode!, "Admin Console"); + await page.goto(admin); + await expectSurface(page, admin, authMode!, "Admin Console"); }); test("the app host serves the user dashboard", async ({ page }) => { diff --git a/e2e/specs/live-map.spec.ts b/e2e/specs/live-map.spec.ts index ef754f3f2..bc93e3b8e 100644 --- a/e2e/specs/live-map.spec.ts +++ b/e2e/specs/live-map.spec.ts @@ -1,8 +1,8 @@ /** * Live Aircraft Map E2E tests, on the simulation surface. * - * This suite visits the admin console's /sim on whichever host `hosts.testmap` - * names: the local dev server's admin.localhost. It verifies the map page + * This suite visits the admin console's /sim on whichever host the `testmap` + * role names: the local dev server's admin.localhost. It verifies the map page * loads, WebSocket connects, aircraft appear, and key interactive elements work * correctly. * @@ -20,19 +20,12 @@ * build. */ import { test, expect, Page } from "@playwright/test"; -import { hosts } from "../playwright.config"; +import { hosts, hostOrSkip } from "../playwright.config"; -const TESTMAP = hosts.testmap; - -test.skip( - TESTMAP === null, +const SIM = `${hostOrSkip( + "testmap", "no synthetic map surface in this environment (only the test droplet runs a fleet)", -); - -// test.skip aborts the tests, not this module — every top-level statement still -// runs during collection — so nothing here may call a method on TESTMAP where it -// is null. Interpolating it is safe. -const BASE = `${TESTMAP}/sim`; +)}/sim`; // Helper: wait for the connection badge to show "LIVE" async function waitForLive(page: Page, timeoutMs = 15_000) { @@ -107,7 +100,7 @@ async function rowsOrSkip(page: Page) { test.describe("Live Map — page identity", () => { test("the console names the page Simulation Map", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); // The header's title rather than the HTML , which is static across // every page of the console. "Simulation Map", not "Live Map": this suite // visits /sim, and DashboardLayout's page-title table names that page for @@ -117,7 +110,7 @@ test.describe("Live Map — page identity", () => { }); test("the console's brand is RETINA, not Tower Finder", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); // Text content, not visibility: the map opens with the sidebar collapsed to // its icon rail, which hides the brand's labels. await expect(page.locator(".brand-text")).toHaveText(/RETINA/i); @@ -127,7 +120,7 @@ test.describe("Live Map — page identity", () => { test("no JavaScript errors on page load", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (err) => errors.push(err.message)); - await page.goto(BASE); + await page.goto(SIM); await page.waitForLoadState("networkidle"); expect(errors).toHaveLength(0); }); @@ -135,18 +128,18 @@ test.describe("Live Map — page identity", () => { test.describe("Live Map — map rendering", () => { test("Leaflet map container is present", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10_000 }); }); test("toolbar is rendered with connection badge", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); await expect(page.locator(".connection-badge")).toBeVisible(); }); test("toolbar shows Coverage / Labels / Trails toggle buttons", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); // "Coverage gaps" also matches a loose /Coverage/, so this must stay exact. await expect(page.getByRole("button", { name: "Coverage", exact: true })).toBeVisible(); @@ -155,7 +148,7 @@ test.describe("Live Map — map rendering", () => { }); test("Debug Truth toggle is present on the simulation surface", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("button", { name: /Debug Truth/i })).toBeVisible(); }); @@ -163,19 +156,19 @@ test.describe("Live Map — map rendering", () => { test.describe("Live Map — WebSocket connectivity", { tag: "@live" }, () => { test("connection badge transitions to LIVE within 15s", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); await expect(page.locator(".connection-badge")).toHaveClass(/connected/); }); test("aircraft count is non-empty once connected", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); await expect(page.locator(".aircraft-count")).toBeVisible(); }); test("Pause button toggles to Resume and back", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); const pauseBtn = page.getByRole("button", { name: /Pause/i }); @@ -192,7 +185,7 @@ test.describe("Live Map — WebSocket connectivity", { tag: "@live" }, () => { test.describe("Live Map — aircraft list panel", { tag: "@live" }, () => { test("aircraft list panel renders within 20s of connection", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); // Panel should exist after aircraft start arriving @@ -200,7 +193,7 @@ test.describe("Live Map — aircraft list panel", { tag: "@live" }, () => { }); test("aircraft list shows rows once data arrives", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); const rows = await rowsOrSkip(page); @@ -208,7 +201,7 @@ test.describe("Live Map — aircraft list panel", { tag: "@live" }, () => { }); test("clicking an aircraft row opens the detail panel", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); const row = (await rowsOrSkip(page)).first(); @@ -230,7 +223,7 @@ const SYNTHETIC_IDENTITY = /^(?:synth|e2e|test|realnode)-\S+$/; test.describe("Live Map — node markers", { tag: "@live" }, () => { test("every node marker is a synthetic one", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); // `.node-marker` is the divIcon NodeMarkersLayer gives a NON-synthetic @@ -242,7 +235,7 @@ test.describe("Live Map — node markers", { tag: "@live" }, () => { }); test("node popup names a synthetic fleet id, not a real node's ref", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await waitForLive(page); const marker = page.locator(".node-marker-synthetic").first(); @@ -260,7 +253,7 @@ test.describe("Live Map — node markers", { tag: "@live" }, () => { test.describe("Live Map — toolbar toggles", () => { test("Coverage toggle adds/removes active class", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); const btn = page.getByRole("button", { name: "Coverage", exact: true }); @@ -272,7 +265,7 @@ test.describe("Live Map — toolbar toggles", () => { }); test("Arcs toggle adds/removes active class", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); const btn = page.getByRole("button", { name: /Arcs/i }); @@ -284,7 +277,7 @@ test.describe("Live Map — toolbar toggles", () => { }); test("Labels toggle adds/removes active class", async ({ page }) => { - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); const btn = page.getByRole("button", { name: /Labels/i }); @@ -298,7 +291,7 @@ test.describe("Live Map — toolbar toggles", () => { const errors: string[] = []; page.on("pageerror", (err) => errors.push(err.message)); - await page.goto(BASE); + await page.goto(SIM); await expect(page.locator(".live-map-toolbar")).toBeVisible({ timeout: 10_000 }); const fitBtn = page.getByRole("button", { name: /Fit/i }); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 7617272d8..6f4e6ba25 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.base.json", + "extends": "../tsconfig.strict.json", "compilerOptions": { "types": [] }, diff --git a/strict-probe.ts b/strict-probe.ts new file mode 100644 index 000000000..3b18cdae0 --- /dev/null +++ b/strict-probe.ts @@ -0,0 +1,21 @@ +// Compiled into every workspace whose tsconfig extends tsconfig.strict.json, +// and imported and run by nothing. Each line under @ts-expect-error has to stay +// a type error, so tsc fails there the day the setting it names stops applying. + +declare const maybe: string | null; + +// strictNullChecks +// @ts-expect-error a value that may be null has no method to call +maybe.toString(); + +// noImplicitAny +// @ts-expect-error a parameter with no type is an implicit any +export const untyped = (value) => value; + +// strict, through useUnknownInCatchVariables +try { + maybe?.toString(); +} catch (error) { + // @ts-expect-error a caught value is unknown + error.toString(); +} diff --git a/tsconfig.strict.json b/tsconfig.strict.json new file mode 100644 index 000000000..247b3ea2e --- /dev/null +++ b/tsconfig.strict.json @@ -0,0 +1,14 @@ +{ + // The base with strict type-checking, for a workspace to extend. Each flag is + // named because the base turns the last two off outright, which `strict` + // alone does not override. strict-probe.ts fails to compile in any workspace + // where one of them stops applying, as long as that workspace declares no + // `files` of its own, which would replace this list. + "extends": "./tsconfig.base.json", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true + }, + "files": ["./strict-probe.ts"] +} From 38aeb5d51af23d1dc54fc7cc4c66762d0dcdbcda Mon Sep 17 00:00:00 2001 From: Babissimo <alexandercharters@protonmail.com> Date: Fri, 25 Sep 2026 15:09:04 +0100 Subject: [PATCH 2/4] Collect the browser suite on every environment before it can gate a deploy A spec that throws at module scope while Playwright collects it takes the whole E2E run down, and the production E2E rolls production back when it fails. Nothing ran collection before a merge: CI's e2e entry typechecks the specs, and tsc sees only some of the ways a spec can throw there, such as a URL built at module scope from a value that parses on one environment and not on another. e2e's typecheck script now runs `playwright test --list` for staging, prod and local after tsc. Listing loads every spec as a run would, with no browser and no network, so a collection-time throw on any environment fails the typecheck on the pull request, through the npm script CI's web-build already runs, with no workflow change. The listing goes to /dev/null; Playwright reports a collection error on stderr, after a "collecting for <env>" line naming the environment. The environments are listed by name, and playwright.config.ts says so beside its table. A probe spec whose top level throws although tsc accepts it fails here; the suite as it stands collects 98 tests on each environment. ClickUp 3.2 (123zgec4my9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/package.json | 3 ++- e2e/playwright.config.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/package.json b/e2e/package.json index 19e6d4d13..d02269327 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,7 +4,8 @@ "version": "1.0.0", "type": "module", "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && npm run collect", + "collect": "for env in staging prod local; do echo \"collecting for $env\"; E2E_ENV=$env playwright test --list > /dev/null || exit 1; done", "test:e2e": "playwright test", "test:e2e:local": "E2E_ENV=local playwright test", "test:e2e:staging": "E2E_ENV=staging playwright test", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index e93ff862b..f06c81618 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -82,6 +82,7 @@ const HOSTS = { }, } as const; +// e2e's collect script lists these environments by name; a new one goes there too. const isEnv = (name: string): name is keyof typeof HOSTS => Object.keys(HOSTS).includes(name); const ENV = process.env.E2E_ENV ?? "staging"; if (!isEnv(ENV)) throw new Error(`E2E_ENV=${ENV} is none of ${Object.keys(HOSTS).join(", ")}`); From 57683f285750874b96a4184e4ef114028e8e3796 Mon Sep 17 00:00:00 2001 From: Babissimo <alexandercharters@protonmail.com> Date: Fri, 25 Sep 2026 14:56:34 +0100 Subject: [PATCH 3/4] Type-check packages/shared strictly packages/shared is the request layer and the sign-in state every surface imports, and it compiled under the base config's strict: false, noImplicitAny: false and strictNullChecks: false like everything else. It already compiles clean with all three on, so its tsconfig.json now extends tsconfig.strict.json as e2e's does. That also compiles strict-probe.ts into it, so switching off any of the three fails its typecheck, which CI's web-build runs per workspace. The dashboard compiles shared's source as part of its own program, still under the base config, so this binds shared's typecheck and not the dashboard's view of it. The dashboard is the rest of 3.2 and is tracked on its own. The image's web stage now copies every root tsconfig rather than tsconfig.base.json alone. Vite's TypeScript transform follows shared's extends chain while it builds the console, and inside the image it could not resolve tsconfig.strict.json. docs/production-hardening.md's section on strict TypeScript says the two already compile strictly. ClickUp 3.2 (123zgec4my9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Dockerfile | 7 ++++--- docs/production-hardening.md | 3 ++- packages/shared/tsconfig.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 57155c4cd..6d70788d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,10 @@ COPY dashboard/package.json dashboard/ COPY packages/shared/package.json packages/shared/ COPY e2e/package.json e2e/ RUN npm ci -# Vite's TypeScript transform follows the app's tsconfig `extends` chain, so the -# build needs this even though nothing here runs tsc. -COPY tsconfig.base.json ./ +# Vite's TypeScript transform follows each workspace's tsconfig `extends` chain +# (the console's and shared's) to these, so the build needs them even though +# nothing here runs tsc. +COPY tsconfig*.json ./ # The console imports from it, so it belongs to the shared layer beneath. COPY packages/ packages/ diff --git a/docs/production-hardening.md b/docs/production-hardening.md index 2c64a7bb7..903e61a36 100644 --- a/docs/production-hardening.md +++ b/docs/production-hardening.md @@ -236,7 +236,8 @@ response shapes and component expectations still surface at runtime. That matters most for the nested structures from `/api/radar/analytics`, `/api/radar/nodes` and the aircraft WebSocket, whose variant-heavy shapes are largely untyped. It can be tightened incrementally: `strictNullChecks` first, -then typing the API shapes file by file. +then typing the API shapes file by file. The e2e suite and `packages/shared` +already compile strictly, by extending `tsconfig.strict.json`. **Priority:** medium **Effort:** moderate; typing every API shape fully is a longer tail diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 564a59900..8524d4248 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../tsconfig.strict.json", "include": ["src"] } From 41e8afcb460b69341ee1aceec44769ed7270397b Mon Sep 17 00:00:00 2001 From: Babissimo <alexandercharters@protonmail.com> Date: Fri, 25 Sep 2026 14:56:41 +0100 Subject: [PATCH 4/4] Name the config that compiles the e2e specs' env.d.ts e2e/specs/env.d.ts said only tsconfig.e2e.json compiles its directory. That file went when the browser suite got a workspace of its own (f3cb599e); e2e/tsconfig.json compiles it now, and the comment's warning (keep this process global out of the browser program) is only actionable if it names the right file. ClickUp 2.8 (123zgec4mxy), its stale-reference item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/specs/env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/specs/env.d.ts b/e2e/specs/env.d.ts index c11119ad6..5c2599c3c 100644 --- a/e2e/specs/env.d.ts +++ b/e2e/specs/env.d.ts @@ -1,4 +1,4 @@ -// Only tsconfig.e2e.json compiles this directory, and it must stay that way: +// Only e2e/tsconfig.json compiles this directory, and it must stay that way: // in the browser program this global would let src code use `process`, which // Vite does not polyfill, so it would typecheck and then throw at runtime. declare const process: { env: Record<string, string | undefined> };