mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
- Web: base bundles built from the app's entry at start (content-hashed, served forever), plugin scripts that register themselves and one combined file, WebScripts/TuiScripts showing the current set, startPage with the connection loop, in-place plugin reloads and a reload on a new base bundle. - React bindings: HostProvider, useHold, useWatch, useRegistry, useRefState. - Terminal: startTerminal, a launcher that signs in with a token, a token file or device pairing and always runs the app's terminal code in a sandboxed child process (pipes, empty environment, --permission granting nothing, no SQLite, a builtin allowlist and a frozen process inside); output filter, confirmed copy/open/attach actions; startTerminalRuntime and TuiScope inside. - Themes: the Themes registry, ThemeChoice per principal, CSS variables on the page, terminal colours by role; CSS modules in plugin builds. - App kit: startServer gains web and tui; startApp renamed startServer, exportAddress/importAddress renamed exportData/importData and export no longer stops the worker; ephemeral sessions end with their client's last link. - Examples 56-web, 57-tui, 58-themes, 59-chat.
218 lines
9.8 KiB
TypeScript
218 lines
9.8 KiB
TypeScript
// Tests of the web surface's server side (slice 11 §1), through the app kit: the base bundle (content-hashed, served
|
|
// forever, rebuilt at each start), plugin scripts as registered scripts and one combined file, `WebScripts` updated by
|
|
// a rollout, the page through the app's template with the baseline's CSP, signed in unless public.
|
|
|
|
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
|
import { WebScripts } from "../../../src/next/index.ts";
|
|
import { Sessions } from "../../../src/next-node/app/index.ts";
|
|
import { pageScripts } from "../../../src/next-node/surfaces/index.ts";
|
|
import { tempDir, testApp } from "../app/support.ts";
|
|
import { send } from "../http/support.ts";
|
|
|
|
/** A web entry in a new directory: its path. */
|
|
async function entry(text = "globalThis.started = true;\n"): Promise<string> {
|
|
const dir = await tempDir();
|
|
await writeFile(join(dir, "style.css"), ":root { --brand: red; }\n");
|
|
await writeFile(join(dir, "main.ts"), `import "./style.css";\n${text}`);
|
|
return join(dir, "main.ts");
|
|
}
|
|
|
|
/** A web plugin's files: a facet with a stylesheet. */
|
|
const WEB_PLUGIN = {
|
|
"web.ts":
|
|
'import { defineFacet } from "@test/app/v1";\nimport "./notes.css";\nexport default defineFacet(() => {});',
|
|
"notes.css": ".notes { color: var(--accent); }",
|
|
};
|
|
|
|
/** The page template of these tests: the assets' tags in a minimal document. */
|
|
const page = (request: { readonly nonce: string }, assets: Parameters<typeof pageScripts>[0]): string =>
|
|
`<!doctype html><title>t</title>${pageScripts(assets, request.nonce)}<div id=root></div>`;
|
|
|
|
/** A kit app with the web surface and `plugins` (a web kind added). */
|
|
async function webApp(
|
|
options: {
|
|
readonly plugins?: Record<string, Record<string, string>>;
|
|
readonly public?: boolean;
|
|
readonly main?: string;
|
|
} = {},
|
|
) {
|
|
return testApp({
|
|
kinds: { server: { platform: "node" }, worker: { platform: "node" }, web: { platform: "browser" } },
|
|
plugins: options.plugins ?? {},
|
|
web: { entry: options.main ?? (await entry()), page, ...(options.public === true ? { public: true } : {}) },
|
|
});
|
|
}
|
|
|
|
/** A bearer header of a new owner session of `app`. */
|
|
async function owner(app: Awaited<ReturnType<typeof webApp>>["app"]): Promise<Record<string, string>> {
|
|
const issued = await app.host.root(
|
|
async (ctx) =>
|
|
(await ctx.get(Sessions, {})).issue({ principal: { kind: "owner" }, label: "t", kind: "device" }, ctx),
|
|
context,
|
|
);
|
|
return { authorization: `Bearer ${issued.token}` };
|
|
}
|
|
|
|
/** The `src`s and `href`s of a page, in order. */
|
|
function urlsOf(html: string): string[] {
|
|
return [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map(([, url]) => url!);
|
|
}
|
|
|
|
describe("the base bundle and the page", () => {
|
|
it("serves the page with the bootstrap, the scripts and the stylesheets; the files forever", async () => {
|
|
const { app, port } = await webApp({ plugins: { notes: WEB_PLUGIN } });
|
|
const headers = await owner(app);
|
|
const answer = await send(port, "GET", "/", { headers });
|
|
expect(answer.status).toBe(200);
|
|
const html = answer.body.toString();
|
|
const nonce = /script-src [^;]*'nonce-([^']+)'/.exec(String(answer.headers["content-security-policy"]))![1]!;
|
|
expect(html).toContain(`<script nonce="${nonce}">globalThis.chordPage={base:"${app.web!.base}"`);
|
|
expect(String(answer.headers["content-security-policy"])).toContain("style-src 'self'");
|
|
// Scripts from the surface's files and the nonce only, not any same-origin response (Opus L8).
|
|
expect(String(answer.headers["content-security-policy"])).toContain(
|
|
`script-src http://127.0.0.1:${port}/_chord/web/ 'nonce-${nonce}'`,
|
|
);
|
|
const urls = urlsOf(html);
|
|
expect(urls).toEqual([
|
|
`/_chord/web/base.${app.web!.base}.css`,
|
|
expect.stringMatching(/^\/_chord\/web\/plugin\/@test~notes\.[0-9a-f]+\.css$/),
|
|
expect.stringMatching(/^\/_chord\/web\/plugins\.[0-9a-f]{16}\.js$/),
|
|
`/_chord/web/base.${app.web!.base}.js`,
|
|
]);
|
|
for (const url of urls) {
|
|
const file = await send(port, "GET", url, { headers });
|
|
expect(file.status).toBe(200);
|
|
expect(file.headers["cache-control"]).toBe("private, max-age=31536000, immutable");
|
|
}
|
|
const base = await send(port, "GET", urls[3]!, { headers });
|
|
expect(base.headers["content-type"]).toBe("text/javascript; charset=utf-8");
|
|
expect(base.body.toString()).toContain("globalThis.started=!0");
|
|
expect((await send(port, "GET", urls[0]!, { headers })).body.toString()).toContain("--brand:");
|
|
await app.close(context);
|
|
});
|
|
|
|
it("answers the current base hash publicly, and refuses the page and files to a request without a session", async () => {
|
|
const { app, port } = await webApp();
|
|
const base = await send(port, "GET", "/_chord/web/base");
|
|
expect([base.status, JSON.parse(base.body.toString()), base.headers["cache-control"]]).toEqual([
|
|
200,
|
|
app.web!.base,
|
|
"no-store",
|
|
]);
|
|
expect((await send(port, "GET", "/")).status).toBe(401);
|
|
expect((await send(port, "GET", `/_chord/web/base.${app.web!.base}.js`)).status).toBe(401);
|
|
await app.close(context);
|
|
const open = await webApp({ public: true });
|
|
expect((await send(open.port, "GET", "/")).status).toBe(200);
|
|
await open.app.close(context);
|
|
});
|
|
|
|
it("rebuilds the base bundle at each start: the same code gives the same hash, other code another", async () => {
|
|
const main = await entry();
|
|
const first = await webApp({ main });
|
|
const hash = first.app.web!.base;
|
|
await first.app.close(context);
|
|
const again = await testApp({
|
|
dataDir: first.dataDir,
|
|
root: first.root,
|
|
kinds: { server: { platform: "node" }, web: { platform: "browser" } },
|
|
web: { entry: main, page },
|
|
});
|
|
expect(again.app.web!.base).toBe(hash);
|
|
await again.app.close(context);
|
|
await writeFile(main, 'import "./style.css";\nglobalThis.started = "changed";\n');
|
|
const changed = await testApp({
|
|
dataDir: first.dataDir,
|
|
root: first.root,
|
|
kinds: { server: { platform: "node" }, web: { platform: "browser" } },
|
|
web: { entry: main, page },
|
|
});
|
|
expect(changed.app.web!.base).not.toBe(hash);
|
|
await changed.app.close(context);
|
|
});
|
|
});
|
|
|
|
describe("plugin scripts", () => {
|
|
it("serves each web facet as a script that registers itself, and the set as one combined file; no eval", async () => {
|
|
const { app, port } = await webApp({ plugins: { notes: WEB_PLUGIN } });
|
|
const headers = await owner(app);
|
|
const scripts = await app.host.get(WebScripts, {}, context);
|
|
const set = scripts.set.value;
|
|
expect(set.base).toBe(app.web!.base);
|
|
expect(set.plugins).toEqual([
|
|
{
|
|
name: "@test/notes",
|
|
digest: expect.stringMatching(/^[0-9a-f]+$/),
|
|
url: `/_chord/web/plugin/@test~notes.${set.plugins[0]!.digest}.js`,
|
|
css: `/_chord/web/plugin/@test~notes.${set.plugins[0]!.digest}.css`,
|
|
},
|
|
]);
|
|
const script = (await send(port, "GET", set.plugins[0]!.url, { headers })).body.toString();
|
|
expect(
|
|
script.startsWith(
|
|
`chordFacet("@test/notes","${set.plugins[0]!.digest}",(function (require, module, exports) {`,
|
|
),
|
|
).toBe(true);
|
|
expect(script).not.toMatch(/\beval\(|new Function/);
|
|
const combined = (await send(port, "GET", set.combined, { headers })).body.toString();
|
|
// The combined file without source maps (a browser reads one per file); each script keeps its own (Fable L8).
|
|
expect(script).toMatch(/\n\/\/# sourceMappingURL=data:application\/json;base64,/);
|
|
expect(combined).not.toContain("sourceMappingURL");
|
|
expect(combined).toBe(script.replace(/\n\/\/# sourceMappingURL=data:[^\n]*/, ""));
|
|
scripts.release();
|
|
await app.close(context);
|
|
});
|
|
|
|
it("keeps a plugin's assets while it keeps its old script after failed builds (Opus L4, Fable M3)", async () => {
|
|
const pictured = {
|
|
"web.ts":
|
|
'import { defineFacet } from "@test/app/v1";\nimport icon from "./icon.svg?url";\nexport default defineFacet(() => { void icon; });',
|
|
"icon.svg": "<svg/>",
|
|
};
|
|
const { app, port, root } = await webApp({ plugins: { pictured, notes: WEB_PLUGIN } });
|
|
const headers = await owner(app);
|
|
const asset = (await app.host.get(WebScripts, {}, context)).set.value;
|
|
const script = (
|
|
await send(port, "GET", asset.plugins.find(({ name }) => name === "@test/pictured")!.url, { headers })
|
|
).body.toString();
|
|
const url = /"(\/_chord\/web\/assets\/icon-[^"]+)"/.exec(script)![1]!;
|
|
await writeFile(join(root, "pictured", "web.ts"), "export default {");
|
|
for (let round = 0; round < 6; round++) {
|
|
await writeFile(join(root, "notes", "notes.css"), `.notes { order: ${round}; }`);
|
|
while (!app.plugins.state.value.dirty.includes("@test/notes"))
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
await app.applyChanges(context);
|
|
}
|
|
expect((await send(port, "GET", url, { headers })).status).toBe(200);
|
|
await app.close(context);
|
|
});
|
|
|
|
it("publishes a new set on a rollout without waiting for clients; the previous files stay served", async () => {
|
|
const { app, port, root } = await webApp({ plugins: { notes: WEB_PLUGIN } });
|
|
const headers = await owner(app);
|
|
const scripts = await app.host.get(WebScripts, {}, context);
|
|
const before = scripts.set.value;
|
|
await writeFile(join(root, "notes", "notes.css"), ".notes { color: var(--fg); }");
|
|
while (!app.plugins.state.value.dirty.includes("@test/notes"))
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
const result = await app.applyChanges(context);
|
|
expect(result.steps.map(({ name }) => name)).toEqual(["server", "web"]);
|
|
expect(result.steps[1]!.hosts).toEqual([
|
|
{
|
|
host: "web",
|
|
report: { plugins: { added: [], changed: ["@test/notes"], removed: [] }, failed: [], objects: [] },
|
|
},
|
|
]);
|
|
const after = scripts.set.value;
|
|
expect(after.plugins[0]!.digest).not.toBe(before.plugins[0]!.digest);
|
|
expect(app.plugins.state.value.dirty).toEqual([]);
|
|
for (const url of [before.plugins[0]!.url, before.combined, after.plugins[0]!.url, after.combined]) {
|
|
expect((await send(port, "GET", url, { headers })).status).toBe(200);
|
|
}
|
|
scripts.release();
|
|
await app.close(context);
|
|
});
|
|
});
|