@norskvideo/ctl-dev-kit 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ export type Step = {
2
+ slug: string;
3
+ file: string;
4
+ head: string;
5
+ desc: string;
6
+ };
7
+ export type ManualPage = {
8
+ id: string;
9
+ nav: string;
10
+ title: string;
11
+ kind: "fn" | "ex";
12
+ tag?: string;
13
+ intro: string;
14
+ steps: Step[];
15
+ links: string[];
16
+ thin?: boolean;
17
+ };
18
+ export interface ManualOverview {
19
+ /** The overview <h1>. */
20
+ headline: string;
21
+ /** The lead paragraph under it. */
22
+ lead: string;
23
+ /** The hero capture (a representative shot) and its caption. */
24
+ hero: {
25
+ slug: string;
26
+ file: string;
27
+ alt: string;
28
+ caption: string;
29
+ };
30
+ /** Left column heading of the two-up index. Default "By function". */
31
+ byFunctionHeading?: string;
32
+ /** Right column heading. Default "By example". */
33
+ byExampleHeading?: string;
34
+ /** The small print under the right column heading. */
35
+ byExampleIntro: string;
36
+ }
37
+ export interface ManualSpec {
38
+ /** Sidebar wordmark + document brand. */
39
+ brand: string;
40
+ /** The full <title> for both the fragment and the standalone document. */
41
+ title: string;
42
+ overview: ManualOverview;
43
+ functionality: ManualPage[];
44
+ examples: ManualPage[];
45
+ }
46
+ export interface BuildManualResult {
47
+ /** Artifact-ready fragment (no doctype/head/body). */
48
+ indexHtml: string;
49
+ /** Complete standalone document wrapping the fragment. */
50
+ standaloneHtml: string;
51
+ /** `<slug>/<file>` of every referenced capture not found on disk. */
52
+ missing: string[];
53
+ }
54
+ export declare function buildManual(spec: ManualSpec, opts: {
55
+ docsRoot: string;
56
+ }): BuildManualResult;
@@ -13,120 +13,63 @@
13
13
  // Product-specific content — the page arrays, the overview copy, the brand — is
14
14
  // supplied by the caller in `ManualSpec`; everything structural (layout, CSS,
15
15
  // routing) lives here so every product's manual looks and behaves the same.
16
-
17
16
  import { spawnSync } from "node:child_process";
18
17
  import { existsSync } from "node:fs";
19
18
  import { join } from "node:path";
20
-
21
- export type Step = { slug: string; file: string; head: string; desc: string };
22
-
23
- export type ManualPage = {
24
- id: string;
25
- nav: string;
26
- title: string;
27
- kind: "fn" | "ex";
28
- tag?: string;
29
- intro: string;
30
- steps: Step[];
31
- links: string[];
32
- thin?: boolean;
33
- };
34
-
35
- export interface ManualOverview {
36
- /** The overview <h1>. */
37
- headline: string;
38
- /** The lead paragraph under it. */
39
- lead: string;
40
- /** The hero capture (a representative shot) and its caption. */
41
- hero: { slug: string; file: string; alt: string; caption: string };
42
- /** Left column heading of the two-up index. Default "By function". */
43
- byFunctionHeading?: string;
44
- /** Right column heading. Default "By example". */
45
- byExampleHeading?: string;
46
- /** The small print under the right column heading. */
47
- byExampleIntro: string;
48
- }
49
-
50
- export interface ManualSpec {
51
- /** Sidebar wordmark + document brand. */
52
- brand: string;
53
- /** The full <title> for both the fragment and the standalone document. */
54
- title: string;
55
- overview: ManualOverview;
56
- functionality: ManualPage[];
57
- examples: ManualPage[];
58
- }
59
-
60
- export interface BuildManualResult {
61
- /** Artifact-ready fragment (no doctype/head/body). */
62
- indexHtml: string;
63
- /** Complete standalone document wrapping the fragment. */
64
- standaloneHtml: string;
65
- /** `<slug>/<file>` of every referenced capture not found on disk. */
66
- missing: string[];
67
- }
68
-
69
- const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
70
-
19
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
71
20
  /** Resize a captured PNG to <=1100px wide JPEG and return a data URI. Missing
72
21
  * captures become a labelled placeholder so the manual still builds. */
73
- function makeUri(docsRoot: string, slug: string, file: string, missing: string[]): string {
74
- const path = join(docsRoot, slug, file);
75
- if (!existsSync(path)) {
76
- missing.push(`${slug}/${file}`);
77
- const label = esc(`missing: ${slug}/${file}`);
78
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="200"><rect width="100%" height="100%" fill="#1a2230"/><text x="50%" y="50%" fill="#6b7787" font-family="monospace" font-size="15" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
79
- return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
80
- }
81
- const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
82
- maxBuffer: 64 * 1024 * 1024,
83
- });
84
- if (r.status !== 0 || !r.stdout?.length) {
85
- throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
86
- }
87
- return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
22
+ function makeUri(docsRoot, slug, file, missing) {
23
+ const path = join(docsRoot, slug, file);
24
+ if (!existsSync(path)) {
25
+ missing.push(`${slug}/${file}`);
26
+ const label = esc(`missing: ${slug}/${file}`);
27
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="200"><rect width="100%" height="100%" fill="#1a2230"/><text x="50%" y="50%" fill="#6b7787" font-family="monospace" font-size="15" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
28
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
29
+ }
30
+ const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
31
+ maxBuffer: 64 * 1024 * 1024,
32
+ });
33
+ if (r.status !== 0 || !r.stdout?.length) {
34
+ throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
35
+ }
36
+ return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
88
37
  }
89
-
90
- export function buildManual(spec: ManualSpec, opts: { docsRoot: string }): BuildManualResult {
91
- const { docsRoot } = opts;
92
- const missing: string[] = [];
93
- const uri = (slug: string, file: string) => makeUri(docsRoot, slug, file, missing);
94
-
95
- const FN = spec.functionality;
96
- const EX = spec.examples;
97
- const ALL = [...FN, ...EX];
98
- const byId = new Map(ALL.map((p) => [p.id, p]));
99
-
100
- function pageHtml(p: ManualPage): string {
101
- const steps = p.steps
102
- .map(
103
- (s, i) => `<li class="step">
38
+ export function buildManual(spec, opts) {
39
+ const { docsRoot } = opts;
40
+ const missing = [];
41
+ const uri = (slug, file) => makeUri(docsRoot, slug, file, missing);
42
+ const FN = spec.functionality;
43
+ const EX = spec.examples;
44
+ const ALL = [...FN, ...EX];
45
+ const byId = new Map(ALL.map((p) => [p.id, p]));
46
+ function pageHtml(p) {
47
+ const steps = p.steps
48
+ .map((s, i) => `<li class="step">
104
49
  <div class="stinfo"><span class="stnum">${String(i + 1).padStart(2, "0")}</span><div><h3>${esc(s.head)}</h3><p>${esc(s.desc)}</p></div></div>
105
50
  <div class="shot"><img loading="lazy" src="${uri(s.slug, s.file)}" alt="${esc(s.head)}"></div>
106
- </li>`,
107
- )
108
- .join("\n");
109
- const links = p.links.length
110
- ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
111
- .map((id) => {
112
- const t = byId.get(id);
113
- return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
114
- })
115
- .join("")}</div>`
116
- : "";
117
- const thin = p.thin
118
- ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
119
- : "";
120
- return `<article class="page" id="${p.id}">
51
+ </li>`)
52
+ .join("\n");
53
+ const links = p.links.length
54
+ ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
55
+ .map((id) => {
56
+ const t = byId.get(id);
57
+ return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
58
+ })
59
+ .join("")}</div>`
60
+ : "";
61
+ const thin = p.thin
62
+ ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
63
+ : "";
64
+ return `<article class="page" id="${p.id}">
121
65
  <div class="phead"><span class="kind ${p.kind}">${p.kind === "fn" ? "Functionality" : "Example deployment"}</span><h1>${esc(p.title)}</h1>${p.tag ? `<div class="ptag mono">${esc(p.tag)}</div>` : ""}</div>
122
66
  <p class="lead">${esc(p.intro)}</p>
123
67
  <ol class="steps">${steps}</ol>
124
68
  ${thin}${links}
125
69
  </article>`;
126
- }
127
-
128
- const ov = spec.overview;
129
- const overview = `<article class="page" id="overview">
70
+ }
71
+ const ov = spec.overview;
72
+ const overview = `<article class="page" id="overview">
130
73
  <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
131
74
  <p class="lead">${esc(ov.lead)}</p>
132
75
  <div class="heroshot"><div class="shot"><img src="${uri(ov.hero.slug, ov.hero.file)}" alt="${esc(ov.hero.alt)}"></div><p class="hcap">${esc(ov.hero.caption)}</p></div>
@@ -135,13 +78,11 @@ export function buildManual(spec: ManualSpec, opts: { docsRoot: string }): Build
135
78
  <div><h2>${esc(ov.byExampleHeading ?? "By example")}</h2><p class="mini">${esc(ov.byExampleIntro)}</p><div class="linklist">${EX.map((p) => `<a href="#${p.id}">${esc(p.nav)}</a>`).join("")}</div></div>
136
79
  </div>
137
80
  </article>`;
138
-
139
- const nav = `
81
+ const nav = `
140
82
  <a href="#overview" data-nav class="ovlink">Overview</a>
141
83
  <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
142
84
  <div class="ngroup"><div class="ghead">Examples <span class="ct">${EX.length}</span></div>${EX.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>`;
143
-
144
- const html = `<title>${esc(spec.title)}</title>
85
+ const html = `<title>${esc(spec.title)}</title>
145
86
  <style>
146
87
  :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
147
88
  --line:#212a36;--accent:#54d6cf;--measure:64ch}
@@ -227,8 +168,7 @@ ${ALL.map(pageHtml).join("\n")}
227
168
  window.addEventListener('hashchange',show);show();
228
169
  })();
229
170
  </script>`;
230
-
231
- const standaloneHtml = `<!doctype html>
171
+ const standaloneHtml = `<!doctype html>
232
172
  <html lang="en">
233
173
  <head>
234
174
  <meta charset="utf-8" />
@@ -239,6 +179,5 @@ ${ALL.map(pageHtml).join("\n")}
239
179
  ${html}
240
180
  </body>
241
181
  </html>`;
242
-
243
- return { indexHtml: html, standaloneHtml, missing };
182
+ return { indexHtml: html, standaloneHtml, missing };
244
183
  }
@@ -0,0 +1,57 @@
1
+ import type { Page } from "@playwright/test";
2
+ export type UiEntry = {
3
+ kind: "section";
4
+ heading: string;
5
+ } | {
6
+ kind: "step";
7
+ heading: string;
8
+ description?: string;
9
+ } | {
10
+ kind: "text";
11
+ prose: string;
12
+ } | {
13
+ kind: "capture";
14
+ caption: string;
15
+ filename: string;
16
+ };
17
+ export interface UiFixture {
18
+ type: "ui";
19
+ title: string;
20
+ entries: UiEntry[];
21
+ }
22
+ /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
23
+ * relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
24
+ * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
25
+ * config that sets the env var at eval time is honoured. */
26
+ export declare function docsRoot(): string;
27
+ /** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
28
+ * `spotlight` keeps the whole screen for context but dims everything except the
29
+ * named region and outlines it. Omit both for a plain full-page capture. */
30
+ export interface CaptureTreatment {
31
+ crop?: string;
32
+ spotlight?: string;
33
+ /** Slack in px around a spotlight region before the dimming starts. */
34
+ pad?: number;
35
+ }
36
+ /** Produce the screenshot bytes for a capture, applying its treatment. Split out
37
+ * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
38
+ * so the treatment geometry is testable on its own. */
39
+ export declare function screenshotFor(page: Page, treat?: CaptureTreatment): Promise<Buffer>;
40
+ /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
41
+ * stored entries.json without re-running the browser. */
42
+ export declare function renderUiMarkdown(title: string, entries: UiEntry[]): string;
43
+ export declare class DocGuide {
44
+ private slug;
45
+ private title;
46
+ private entries;
47
+ private captureIndex;
48
+ constructor(slug: string, title: string);
49
+ section(heading: string): void;
50
+ step(heading: string, description?: string): void;
51
+ text(prose: string): void;
52
+ /** Screenshot the page and write it immediately under the slug's docs dir. Pass
53
+ * a treatment to crop to one component or spotlight a region in context. */
54
+ capture(page: Page, caption: string, treat?: CaptureTreatment): Promise<void>;
55
+ /** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
56
+ flush(): Promise<void>;
57
+ }
@@ -0,0 +1,128 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
4
+ * relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
5
+ * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
6
+ * config that sets the env var at eval time is honoured. */
7
+ export function docsRoot() {
8
+ const override = process.env.NORSK_DOCS_ROOT;
9
+ if (override)
10
+ return isAbsolute(override) ? override : resolve(process.cwd(), override);
11
+ return resolve(process.cwd(), "docs/generated");
12
+ }
13
+ const SPOTLIGHT_ID = "docguide-spotlight";
14
+ /** Dim the page except the target region, drawn as one fixed box whose oversized
15
+ * box-shadow does the dimming — appended to <body> so no ancestor `overflow`
16
+ * clips it. Scrolls the target into view first so the lit region is on-screen. */
17
+ async function spotlightOn(page, selector, pad) {
18
+ await page.locator(selector).first().scrollIntoViewIfNeeded();
19
+ await page.evaluate(({ selector, pad, id }) => {
20
+ const el = document.querySelector(selector);
21
+ if (!el)
22
+ throw new Error(`spotlight target not found: ${selector}`);
23
+ const r = el.getBoundingClientRect();
24
+ const box = document.createElement("div");
25
+ box.id = id;
26
+ Object.assign(box.style, {
27
+ position: "fixed",
28
+ left: `${Math.max(0, r.left - pad)}px`,
29
+ top: `${Math.max(0, r.top - pad)}px`,
30
+ width: `${r.width + pad * 2}px`,
31
+ height: `${r.height + pad * 2}px`,
32
+ border: "2px solid #54d6cf",
33
+ borderRadius: "10px",
34
+ boxShadow: "0 0 0 4000px rgba(6,9,13,0.62)",
35
+ zIndex: "2147483647",
36
+ pointerEvents: "none",
37
+ });
38
+ document.body.appendChild(box);
39
+ }, { selector, pad, id: SPOTLIGHT_ID });
40
+ }
41
+ async function spotlightOff(page) {
42
+ await page.evaluate((id) => document.getElementById(id)?.remove(), SPOTLIGHT_ID);
43
+ }
44
+ /** Produce the screenshot bytes for a capture, applying its treatment. Split out
45
+ * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
46
+ * so the treatment geometry is testable on its own. */
47
+ export async function screenshotFor(page, treat) {
48
+ // Wait for web fonts, else the snapshot catches a fallback font mid-swap.
49
+ await page.evaluate(() => document.fonts.ready);
50
+ if (treat?.crop)
51
+ return page.locator(treat.crop).first().screenshot();
52
+ if (treat?.spotlight) {
53
+ await spotlightOn(page, treat.spotlight, treat.pad ?? 8);
54
+ try {
55
+ return await page.screenshot();
56
+ }
57
+ finally {
58
+ await spotlightOff(page);
59
+ }
60
+ }
61
+ return page.screenshot({ fullPage: true });
62
+ }
63
+ /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
64
+ * stored entries.json without re-running the browser. */
65
+ export function renderUiMarkdown(title, entries) {
66
+ const lines = ["---", `title: ${title}`, "---", "", `# ${title}`, ""];
67
+ for (const entry of entries) {
68
+ switch (entry.kind) {
69
+ case "section":
70
+ lines.push(`## ${entry.heading}`, "");
71
+ break;
72
+ case "step":
73
+ lines.push(`### ${entry.heading}`, "");
74
+ if (entry.description)
75
+ lines.push(entry.description, "");
76
+ break;
77
+ case "text":
78
+ lines.push(entry.prose, "");
79
+ break;
80
+ case "capture":
81
+ lines.push(`![${entry.caption}](./${entry.filename})`, "");
82
+ break;
83
+ }
84
+ }
85
+ return lines.join("\n");
86
+ }
87
+ export class DocGuide {
88
+ slug;
89
+ title;
90
+ entries = [];
91
+ captureIndex = 0;
92
+ constructor(slug, title) {
93
+ this.slug = slug;
94
+ this.title = title;
95
+ }
96
+ section(heading) {
97
+ this.entries.push({ kind: "section", heading });
98
+ }
99
+ step(heading, description) {
100
+ this.entries.push({ kind: "step", heading, description });
101
+ }
102
+ text(prose) {
103
+ this.entries.push({ kind: "text", prose });
104
+ }
105
+ /** Screenshot the page and write it immediately under the slug's docs dir. Pass
106
+ * a treatment to crop to one component or spotlight a region in context. */
107
+ async capture(page, caption, treat) {
108
+ if (process.env.DOCS_GENERATE !== "1")
109
+ return;
110
+ this.captureIndex++;
111
+ const filename = `${this.slug}-${String(this.captureIndex).padStart(3, "0")}.png`;
112
+ const buffer = await screenshotFor(page, treat);
113
+ const outDir = join(docsRoot(), this.slug);
114
+ mkdirSync(outDir, { recursive: true });
115
+ writeFileSync(join(outDir, filename), buffer);
116
+ this.entries.push({ kind: "capture", caption, filename });
117
+ }
118
+ /** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
119
+ async flush() {
120
+ if (process.env.DOCS_GENERATE !== "1")
121
+ return;
122
+ const outDir = join(docsRoot(), this.slug);
123
+ mkdirSync(outDir, { recursive: true });
124
+ const fixture = { type: "ui", title: this.title, entries: this.entries };
125
+ writeFileSync(join(outDir, "entries.json"), `${JSON.stringify(fixture, null, 2)}\n`);
126
+ writeFileSync(join(outDir, "index.md"), renderUiMarkdown(this.title, this.entries));
127
+ }
128
+ }
@@ -0,0 +1,19 @@
1
+ import { type PlaywrightTestConfig } from "@playwright/test";
2
+ export interface GuidesConfigOptions {
3
+ /** Vite port. Pick one clear of the product's dev servers and sibling sessions. */
4
+ port: number;
5
+ /** Absolute path to the repo-level docs/generated dir. Exported as NORSK_DOCS_ROOT. */
6
+ docsRoot: string;
7
+ /** Capture viewport. Default 1440x960. */
8
+ viewport?: {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ /** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
13
+ command?: string;
14
+ /** Playwright testDir. Default "./tests/guides". */
15
+ testDir?: string;
16
+ /** Per-test timeout in ms. Default 60_000. */
17
+ timeout?: number;
18
+ }
19
+ export declare function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig;
@@ -0,0 +1,28 @@
1
+ import { defineConfig } from "@playwright/test";
2
+ export function guidesConfig(opts) {
3
+ // Exported before the test files import the dev-kit DocGuide, so its deferred
4
+ // docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
5
+ process.env.NORSK_DOCS_ROOT = opts.docsRoot;
6
+ const baseURL = `http://127.0.0.1:${opts.port}`;
7
+ const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
8
+ return defineConfig({
9
+ testDir: opts.testDir ?? "./tests/guides",
10
+ workers: 1,
11
+ timeout: opts.timeout ?? 60_000,
12
+ use: {
13
+ baseURL,
14
+ browserName: "chromium",
15
+ headless: true,
16
+ viewport: opts.viewport ?? { width: 1440, height: 960 },
17
+ ...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
18
+ },
19
+ webServer: {
20
+ command,
21
+ url: baseURL,
22
+ reuseExistingServer: !process.env.CI,
23
+ timeout: 120_000, // cold CI runners are slow to first-serve
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,16 @@
1
+ export interface InstanceProxy {
2
+ /** Origin of the proxy, e.g. http://localhost:53421 */
3
+ origin: string;
4
+ /** Full dashboard URL for a hash-routed page, under the advertised prefix. */
5
+ dashboardUrl: (page?: string) => string;
6
+ stop: () => void;
7
+ }
8
+ /** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
9
+ * studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
10
+ * (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
11
+ * path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
12
+ export declare function startInstanceProxy(opts: {
13
+ studioHostPort: number;
14
+ instanceId: string;
15
+ dashboardKey: string;
16
+ }): InstanceProxy;
@@ -0,0 +1,90 @@
1
+ // A tiny per-instance reverse proxy for the engine-tier doc-guides.
2
+ //
3
+ // A product's baked operator dashboard is built to run behind the runner's oauth2
4
+ // proxy: its `env` endpoint advertises instance-scoped paths
5
+ // (`apiBasePath: /instance/<id>/live/api`, `wsBasePath: /instance/<id>/live`).
6
+ // The harness, though, publishes Studio DIRECTLY on studioHostPort with no
7
+ // `/instance/<id>` prefix — so those advertised paths 404 and the console never
8
+ // leaves its "workflow starting up" splash.
9
+ //
10
+ // Rather than drag the whole oauth2 proxy + TLS + port 443 into a doc run, this
11
+ // serves the dashboard under exactly the prefix `env` advertises and strips it
12
+ // before forwarding to studioHostPort — for both HTTP and the live-state
13
+ // WebSocket (`useLiveComponent`), which is what carries programReceiving / audio
14
+ // levels and therefore the ON AIR badge. Plain HTTP, ephemeral port, self-owned.
15
+ // Response headers that describe the upstream transfer encoding; fetch() has
16
+ // already decoded the body, so forwarding these would misdescribe what we send.
17
+ const STRIP_RESPONSE_HEADERS = ["content-encoding", "content-length", "transfer-encoding"];
18
+ /** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
19
+ * studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
20
+ * (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
21
+ * path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
22
+ export function startInstanceProxy(opts) {
23
+ const prefix = `/instance/${opts.instanceId}`;
24
+ const httpUpstream = `http://localhost:${opts.studioHostPort}`;
25
+ const strip = (pathname) => pathname === prefix ? "/" : pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
26
+ const server = Bun.serve({
27
+ port: 0,
28
+ async fetch(req, srv) {
29
+ const u = new URL(req.url);
30
+ const path = strip(u.pathname);
31
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
32
+ const upstreamUrl = `ws://localhost:${opts.studioHostPort}${path}${u.search}`;
33
+ const ok = srv.upgrade(req, { data: { upstreamUrl, upstream: null, queue: [] } });
34
+ return ok ? undefined : new Response("ws upgrade failed", { status: 400 });
35
+ }
36
+ const headers = new Headers(req.headers);
37
+ headers.delete("host");
38
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
39
+ const upstream = await fetch(`${httpUpstream}${path}${u.search}`, {
40
+ method: req.method,
41
+ headers,
42
+ body,
43
+ redirect: "manual",
44
+ });
45
+ const outHeaders = new Headers(upstream.headers);
46
+ for (const h of STRIP_RESPONSE_HEADERS)
47
+ outHeaders.delete(h);
48
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
49
+ },
50
+ websocket: {
51
+ open(ws) {
52
+ const up = new WebSocket(ws.data.upstreamUrl);
53
+ up.binaryType = "arraybuffer";
54
+ ws.data.upstream = up;
55
+ up.onopen = () => {
56
+ for (const m of ws.data.queue)
57
+ up.send(m);
58
+ ws.data.queue = [];
59
+ };
60
+ up.onmessage = (e) => ws.send(e.data);
61
+ up.onclose = (e) => ws.close(e.code || 1000, e.reason);
62
+ up.onerror = () => {
63
+ try {
64
+ ws.close();
65
+ }
66
+ catch { }
67
+ };
68
+ },
69
+ message(ws, message) {
70
+ const up = ws.data.upstream;
71
+ if (up && up.readyState === WebSocket.OPEN)
72
+ up.send(message);
73
+ else
74
+ ws.data.queue.push(message);
75
+ },
76
+ close(ws) {
77
+ try {
78
+ ws.data.upstream?.close();
79
+ }
80
+ catch { }
81
+ },
82
+ },
83
+ });
84
+ const origin = `http://localhost:${server.port}`;
85
+ return {
86
+ origin,
87
+ dashboardUrl: (page = "onair") => `${origin}${prefix}/dashboard/${opts.dashboardKey}/#/${page}`,
88
+ stop: () => server.stop(true),
89
+ };
90
+ }
@@ -0,0 +1,15 @@
1
+ import { type Browser, type BrowserContext, type Page } from "@playwright/test";
2
+ export interface LiveBrowser {
3
+ browser: Browser;
4
+ context: BrowserContext;
5
+ page: Page;
6
+ }
7
+ /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
8
+ * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
9
+ * --no-sandbox because the nix chromium has no setuid sandbox helper. */
10
+ export declare function launchLiveBrowser(opts?: {
11
+ viewport?: {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ }): Promise<LiveBrowser>;
@@ -0,0 +1,21 @@
1
+ // Engine-tier doc-guide browser support. The fixture-tier guides run under the
2
+ // @playwright/test runner against a Vite dev server; the engine tier instead
3
+ // drives a REAL launched instance from inside a bun:test that already owns the
4
+ // product harness lifecycle. So we launch a raw chromium here (the same nixpkgs
5
+ // chromium the fixture tier uses, via BROWSER_FOR_TESTING) rather than go through
6
+ // the test-runner's webServer model.
7
+ import { chromium } from "@playwright/test";
8
+ /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
9
+ * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
10
+ * --no-sandbox because the nix chromium has no setuid sandbox helper. */
11
+ export async function launchLiveBrowser(opts) {
12
+ const executablePath = process.env.BROWSER_FOR_TESTING;
13
+ if (!executablePath) {
14
+ throw new Error("BROWSER_FOR_TESTING is unset — run inside `nix develop` so chromium is on offer");
15
+ }
16
+ const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
17
+ const viewport = opts?.viewport ?? { width: 1440, height: 960 };
18
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
19
+ const page = await context.newPage();
20
+ return { browser, context, page };
21
+ }
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
7
7
  "./testing/invariants": "./testing/invariants.ts",
8
8
  "./testing/byte-snapshot": "./testing/byte-snapshot.ts",
9
9
  "./create-product": "./create-product/create-product.ts",
10
- "./doc-guide": "./doc-guide/doc-guide.ts",
11
- "./doc-guide/build-manual": "./doc-guide/build-manual.ts",
12
- "./doc-guide/guides-config": "./doc-guide/guides-config.ts",
13
- "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.ts",
14
- "./doc-guide/live-browser": "./doc-guide/live-browser.ts"
10
+ "./doc-guide": "./doc-guide/doc-guide.js",
11
+ "./doc-guide/build-manual": "./doc-guide/build-manual.js",
12
+ "./doc-guide/guides-config": "./doc-guide/guides-config.js",
13
+ "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
14
+ "./doc-guide/live-browser": "./doc-guide/live-browser.js"
15
15
  },
16
16
  "bin": {
17
17
  "ctl-dev-kit": "./create-product/cli.ts"
@@ -1,174 +0,0 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, join, resolve } from "node:path";
3
- import type { Page } from "@playwright/test";
4
-
5
- // Shared support for a product repo's Playwright/engine doc-guides. A guide drives
6
- // a real surface (a configure SPA, an operator dashboard, a launched instance) and
7
- // narrates itself; entries render to an illustrated markdown manual under
8
- // <repo>/docs/generated/<slug>/. Screenshots can never depict a UI that doesn't
9
- // exist, because they come from a real, asserted run.
10
- //
11
- // Extracted from norsk-commentary (which took it from norsk-ctl) into the dev kit
12
- // so every ctl product shoots its manual the same way. The one thing that had to
13
- // change on the way in: the old copy resolved docs/generated RELATIVE TO ITS OWN
14
- // FILE, which worked only while it lived in the repo. Living in node_modules it
15
- // cannot, so the docs root is now explicit — NORSK_DOCS_ROOT if set, else
16
- // <cwd>/docs/generated. A fixture runner whose cwd is a package subdir (e.g.
17
- // dashboards/<slug>) MUST export NORSK_DOCS_ROOT to the repo-level dir; the
18
- // guidesConfig() factory does this for you. Resolution is deferred to call time
19
- // so the env var need only be set before the first capture, not before import.
20
-
21
- export type UiEntry =
22
- | { kind: "section"; heading: string }
23
- | { kind: "step"; heading: string; description?: string }
24
- | { kind: "text"; prose: string }
25
- | { kind: "capture"; caption: string; filename: string };
26
-
27
- export interface UiFixture {
28
- type: "ui";
29
- title: string;
30
- entries: UiEntry[];
31
- }
32
-
33
- /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
34
- * relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
35
- * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
36
- * config that sets the env var at eval time is honoured. */
37
- export function docsRoot(): string {
38
- const override = process.env.NORSK_DOCS_ROOT;
39
- if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override);
40
- return resolve(process.cwd(), "docs/generated");
41
- }
42
-
43
- /** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
44
- * `spotlight` keeps the whole screen for context but dims everything except the
45
- * named region and outlines it. Omit both for a plain full-page capture. */
46
- export interface CaptureTreatment {
47
- crop?: string;
48
- spotlight?: string;
49
- /** Slack in px around a spotlight region before the dimming starts. */
50
- pad?: number;
51
- }
52
-
53
- const SPOTLIGHT_ID = "docguide-spotlight";
54
-
55
- /** Dim the page except the target region, drawn as one fixed box whose oversized
56
- * box-shadow does the dimming — appended to <body> so no ancestor `overflow`
57
- * clips it. Scrolls the target into view first so the lit region is on-screen. */
58
- async function spotlightOn(page: Page, selector: string, pad: number): Promise<void> {
59
- await page.locator(selector).first().scrollIntoViewIfNeeded();
60
- await page.evaluate(
61
- ({ selector, pad, id }) => {
62
- const el = document.querySelector(selector);
63
- if (!el) throw new Error(`spotlight target not found: ${selector}`);
64
- const r = el.getBoundingClientRect();
65
- const box = document.createElement("div");
66
- box.id = id;
67
- Object.assign(box.style, {
68
- position: "fixed",
69
- left: `${Math.max(0, r.left - pad)}px`,
70
- top: `${Math.max(0, r.top - pad)}px`,
71
- width: `${r.width + pad * 2}px`,
72
- height: `${r.height + pad * 2}px`,
73
- border: "2px solid #54d6cf",
74
- borderRadius: "10px",
75
- boxShadow: "0 0 0 4000px rgba(6,9,13,0.62)",
76
- zIndex: "2147483647",
77
- pointerEvents: "none",
78
- } satisfies Partial<CSSStyleDeclaration>);
79
- document.body.appendChild(box);
80
- },
81
- { selector, pad, id: SPOTLIGHT_ID },
82
- );
83
- }
84
-
85
- async function spotlightOff(page: Page): Promise<void> {
86
- await page.evaluate((id) => document.getElementById(id)?.remove(), SPOTLIGHT_ID);
87
- }
88
-
89
- /** Produce the screenshot bytes for a capture, applying its treatment. Split out
90
- * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
91
- * so the treatment geometry is testable on its own. */
92
- export async function screenshotFor(page: Page, treat?: CaptureTreatment): Promise<Buffer> {
93
- // Wait for web fonts, else the snapshot catches a fallback font mid-swap.
94
- await page.evaluate(() => document.fonts.ready);
95
- if (treat?.crop) return page.locator(treat.crop).first().screenshot();
96
- if (treat?.spotlight) {
97
- await spotlightOn(page, treat.spotlight, treat.pad ?? 8);
98
- try {
99
- return await page.screenshot();
100
- } finally {
101
- await spotlightOff(page);
102
- }
103
- }
104
- return page.screenshot({ fullPage: true });
105
- }
106
-
107
- /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
108
- * stored entries.json without re-running the browser. */
109
- export function renderUiMarkdown(title: string, entries: UiEntry[]): string {
110
- const lines: string[] = ["---", `title: ${title}`, "---", "", `# ${title}`, ""];
111
- for (const entry of entries) {
112
- switch (entry.kind) {
113
- case "section":
114
- lines.push(`## ${entry.heading}`, "");
115
- break;
116
- case "step":
117
- lines.push(`### ${entry.heading}`, "");
118
- if (entry.description) lines.push(entry.description, "");
119
- break;
120
- case "text":
121
- lines.push(entry.prose, "");
122
- break;
123
- case "capture":
124
- lines.push(`![${entry.caption}](./${entry.filename})`, "");
125
- break;
126
- }
127
- }
128
- return lines.join("\n");
129
- }
130
-
131
- export class DocGuide {
132
- private entries: UiEntry[] = [];
133
- private captureIndex = 0;
134
-
135
- constructor(
136
- private slug: string,
137
- private title: string,
138
- ) {}
139
-
140
- section(heading: string): void {
141
- this.entries.push({ kind: "section", heading });
142
- }
143
-
144
- step(heading: string, description?: string): void {
145
- this.entries.push({ kind: "step", heading, description });
146
- }
147
-
148
- text(prose: string): void {
149
- this.entries.push({ kind: "text", prose });
150
- }
151
-
152
- /** Screenshot the page and write it immediately under the slug's docs dir. Pass
153
- * a treatment to crop to one component or spotlight a region in context. */
154
- async capture(page: Page, caption: string, treat?: CaptureTreatment): Promise<void> {
155
- if (process.env.DOCS_GENERATE !== "1") return;
156
- this.captureIndex++;
157
- const filename = `${this.slug}-${String(this.captureIndex).padStart(3, "0")}.png`;
158
- const buffer = await screenshotFor(page, treat);
159
- const outDir = join(docsRoot(), this.slug);
160
- mkdirSync(outDir, { recursive: true });
161
- writeFileSync(join(outDir, filename), buffer);
162
- this.entries.push({ kind: "capture", caption, filename });
163
- }
164
-
165
- /** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
166
- async flush(): Promise<void> {
167
- if (process.env.DOCS_GENERATE !== "1") return;
168
- const outDir = join(docsRoot(), this.slug);
169
- mkdirSync(outDir, { recursive: true });
170
- const fixture: UiFixture = { type: "ui", title: this.title, entries: this.entries };
171
- writeFileSync(join(outDir, "entries.json"), `${JSON.stringify(fixture, null, 2)}\n`);
172
- writeFileSync(join(outDir, "index.md"), renderUiMarkdown(this.title, this.entries));
173
- }
174
- }
@@ -1,63 +0,0 @@
1
- import { defineConfig, type PlaywrightTestConfig } from "@playwright/test";
2
-
3
- // Playwright config factory for a product's FIXTURE-tier doc-guides: boot the
4
- // product's Vite app alone (no daemon, no license, no Docker) and drive it to
5
- // produce illustrated manual captures under <repo>/docs/generated/<slug>/.
6
- //
7
- // Carries the hard-won CI defaults so every product's runner behaves the same:
8
- // - bind + probe 127.0.0.1 explicitly. On CI runners `localhost` can resolve to
9
- // IPv6 ::1 first while Vite listens on IPv4, so the readiness probe never gets
10
- // 200 and the webServer "times out" though the server is up.
11
- // - a 120s webServer timeout (cold CI runners are slow to first-serve) and piped
12
- // stdout/stderr so a stuck boot shows in the log.
13
- // - export NORSK_DOCS_ROOT so the dev-kit DocGuide writes to the REPO-level
14
- // docs/generated even though the runner's cwd is a package subdir. Set here at
15
- // config-eval time, before any guide imports doc-guide.ts.
16
- //
17
- // Chromium comes from the nix flake via BROWSER_FOR_TESTING; the guide script
18
- // wraps the whole run in with-display.sh to supply an X display.
19
-
20
- export interface GuidesConfigOptions {
21
- /** Vite port. Pick one clear of the product's dev servers and sibling sessions. */
22
- port: number;
23
- /** Absolute path to the repo-level docs/generated dir. Exported as NORSK_DOCS_ROOT. */
24
- docsRoot: string;
25
- /** Capture viewport. Default 1440x960. */
26
- viewport?: { width: number; height: number };
27
- /** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
28
- command?: string;
29
- /** Playwright testDir. Default "./tests/guides". */
30
- testDir?: string;
31
- /** Per-test timeout in ms. Default 60_000. */
32
- timeout?: number;
33
- }
34
-
35
- export function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig {
36
- // Exported before the test files import the dev-kit DocGuide, so its deferred
37
- // docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
38
- process.env.NORSK_DOCS_ROOT = opts.docsRoot;
39
-
40
- const baseURL = `http://127.0.0.1:${opts.port}`;
41
- const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
42
-
43
- return defineConfig({
44
- testDir: opts.testDir ?? "./tests/guides",
45
- workers: 1,
46
- timeout: opts.timeout ?? 60_000,
47
- use: {
48
- baseURL,
49
- browserName: "chromium",
50
- headless: true,
51
- viewport: opts.viewport ?? { width: 1440, height: 960 },
52
- ...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
53
- },
54
- webServer: {
55
- command,
56
- url: baseURL,
57
- reuseExistingServer: !process.env.CI,
58
- timeout: 120_000, // cold CI runners are slow to first-serve
59
- stdout: "pipe",
60
- stderr: "pipe",
61
- },
62
- });
63
- }
@@ -1,111 +0,0 @@
1
- // A tiny per-instance reverse proxy for the engine-tier doc-guides.
2
- //
3
- // A product's baked operator dashboard is built to run behind the runner's oauth2
4
- // proxy: its `env` endpoint advertises instance-scoped paths
5
- // (`apiBasePath: /instance/<id>/live/api`, `wsBasePath: /instance/<id>/live`).
6
- // The harness, though, publishes Studio DIRECTLY on studioHostPort with no
7
- // `/instance/<id>` prefix — so those advertised paths 404 and the console never
8
- // leaves its "workflow starting up" splash.
9
- //
10
- // Rather than drag the whole oauth2 proxy + TLS + port 443 into a doc run, this
11
- // serves the dashboard under exactly the prefix `env` advertises and strips it
12
- // before forwarding to studioHostPort — for both HTTP and the live-state
13
- // WebSocket (`useLiveComponent`), which is what carries programReceiving / audio
14
- // levels and therefore the ON AIR badge. Plain HTTP, ephemeral port, self-owned.
15
-
16
- import type { ServerWebSocket } from "bun";
17
-
18
- interface WsBridge {
19
- upstreamUrl: string;
20
- upstream: WebSocket | null;
21
- queue: (string | ArrayBufferLike | Uint8Array)[];
22
- }
23
-
24
- export interface InstanceProxy {
25
- /** Origin of the proxy, e.g. http://localhost:53421 */
26
- origin: string;
27
- /** Full dashboard URL for a hash-routed page, under the advertised prefix. */
28
- dashboardUrl: (page?: string) => string;
29
- stop: () => void;
30
- }
31
-
32
- // Response headers that describe the upstream transfer encoding; fetch() has
33
- // already decoded the body, so forwarding these would misdescribe what we send.
34
- const STRIP_RESPONSE_HEADERS = ["content-encoding", "content-length", "transfer-encoding"];
35
-
36
- /** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
37
- * studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
38
- * (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
39
- * path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
40
- export function startInstanceProxy(opts: {
41
- studioHostPort: number;
42
- instanceId: string;
43
- dashboardKey: string;
44
- }): InstanceProxy {
45
- const prefix = `/instance/${opts.instanceId}`;
46
- const httpUpstream = `http://localhost:${opts.studioHostPort}`;
47
- const strip = (pathname: string): string =>
48
- pathname === prefix ? "/" : pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
49
-
50
- const server = Bun.serve<WsBridge>({
51
- port: 0,
52
- async fetch(req, srv) {
53
- const u = new URL(req.url);
54
- const path = strip(u.pathname);
55
-
56
- if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
57
- const upstreamUrl = `ws://localhost:${opts.studioHostPort}${path}${u.search}`;
58
- const ok = srv.upgrade(req, { data: { upstreamUrl, upstream: null, queue: [] } });
59
- return ok ? undefined : new Response("ws upgrade failed", { status: 400 });
60
- }
61
-
62
- const headers = new Headers(req.headers);
63
- headers.delete("host");
64
- const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
65
- const upstream = await fetch(`${httpUpstream}${path}${u.search}`, {
66
- method: req.method,
67
- headers,
68
- body,
69
- redirect: "manual",
70
- });
71
- const outHeaders = new Headers(upstream.headers);
72
- for (const h of STRIP_RESPONSE_HEADERS) outHeaders.delete(h);
73
- return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
74
- },
75
- websocket: {
76
- open(ws: ServerWebSocket<WsBridge>) {
77
- const up = new WebSocket(ws.data.upstreamUrl);
78
- up.binaryType = "arraybuffer";
79
- ws.data.upstream = up;
80
- up.onopen = () => {
81
- for (const m of ws.data.queue) up.send(m as string | ArrayBufferLike);
82
- ws.data.queue = [];
83
- };
84
- up.onmessage = (e: MessageEvent) => ws.send(e.data);
85
- up.onclose = (e: CloseEvent) => ws.close(e.code || 1000, e.reason);
86
- up.onerror = () => {
87
- try {
88
- ws.close();
89
- } catch {}
90
- };
91
- },
92
- message(ws: ServerWebSocket<WsBridge>, message: string | Buffer) {
93
- const up = ws.data.upstream;
94
- if (up && up.readyState === WebSocket.OPEN) up.send(message);
95
- else ws.data.queue.push(message);
96
- },
97
- close(ws: ServerWebSocket<WsBridge>) {
98
- try {
99
- ws.data.upstream?.close();
100
- } catch {}
101
- },
102
- },
103
- });
104
-
105
- const origin = `http://localhost:${server.port}`;
106
- return {
107
- origin,
108
- dashboardUrl: (page = "onair") => `${origin}${prefix}/dashboard/${opts.dashboardKey}/#/${page}`,
109
- stop: () => server.stop(true),
110
- };
111
- }
@@ -1,29 +0,0 @@
1
- // Engine-tier doc-guide browser support. The fixture-tier guides run under the
2
- // @playwright/test runner against a Vite dev server; the engine tier instead
3
- // drives a REAL launched instance from inside a bun:test that already owns the
4
- // product harness lifecycle. So we launch a raw chromium here (the same nixpkgs
5
- // chromium the fixture tier uses, via BROWSER_FOR_TESTING) rather than go through
6
- // the test-runner's webServer model.
7
-
8
- import { type Browser, type BrowserContext, chromium, type Page } from "@playwright/test";
9
-
10
- export interface LiveBrowser {
11
- browser: Browser;
12
- context: BrowserContext;
13
- page: Page;
14
- }
15
-
16
- /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
17
- * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
18
- * --no-sandbox because the nix chromium has no setuid sandbox helper. */
19
- export async function launchLiveBrowser(opts?: { viewport?: { width: number; height: number } }): Promise<LiveBrowser> {
20
- const executablePath = process.env.BROWSER_FOR_TESTING;
21
- if (!executablePath) {
22
- throw new Error("BROWSER_FOR_TESTING is unset — run inside `nix develop` so chromium is on offer");
23
- }
24
- const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
25
- const viewport = opts?.viewport ?? { width: 1440, height: 960 };
26
- const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
27
- const page = await context.newPage();
28
- return { browser, context, page };
29
- }