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
221 changes: 221 additions & 0 deletions src/node/orpc/firstRunView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* The early script of the static first-run view (T3 #5971, C step 2, plan section 5.6, PR 4a),
* run in happy-dom with a fake fetch, timers and frames, so every path is deterministic.
*/
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView";
import { AUTH_TOKEN_KEY, ONBOARDING_VIEWED_KEY } from "@/common/constants/storage";
import { FIRST_RUN_MODULES_ID } from "../../../scripts/perf/firstLoadJs";
import { FIRST_RUN_EARLY_SCRIPT, FIRST_RUN_TEMPLATE_ID, FIRST_RUN_VIEW_HTML } from "./firstRunView";

interface Call {
url: string;
headers: Record<string, string>;
settle: (status: number, body?: unknown) => void;
fail: () => void;
}

let dom: GlobalWindow | undefined;

afterEach(async () => {
await dom?.happyDOM.close();
dom = undefined;
});

/** Runs the script on a served "/" (the module list, then the template), with fakes for its inputs. */
function run(
o: {
url?: string;
viewed?: boolean;
token?: string;
hidden?: boolean;
list?: string;
loading?: boolean;
} = {}
) {
const window = new GlobalWindow({
url: o.url ?? "http://127.0.0.1/",
settings: { disableJavaScriptFileLoading: true, handleDisabledFileLoadingAsSuccess: true },
});
dom = window;
const doc = window.document as unknown as Document;
const list = { script: "./main-A.js", preload: ["./api-B.js"] };
doc.head.innerHTML = `<script type="application/json" id="${FIRST_RUN_MODULES_ID}">${o.list ?? JSON.stringify(list)}</script>`;
doc.body.innerHTML = `<div id="root"></div><template id="${FIRST_RUN_TEMPLATE_ID}">${FIRST_RUN_VIEW_HTML}</template>`;
if (o.viewed) window.localStorage.setItem(ONBOARDING_VIEWED_KEY, "true");
if (o.token) window.localStorage.setItem(AUTH_TOKEN_KEY, o.token);
let visibility = o.hidden ? "hidden" : "visible";
let readyState = o.loading ? "loading" : "complete";
Object.defineProperty(doc, "visibilityState", { get: () => visibility, configurable: true });
Object.defineProperty(doc, "readyState", { get: () => readyState, configurable: true });
const calls: Call[] = [];
const timers: Array<{ ms: number; fn: () => void; cleared: boolean }> = [];
const frames: Array<() => void> = [];
const fetchFake = (url: URL, init: { headers: Record<string, string>; signal: AbortSignal }) =>
new Promise((resolve, reject) => {
init.signal.addEventListener("abort", () => reject(new Error("aborted")));
const settle = (status: number, body?: unknown) =>
resolve({ ok: status === 200, status, json: () => Promise.resolve(body) });
const fail = () => reject(new Error("offline"));
calls.push({ url: String(url), headers: init.headers, settle, fail });
});
const inputs = {
document: doc,
location: window.location,
localStorage: window.localStorage,
fetch: fetchFake,
setTimeout: (fn: () => void, ms: number) => timers.push({ ms, fn, cleared: false }) - 1,
clearTimeout: (id: number) => timers[id] && (timers[id].cleared = true),
requestAnimationFrame: (fn: () => void) => frames.push(fn),
};
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- runs the shipped script string itself
const script = new Function(...Object.keys(inputs), FIRST_RUN_EARLY_SCRIPT) as (
...a: unknown[]
) => void;
script(...Object.values(inputs)); // element checks below compare to null: a failing element dumps MBs
// happy-dom's Event, typed as the DOM's for the Document-typed nodes.
const event = (type: string) => new (window.Event as unknown as typeof Event)(type);
const settled = () => new Promise((resolve) => setTimeout(resolve, 0));
return {
calls,
timers,
frames,
settled,
view: () => doc.getElementById(FIRST_RUN_VIEW_ID),
apps: () => doc.querySelectorAll('script[type="module"]'),
preloads: () =>
[...doc.querySelectorAll("link[rel=modulepreload]")].map((l) => l.getAttribute("href")),
event,
hide: () => ((visibility = "hidden"), doc.dispatchEvent(event("visibilitychange"))),
parsed: () => ((readyState = "interactive"), doc.dispatchEvent(event("DOMContentLoaded"))),
flushFrames: () => frames.splice(0).forEach((fn) => fn()),
/** Answers both POSTs: the viewed list, then the provider config. */
answer: async (viewedIds: string[], config: Record<string, unknown>) => {
calls[0].settle(200, viewedIds);
calls[1].settle(200, config);
await settled();
},
};
}

describe("the early script", () => {
// Catches the app loading before the frame that paints the view (no or one requestAnimationFrame),
// POSTs sent one after the other, a missing bearer token, and any safety timer besides the abort.
test("shows the view, starts both POSTs together, and loads the app two frames later", async () => {
const page = run({ url: "http://127.0.0.1/?token=from-url", token: "stored" });
expect(page.calls.map((c) => c.url)).toEqual([
"http://127.0.0.1/api/splashScreens/getViewedSplashScreens",
"http://127.0.0.1/api/providers/getConfig",
]);
expect(page.calls.map((c) => c.headers.Authorization)).toEqual([
"Bearer from-url",
"Bearer from-url",
]);
await page.answer([], { anthropic: { isConfigured: false } });
expect(page.view()?.hasAttribute("inert")).toBe(true);
expect(page.apps()).toHaveLength(0);
page.flushFrames();
expect(page.apps()).toHaveLength(0);
page.flushFrames();
expect([...page.apps()].map((s) => s.getAttribute("src"))).toEqual(["./main-A.js"]);
expect(page.preloads()).toEqual(["./api-B.js"]);
expect(page.timers.map((t) => [t.ms, t.cleared])).toEqual([[1000, true]]);
});

// Each row catches the removal of its own branch: the view would show, or the app never loads.
test.each([
["the wizard was viewed", (p: Page) => p.answer(["onboarding-wizard-v1"], {})],
["a provider is configured", (p: Page) => p.answer([], { openai: { isConfigured: true } })],
[
"a gateway coupon is set",
(p: Page) => p.answer([], { "mux-gateway": { couponCodeSet: true } }),
],
["a POST returns 401", (p: Page) => (p.calls[0].settle(401), p.settled())],
["a POST fails", (p: Page) => (p.calls[1].fail(), p.settled())],
["the POSTs take over 1,000 ms", (p: Page) => (p.timers[0].fn(), p.settled())],
])("shows no view and loads the app at once when %s", async (_name, arrange) => {
const page = run();
await arrange(page);
expect(page.view() === null).toBe(true);
expect(page.apps()).toHaveLength(1);
});

// Catches the paint-cache key or the hidden check running after the POSTs start.
test.each([
["the paint-cache key is set", { viewed: true }],
["the tab is hidden", { hidden: true }],
])("makes no API call and loads the app at once when %s", (_name, options) => {
const page = run(options);
expect(page.calls).toHaveLength(0);
expect(page.timers).toHaveLength(0);
expect(page.apps()).toHaveLength(1);
expect(page.view() === null).toBe(true);
});

// Catches a missing visibilitychange listener: a hidden tab runs no frames, so the app never loads.
test("loads the app at once when the tab turns hidden while the view waits for its frames", async () => {
const page = run();
await page.answer([], {});
expect(page.view() === null).toBe(false);
page.hide();
expect(page.apps()).toHaveLength(1);
page.flushFrames();
expect(page.apps()).toHaveLength(1); // loaded once
});

// Catches a module script without an error listener: a failed app load would leave the view up.
test("removes the view when the app's module script fails to load", async () => {
const page = run();
await page.answer([], {});
page.flushFrames();
page.flushFrames();
page.apps()[0].dispatchEvent(page.event("error"));
expect(page.view() === null).toBe(true);
});

// Catches an insert that does not wait for the parser (in the <head>, the <template> comes later),
// and an untrimmed URL token (a blank one must fall back to the stored token, as the app does).
test("waits for DOMContentLoaded when the POSTs answer before the template is parsed", async () => {
const page = run({ url: "http://127.0.0.1/?token=%20", token: "stored", loading: true });
expect(page.calls[0].headers.Authorization).toBe("Bearer stored");
const template = dom!.document.getElementById(FIRST_RUN_TEMPLATE_ID)!;
template.remove();
await page.answer([], {});
expect([page.view() === null, page.apps().length]).toEqual([true, 0]);
dom!.document.body.append(template);
page.parsed();
expect(page.view() === null).toBe(false);
});

// Catches a module script added while the HTML still parses: it can run before #root exists.
test("adds the app's module script only once the HTML is parsed", () => {
const page = run({ viewed: true, loading: true });
expect([page.preloads().length, page.apps().length]).toEqual([1, 0]);
page.parsed();
expect(page.apps()).toHaveLength(1);
});

// Catches an unguarded module list: the throw would leave the view up with no app behind it.
test("drops the view when the module list is unusable", async () => {
const error = spyOn(console, "error").mockImplementation(() => undefined);
const page = run({ list: '{"script": 1}' });
await page.answer([], {});
page.flushFrames();
page.flushFrames();
expect([page.view() === null, page.apps().length, error.mock.calls.length]).toEqual([
true,
0,
1,
]);
error.mockRestore();
});

// Catches a script that runs on a slashless URL, which the redirect script navigates away from.
test("does nothing on a slashless URL", () => {
const page = run({ url: "http://127.0.0.1/xum" });
expect([page.calls.length, page.timers.length, page.apps().length]).toEqual([0, 0, 0]);
});
});

type Page = ReturnType<typeof run>;
78 changes: 78 additions & 0 deletions src/node/orpc/firstRunView.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView";
import { AUTH_TOKEN_KEY, ONBOARDING_VIEWED_KEY } from "@/common/constants/storage";

/**
* The static first-run view (T3 #5971, Option C step 2, plan section 5.6): a copy of the onboarding
Expand Down Expand Up @@ -93,3 +94,80 @@ export const FIRST_RUN_VIEW_HTML = `<div id="${FIRST_RUN_VIEW_ID}" inert style="
<button type="button" class="text-muted hover:text-foreground absolute top-4 right-4 rounded-sm transition-colors focus:outline-none disabled:pointer-events-none"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg><span class="sr-only">Close</span></button>
</div>
</div>`;

/** The <template> that holds FIRST_RUN_VIEW_HTML on the served "/" (PR 4b). */
export const FIRST_RUN_TEMPLATE_ID = "xum-pre-template";

/** `{"script": href, "preload": [href, ...]}`, the app's modules: scripts/perf/firstLoadJs.ts reads it too. */
const FIRST_RUN_MODULES_ID = "xum-pre-modules";

/**
* The early script (plan section 5.6), for an inline <script> on the served "/" after the slashless
* redirect, the <base> tag and the module list (PR 4b injects them). Its only timer is the 1,000 ms
* abort of its two POSTs. With the paint-cache key set or a hidden tab it loads the app at once,
* with no API call. A failed, 401 or aborted POST loads the app with no view, as today. The view
* shows only when the wizard opens on the gateway step as captured: not viewed, no configured
* provider, and no gateway coupon (a coupon swaps that step's panel). The app then loads two frames
* later, or at once when the tab turns hidden. A failed module script removes the view.
* SECURITY AUDIT: no request or user data; the view comes from a template clone, never HTML.
*/
export const FIRST_RUN_EARLY_SCRIPT = `(() => {
const path = location.pathname;
if (!path.startsWith("//") && !path.endsWith("/")) return; // the redirect navigates away
let loaded = false;
const loadApp = () => {
if (loaded) return;
loaded = true;
Comment thread
ThomasK33 marked this conversation as resolved.
let list;
try {
list = JSON.parse(document.getElementById(${JSON.stringify(FIRST_RUN_MODULES_ID)}).textContent);
if (typeof list.script !== "string" || !Array.isArray(list.preload)) throw new Error("bad list");
} catch (error) { // no list, no app to load: at least drop the view
document.getElementById(${JSON.stringify(FIRST_RUN_VIEW_ID)})?.remove();
return console.error("first-run view: no usable module list", error);
}
for (const href of list.preload) {
const link = document.createElement("link");
Object.assign(link, { rel: "modulepreload", crossOrigin: "", href });
document.head.appendChild(link);
}
const script = document.createElement("script");
Object.assign(script, { type: "module", crossOrigin: "", src: list.script });
script.addEventListener("error", () => document.getElementById(${JSON.stringify(FIRST_RUN_VIEW_ID)})?.remove());
// Unlike the deferred tag it replaces, a dynamic module runs once loaded: wait for #root.
const start = () => document.head.appendChild(script);
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();
};
const read = (key) => { try { return localStorage.getItem(key); } catch { return null; } };
if (read(${JSON.stringify(ONBOARDING_VIEWED_KEY)}) === "true" || document.visibilityState === "hidden") return loadApp();
document.addEventListener("visibilitychange", () => document.visibilityState === "hidden" && loadApp());
const token = new URLSearchParams(location.search).get("token")?.trim() || read(${JSON.stringify(AUTH_TOKEN_KEY)});
const headers = token ? { Authorization: "Bearer " + token } : {};
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), 1000);
const post = (route) =>
fetch(new URL("api/" + route, document.baseURI), { method: "POST", headers, signal: abort.signal }).then((r) => {
Comment thread
ThomasK33 marked this conversation as resolved.
if (!r.ok) throw new Error(String(r.status));
return r.json();
});
Promise.all([post("splashScreens/getViewedSplashScreens"), post("providers/getConfig")])
.then(([viewed, config]) => {
clearTimeout(timer);
const providers = Object.values(config ?? {});
const show =
Array.isArray(viewed) &&
!viewed.includes("onboarding-wizard-v1") && // ONBOARDING_WIZARD_SPLASH_ID
Comment thread
ThomasK33 marked this conversation as resolved.
!providers.some((p) => p?.isConfigured === true) &&
config?.["mux-gateway"]?.couponCodeSet !== true;
Comment thread
ThomasK33 marked this conversation as resolved.
const insert = () => {
const template = document.getElementById(${JSON.stringify(FIRST_RUN_TEMPLATE_ID)});
if (!show || loaded || !template) return loadApp();
document.body.appendChild(template.content.cloneNode(true));
requestAnimationFrame(() => requestAnimationFrame(loadApp));
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", insert);
else insert();
})
.catch(loadApp);
})();`;
Loading