@glasshome/widget-cli 0.8.4 → 0.9.0
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.
- package/bin/cli.ts +13 -0
- package/package.json +20 -3
- package/src/commands/preview.ts +91 -0
- package/src/preview/capture.ts +254 -0
- package/src/preview/constraints.ts +240 -0
- package/src/preview/harness/harness.tsx +170 -0
- package/src/preview/harness/index.html +28 -0
- package/src/preview/nix-ld.ts +49 -0
- package/template/.gitignore.template +2 -0
- package/src/commands/dev-registry.ts +0 -86
package/bin/cli.ts
CHANGED
|
@@ -27,6 +27,8 @@ Commands:
|
|
|
27
27
|
build Build all widgets (per-widget, self-contained bundles)
|
|
28
28
|
connect <url> Connect to a running dashboard for live testing
|
|
29
29
|
--re-auth discards stored credentials and re-runs device approval
|
|
30
|
+
preview [name...] Screenshot widgets' examples (light + dark) into preview/
|
|
31
|
+
--isolate gives every render its own browser process
|
|
30
32
|
validate [name] Validate all widgets or a specific one
|
|
31
33
|
publish [hub-url] Build and publish a widget to Hub
|
|
32
34
|
login [hub-url] Authenticate with GlassHome Hub
|
|
@@ -42,6 +44,8 @@ Examples:
|
|
|
42
44
|
glasshome-widget add
|
|
43
45
|
glasshome-widget build
|
|
44
46
|
glasshome-widget connect http://localhost:3333
|
|
47
|
+
glasshome-widget preview
|
|
48
|
+
glasshome-widget preview clock
|
|
45
49
|
glasshome-widget validate
|
|
46
50
|
glasshome-widget validate clock
|
|
47
51
|
glasshome-widget info
|
|
@@ -65,6 +69,7 @@ const { values: flags, positionals } = parseArgs({
|
|
|
65
69
|
bump: { type: "string" },
|
|
66
70
|
scope: { type: "string" },
|
|
67
71
|
"re-auth": { type: "boolean" },
|
|
72
|
+
isolate: { type: "boolean" },
|
|
68
73
|
dry: { type: "boolean" },
|
|
69
74
|
help: { type: "boolean", short: "h" },
|
|
70
75
|
},
|
|
@@ -134,6 +139,14 @@ switch (effectiveCommand) {
|
|
|
134
139
|
break;
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
case "preview": {
|
|
143
|
+
const widgetDir = resolveWidgetDir();
|
|
144
|
+
await notifySdkUpdate(widgetDir);
|
|
145
|
+
const { runPreview } = await import("../src/commands/preview");
|
|
146
|
+
await runPreview(widgetDir, args, flags.isolate === true);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
case "validate": {
|
|
138
151
|
const widgetName = args[0];
|
|
139
152
|
const widgetDir = resolveWidgetDir();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,19 +16,36 @@
|
|
|
16
16
|
"template"
|
|
17
17
|
],
|
|
18
18
|
"devDependencies": {
|
|
19
|
+
"@iconify/types": "^2.0.0",
|
|
19
20
|
"@types/bun": "latest",
|
|
20
|
-
"@types/semver": "^7.0.0"
|
|
21
|
+
"@types/semver": "^7.0.0",
|
|
22
|
+
"playwright": "^1.40.0"
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
23
25
|
"@clack/prompts": "^1.6.0",
|
|
26
|
+
"@fontsource-variable/caveat": "^5.2.8",
|
|
27
|
+
"@fontsource-variable/geist": "^5.2.9",
|
|
28
|
+
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
29
|
+
"@glasshome/sync-layer": "0.4.0",
|
|
24
30
|
"@glasshome/widget-contract": "0.2.0",
|
|
25
|
-
"@glasshome/widget-sdk": "1.
|
|
31
|
+
"@glasshome/widget-sdk": "1.7.0",
|
|
32
|
+
"@iconify-json/lucide": "^1.2.0",
|
|
33
|
+
"@iconify-json/mdi": "^1.2.3",
|
|
34
|
+
"@tailwindcss/vite": "^4.1.13",
|
|
26
35
|
"open": "^11.0.0",
|
|
27
36
|
"picocolors": "^1.1.1",
|
|
28
37
|
"semver": "^7.6.0",
|
|
29
38
|
"ts-morph": "^28.0.0",
|
|
30
39
|
"vite-plugin-solid": "^2.11.0"
|
|
31
40
|
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"playwright": "^1.40.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"playwright": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
32
49
|
"repository": {
|
|
33
50
|
"type": "git",
|
|
34
51
|
"url": "https://github.com/glasshome/widget-cli.git"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { log } from "@clack/prompts";
|
|
2
|
+
import color from "picocolors";
|
|
3
|
+
import { runPreview as capturePreview } from "../preview/capture";
|
|
4
|
+
import { withQuietStdout } from "../utils/quiet";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Screenshot every widget's authored examples (light + dark) into
|
|
8
|
+
* `<project>/preview/`, rendered through the same constraints as the hub's
|
|
9
|
+
* render worker: frozen clock, DNS blackhole, per-render timeout, hash pin.
|
|
10
|
+
*
|
|
11
|
+
* Playwright is an OPTIONAL peer — the CLI stays Chromium-free by default — so
|
|
12
|
+
* detect it first and give an actionable install hint rather than a raw
|
|
13
|
+
* module-not-found when it is absent.
|
|
14
|
+
*/
|
|
15
|
+
export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
await import("playwright");
|
|
18
|
+
} catch {
|
|
19
|
+
log.error("Preview needs Playwright + a Chromium build, which are not installed.");
|
|
20
|
+
log.info(`Install them with:\n ${color.bold("bun add -d playwright && bunx playwright install chromium")}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Capture the REAL stdout writer before withQuietStdout no-ops
|
|
25
|
+
// process.stdout.write. A clack spinner writes through that same override and
|
|
26
|
+
// would freeze inside the quiet block, so progress goes out this bound
|
|
27
|
+
// reference (which the override cannot intercept) as plain lines. Build/vite
|
|
28
|
+
// noise stays silenced; only these phase lines get through.
|
|
29
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
30
|
+
const progress = (m: string) => {
|
|
31
|
+
realWrite(`${color.gray("│")} ${color.dim(m)}\n`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
|
|
35
|
+
|
|
36
|
+
let summary: Awaited<ReturnType<typeof capturePreview>>;
|
|
37
|
+
try {
|
|
38
|
+
summary = await withQuietStdout(() =>
|
|
39
|
+
capturePreview({ projectDir: cwd, only: names, isolate, onProgress: progress }),
|
|
40
|
+
);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
log.success(`Captured ${summary.shots} shot(s) across ${summary.widgets} widget(s)`);
|
|
47
|
+
|
|
48
|
+
if (summary.skipped.length) {
|
|
49
|
+
log.info(`No examples (skipped): ${summary.skipped.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const network = summary.failures.filter((f) => f.kind === "network");
|
|
53
|
+
const hangs = summary.failures.filter((f) => f.kind === "hang");
|
|
54
|
+
const integrity = summary.failures.filter((f) => f.kind === "integrity");
|
|
55
|
+
|
|
56
|
+
// Nothing can actually leave — DNS is blackholed. These are blocked attempts,
|
|
57
|
+
// i.e. what each widget wanted from the network and must degrade without.
|
|
58
|
+
if (network.length) {
|
|
59
|
+
log.warn(
|
|
60
|
+
`NETWORK: ${network.length} blocked request(s) — all denied, none left the machine\n` +
|
|
61
|
+
network.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
log.success("NETWORK: no widget attempted to reach the network");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (hangs.length) {
|
|
68
|
+
log.error(
|
|
69
|
+
`RENDER: ${hangs.length} render(s) failed or timed out\n` +
|
|
70
|
+
hangs.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
log.success("RENDER: every render settled inside the timeout");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (integrity.length) {
|
|
77
|
+
log.error(
|
|
78
|
+
`INTEGRITY: ${integrity.length} widget(s) changed after pinning\n` +
|
|
79
|
+
integrity.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
log.success("INTEGRITY: every bundle matched its pinned hash");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
log.info(`Previews written to ${color.cyan("preview/")}`);
|
|
86
|
+
|
|
87
|
+
// Vite's dev server leaves live handles behind (file watchers, keep-alive
|
|
88
|
+
// sockets from browsers that crashed mid-render), so the process would sit
|
|
89
|
+
// idle forever after the verdict instead of exiting. Leave deliberately.
|
|
90
|
+
process.exit(hangs.length || integrity.length ? 1 : 0);
|
|
91
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { buildWidgets } from "@glasshome/widget-sdk/vite";
|
|
4
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
5
|
+
import { createServer } from "vite";
|
|
6
|
+
import solid from "vite-plugin-solid";
|
|
7
|
+
import {
|
|
8
|
+
freezeClock,
|
|
9
|
+
hashWidgetArtifacts,
|
|
10
|
+
settleAnimations,
|
|
11
|
+
type SharedBrowser,
|
|
12
|
+
watchEgress,
|
|
13
|
+
withFreshBrowser,
|
|
14
|
+
withRenderTimeout,
|
|
15
|
+
withSharedBrowser,
|
|
16
|
+
} from "./constraints";
|
|
17
|
+
|
|
18
|
+
const THEMES = ["light", "dark"] as const;
|
|
19
|
+
|
|
20
|
+
interface ShotListEntry {
|
|
21
|
+
label?: string;
|
|
22
|
+
size: { w: number; h: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Failure {
|
|
26
|
+
widget: string;
|
|
27
|
+
kind: "network" | "hang" | "integrity";
|
|
28
|
+
detail: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PreviewSummary {
|
|
32
|
+
shots: number;
|
|
33
|
+
widgets: number;
|
|
34
|
+
skipped: string[];
|
|
35
|
+
failures: Failure[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PreviewOptions {
|
|
39
|
+
projectDir: string;
|
|
40
|
+
only?: string[];
|
|
41
|
+
isolate?: boolean;
|
|
42
|
+
/** Phase updates for a caller-owned spinner; build + vite output is silenced. */
|
|
43
|
+
onProgress?: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Filenames become CDN path segments, so keep them to [a-z0-9-]: an example
|
|
47
|
+
// labelled "Solar, battery and EV" must not put a comma in a URL.
|
|
48
|
+
function slug(s: string): string {
|
|
49
|
+
return s
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-|-$/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every ancestor of the project dir, so vite's fs guard allows the harness to
|
|
56
|
+
// reach node_modules hoisted anywhere up the workspace (bun hoists widget-cli's
|
|
57
|
+
// deps to the project root, which may be several levels above a widget project).
|
|
58
|
+
function ancestors(dir: string): string[] {
|
|
59
|
+
const out: string[] = [];
|
|
60
|
+
let d = dir;
|
|
61
|
+
for (;;) {
|
|
62
|
+
out.push(d);
|
|
63
|
+
const parent = dirname(d);
|
|
64
|
+
if (parent === d) break;
|
|
65
|
+
d = parent;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build every widget, serve the harness, and screenshot each authored example
|
|
72
|
+
* in light and dark under the render worker's constraints (frozen clock, DNS
|
|
73
|
+
* blackhole, per-render timeout, verify-before-execute hash pin).
|
|
74
|
+
*
|
|
75
|
+
* The vite root is a temp dir created UNDER the project so that: the harness's
|
|
76
|
+
* `../dist/*.js` glob resolves to `<projectDir>/dist`, and every bare import
|
|
77
|
+
* (@glasshome/*, iconify, solid) resolves from the project's own node_modules —
|
|
78
|
+
* a single solid/ui instance, which the built bundle's external imports require.
|
|
79
|
+
*/
|
|
80
|
+
export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary> {
|
|
81
|
+
const projectDir = resolve(opts.projectDir);
|
|
82
|
+
const only = opts.only ?? [];
|
|
83
|
+
const isolate = opts.isolate ?? false;
|
|
84
|
+
const progress = opts.onProgress ?? (() => {});
|
|
85
|
+
|
|
86
|
+
const distDir = resolve(projectDir, "dist");
|
|
87
|
+
const outDir = resolve(projectDir, "preview");
|
|
88
|
+
const harnessSrc = resolve(import.meta.dirname, "harness");
|
|
89
|
+
// Temp vite root under the project: keeps node_modules resolution and the
|
|
90
|
+
// `../dist` glob pointed at the project, and is removed when the run ends.
|
|
91
|
+
const tempRoot = resolve(projectDir, ".glasshome-preview");
|
|
92
|
+
|
|
93
|
+
// 1. Build the widget bundles so authored examples land in dist/<name>.js.
|
|
94
|
+
progress(only.length ? `Building ${only.join(", ")}...` : "Building widgets...");
|
|
95
|
+
process.chdir(projectDir);
|
|
96
|
+
await buildWidgets({
|
|
97
|
+
srcDir: "src",
|
|
98
|
+
outDir: "dist",
|
|
99
|
+
...(only.length ? { only } : {}),
|
|
100
|
+
plugins: [solid({ solid: { delegateEvents: false } })],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const widgetNames = readdirSync(distDir)
|
|
104
|
+
.filter((f) => f.endsWith(".js"))
|
|
105
|
+
.map((f) => f.slice(0, -3))
|
|
106
|
+
.filter((n) => (only.length ? only.includes(n) : true))
|
|
107
|
+
.sort();
|
|
108
|
+
|
|
109
|
+
// 2. Stage the harness into the temp root and serve it.
|
|
110
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
111
|
+
mkdirSync(tempRoot, { recursive: true });
|
|
112
|
+
copyFileSync(resolve(harnessSrc, "harness.tsx"), resolve(tempRoot, "harness.tsx"));
|
|
113
|
+
copyFileSync(resolve(harnessSrc, "index.html"), resolve(tempRoot, "index.html"));
|
|
114
|
+
|
|
115
|
+
const skipped: string[] = [];
|
|
116
|
+
let shot = 0;
|
|
117
|
+
const failures: Failure[] = [];
|
|
118
|
+
const shotLists = new Map<string, ShotListEntry[]>();
|
|
119
|
+
|
|
120
|
+
const server = await createServer({
|
|
121
|
+
root: tempRoot,
|
|
122
|
+
configFile: false,
|
|
123
|
+
// delegateEvents: false — widgets mount in closed shadow roots where Solid's
|
|
124
|
+
// document-level event delegation cannot see the target (matches the widgets
|
|
125
|
+
// build and dash mount). tailwindcss() compiles @glasshome/ui/styles so the
|
|
126
|
+
// app theme tokens land on :root exactly as they do in dash.
|
|
127
|
+
plugins: [tailwindcss(), solid({ solid: { delegateEvents: false } })],
|
|
128
|
+
server: { fs: { allow: [tempRoot, ...ancestors(projectDir)] } },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await server.listen();
|
|
133
|
+
const base = server.resolvedUrls?.local[0];
|
|
134
|
+
if (!base) throw new Error("vite dev server has no local url");
|
|
135
|
+
const origin = new URL(base).origin;
|
|
136
|
+
|
|
137
|
+
mkdirSync(outDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
// 3. Enumerate each widget's shot list (one locked browser for the whole pass).
|
|
140
|
+
progress(`Loading ${widgetNames.length} widget(s)...`);
|
|
141
|
+
const pinnedHashes = new Map<string, string>();
|
|
142
|
+
await withFreshBrowser(async (page) => {
|
|
143
|
+
watchEgress(page, origin);
|
|
144
|
+
for (const widget of widgetNames) {
|
|
145
|
+
await page.goto(`${base}?widget=${widget}&ex=0&theme=light`, {
|
|
146
|
+
waitUntil: "domcontentloaded",
|
|
147
|
+
});
|
|
148
|
+
await page.waitForSelector("html[data-harness-examples]", {
|
|
149
|
+
state: "attached",
|
|
150
|
+
timeout: 30_000,
|
|
151
|
+
});
|
|
152
|
+
const examples: ShotListEntry[] = JSON.parse(
|
|
153
|
+
(await page.getAttribute("html", "data-harness-examples")) ?? "[]",
|
|
154
|
+
);
|
|
155
|
+
if (examples.length === 0) skipped.push(widget);
|
|
156
|
+
else {
|
|
157
|
+
shotLists.set(widget, examples);
|
|
158
|
+
// Pin the bytes now; every later render must match this.
|
|
159
|
+
pinnedHashes.set(widget, hashWidgetArtifacts(distDir, widget));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 4. Render every shot under the worker's constraints.
|
|
165
|
+
const runAll = async (shared: SharedBrowser | null) => {
|
|
166
|
+
let done = 0;
|
|
167
|
+
const total = shotLists.size;
|
|
168
|
+
for (const [widget, examples] of shotLists) {
|
|
169
|
+
progress(`Rendering ${widget} (${++done}/${total})...`);
|
|
170
|
+
const widgetAttempts = new Set<string>();
|
|
171
|
+
|
|
172
|
+
// Verify-before-execute: refuse to render bytes that changed since the
|
|
173
|
+
// shot list was built.
|
|
174
|
+
const current = hashWidgetArtifacts(distDir, widget);
|
|
175
|
+
if (current !== pinnedHashes.get(widget)) {
|
|
176
|
+
failures.push({
|
|
177
|
+
widget,
|
|
178
|
+
kind: "integrity",
|
|
179
|
+
detail: `bundle changed after pinning (${pinnedHashes.get(widget)?.slice(0, 12)} -> ${current.slice(0, 12)})`,
|
|
180
|
+
});
|
|
181
|
+
console.log(`${widget}: SKIPPED — bundle hash mismatch`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < examples.length; i++) {
|
|
186
|
+
const label = slug(examples[i].label ?? `example-${i}`);
|
|
187
|
+
for (const theme of THEMES) {
|
|
188
|
+
const url = `${base}?widget=${widget}&ex=${i}&theme=${theme}`;
|
|
189
|
+
const file = resolve(outDir, `${widget}-${label}-${theme}.png`);
|
|
190
|
+
|
|
191
|
+
const shoot = async (page: import("playwright").Page) => {
|
|
192
|
+
const lock = watchEgress(page, origin);
|
|
193
|
+
await freezeClock(page);
|
|
194
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
195
|
+
await page.waitForSelector("html[data-harness-ready='1']", {
|
|
196
|
+
state: "attached",
|
|
197
|
+
timeout: 20_000,
|
|
198
|
+
});
|
|
199
|
+
// Fire rAF-gated mount animations before capturing.
|
|
200
|
+
await settleAnimations(page);
|
|
201
|
+
await page.locator("#stage").screenshot({ path: file, omitBackground: true });
|
|
202
|
+
for (const a of lock.attempts) widgetAttempts.add(a);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await withRenderTimeout(`${widget} / ${label} / ${theme}`, async () => {
|
|
207
|
+
if (shared) {
|
|
208
|
+
const page = await shared.newPage();
|
|
209
|
+
try {
|
|
210
|
+
await shoot(page);
|
|
211
|
+
} finally {
|
|
212
|
+
// The browser may already be gone; a failed cleanup must not
|
|
213
|
+
// mask the real outcome of the render.
|
|
214
|
+
await page
|
|
215
|
+
.context()
|
|
216
|
+
.close()
|
|
217
|
+
.catch(() => {});
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
await withFreshBrowser(shoot);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
shot++;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
failures.push({
|
|
226
|
+
widget,
|
|
227
|
+
kind: "hang",
|
|
228
|
+
detail: `${label}/${theme}: ${err instanceof Error ? err.message : String(err)}`,
|
|
229
|
+
});
|
|
230
|
+
// A timed-out render leaves the browser wedged; drop it so the next
|
|
231
|
+
// shot starts clean instead of cascading into more timeouts.
|
|
232
|
+
await shared?.recycle().catch(() => {});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const a of widgetAttempts) {
|
|
238
|
+
failures.push({ widget, kind: "network", detail: a });
|
|
239
|
+
}
|
|
240
|
+
const flag = widgetAttempts.size ? ` ⚠ needs network (${widgetAttempts.size})` : "";
|
|
241
|
+
console.log(`${widget}: ${examples.length} example(s)${flag}`);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (isolate) await runAll(null);
|
|
246
|
+
else await withSharedBrowser((shared) => runAll(shared));
|
|
247
|
+
} finally {
|
|
248
|
+
await server.close().catch(() => {});
|
|
249
|
+
// Temp root is throwaway staging; never leave it in the project tree.
|
|
250
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { shots: shot, widgets: shotLists.size, skipped, failures };
|
|
254
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import type { Browser, Page } from "playwright";
|
|
4
|
+
import { chromium } from "playwright";
|
|
5
|
+
import { nixLdLibraryPath } from "./nix-ld";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Local rehearsal of the render worker's runtime constraints.
|
|
9
|
+
*
|
|
10
|
+
* The worker will execute attacker-controlled widget code on hub infra with no
|
|
11
|
+
* consent gate, so the screenshot itself is a potential exfiltration channel: a
|
|
12
|
+
* widget could fetch an internal endpoint and paint the response. Imposing the
|
|
13
|
+
* same constraints on the local harness answers "can widgets even render like
|
|
14
|
+
* this?" before any infrastructure exists.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Hard wall-clock cap per render. A widget that hangs (camera waiting on a
|
|
18
|
+
stream that will never arrive) must be killed, not allowed to stall the run. */
|
|
19
|
+
export const RENDER_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
export interface EgressLock {
|
|
22
|
+
/**
|
|
23
|
+
* Every non-local URL the page ATTEMPTED to reach. These were all blocked by
|
|
24
|
+
* the DNS blackhole, so nothing actually left the machine — this records
|
|
25
|
+
* intent, not leakage.
|
|
26
|
+
*
|
|
27
|
+
* A non-empty list is still a finding: it names what a widget needs from the
|
|
28
|
+
* network and therefore what it must degrade gracefully without.
|
|
29
|
+
*/
|
|
30
|
+
readonly attempts: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Chromium launch flags that blackhole DNS for everything except localhost.
|
|
35
|
+
*
|
|
36
|
+
* Enforcement lives at the browser, not in Playwright's request interception.
|
|
37
|
+
* Registering any `page.route` makes Playwright intercept EVERY request and
|
|
38
|
+
* round-trip it through Node — with multi-megabyte icon collections on the page
|
|
39
|
+
* that is pathologically slow. Resolver rules cost nothing at runtime and are a
|
|
40
|
+
* closer analogue of the container egress allowlist the worker will actually
|
|
41
|
+
* rely on.
|
|
42
|
+
*/
|
|
43
|
+
export const NO_EGRESS_ARGS = [
|
|
44
|
+
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record (do not intercept) every non-local request the page attempts.
|
|
49
|
+
*
|
|
50
|
+
* Passive listeners, so no proxying overhead. The DNS blackhole above does the
|
|
51
|
+
* blocking; this only answers "what did it try to reach?".
|
|
52
|
+
*/
|
|
53
|
+
export function watchEgress(page: Page, allowedOrigin: string): EgressLock {
|
|
54
|
+
const attempts: string[] = [];
|
|
55
|
+
|
|
56
|
+
const isLocal = (href: string): boolean =>
|
|
57
|
+
href.startsWith(allowedOrigin) ||
|
|
58
|
+
href.startsWith("data:") ||
|
|
59
|
+
href.startsWith("blob:") ||
|
|
60
|
+
href.startsWith("about:") ||
|
|
61
|
+
href.startsWith("chrome-extension:");
|
|
62
|
+
|
|
63
|
+
page.on("request", (req) => {
|
|
64
|
+
const url = req.url();
|
|
65
|
+
if (!isLocal(url)) attempts.push(url);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { attempts };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hash the exact bytes about to be rendered.
|
|
73
|
+
*
|
|
74
|
+
* Rehearses the worker's verify-before-execute rule (S6c). In production the
|
|
75
|
+
* hub pins a bundle's hash at publish time, but the author's presigned upload
|
|
76
|
+
* URL stays valid for another 300s — so the object in storage can be swapped
|
|
77
|
+
* after verification. The worker must therefore render the bytes the hub
|
|
78
|
+
* pinned, not whatever a re-fetch returns. Locally this proves the plumbing:
|
|
79
|
+
* hash both artifacts, and refuse to render if they don't match what was
|
|
80
|
+
* recorded when the shot list was built.
|
|
81
|
+
*/
|
|
82
|
+
export function hashWidgetArtifacts(distDir: string, widget: string): string {
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
for (const file of [`${distDir}/${widget}.js`, `${distDir}/${widget}.css`]) {
|
|
85
|
+
// CSS is optional — a widget that ships no stylesheet is legitimate.
|
|
86
|
+
if (existsSync(file)) hash.update(readFileSync(file));
|
|
87
|
+
}
|
|
88
|
+
return hash.digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fixed wall-clock instant every render starts from: a bright weekday midday, so
|
|
93
|
+
* time-of-day-dependent widgets (clock, weather, sun-driven energy) land in a
|
|
94
|
+
* flattering, stable state instead of drifting with the run.
|
|
95
|
+
*/
|
|
96
|
+
export const FROZEN_TIME = new Date("2026-06-15T12:34:00Z");
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Freeze time, then step it forward deliberately.
|
|
100
|
+
*
|
|
101
|
+
* Widgets gate their mount animation behind `requestAnimationFrame(() =>
|
|
102
|
+
* setMounted(true))` (sensor/sparkline.tsx, weather/forecast-chart.tsx). A
|
|
103
|
+
* fully paused clock never fires that callback, so those widgets would be
|
|
104
|
+
* screenshotted in their pre-mount state — or wait forever and die to the
|
|
105
|
+
* timeout. Installing the clock and then running it forward a short, fixed
|
|
106
|
+
* amount fires the rAF callbacks and any mount transition, while keeping the
|
|
107
|
+
* result byte-identical between runs.
|
|
108
|
+
*/
|
|
109
|
+
export async function freezeClock(page: Page): Promise<void> {
|
|
110
|
+
await page.clock.install({ time: FROZEN_TIME });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Advance the frozen clock enough to settle rAF-gated mount animations. */
|
|
114
|
+
export async function settleAnimations(page: Page, ms = 1_000): Promise<void> {
|
|
115
|
+
await page.clock.runFor(ms);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Reject if a render exceeds the cap, so a hang surfaces as a failure rather
|
|
119
|
+
than a stalled run. */
|
|
120
|
+
export async function withRenderTimeout<T>(
|
|
121
|
+
label: string,
|
|
122
|
+
fn: () => Promise<T>,
|
|
123
|
+
ms: number = RENDER_TIMEOUT_MS,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
127
|
+
timer = setTimeout(() => reject(new Error(`render timeout after ${ms}ms: ${label}`)), ms);
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
return await Promise.race([fn(), timeout]);
|
|
131
|
+
} finally {
|
|
132
|
+
if (timer) clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Apply the nix-ld library path exactly once. `nixLdLibraryPath()` prepends the
|
|
137
|
+
existing value, so calling it per launch would grow the variable on every
|
|
138
|
+
render until the child's environment blows past the exec limit (E2BIG). */
|
|
139
|
+
let nixLdApplied = false;
|
|
140
|
+
function applyNixLdOnce(): void {
|
|
141
|
+
if (nixLdApplied) return;
|
|
142
|
+
nixLdApplied = true;
|
|
143
|
+
const nixLd = nixLdLibraryPath();
|
|
144
|
+
if (nixLd) process.env.NIX_LD_LIBRARY_PATH = nixLd;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Launch one browser and hand out a fresh context (own cache and storage) per
|
|
148
|
+
render. Enough isolation for the egress question, without paying a process
|
|
149
|
+
launch per shot. */
|
|
150
|
+
/**
|
|
151
|
+
* Force a browser dead without hanging. A render that times out abandons its
|
|
152
|
+
* Playwright op but leaves the browser wedged: a graceful `browser.close()` on
|
|
153
|
+
* it never returns, so the next shot's recycle hangs on it forever. Race the
|
|
154
|
+
* graceful close against a short deadline, then SIGKILL the process outright.
|
|
155
|
+
*/
|
|
156
|
+
async function hardClose(browser: Browser): Promise<void> {
|
|
157
|
+
// `process()` exists at runtime on a launched Chromium but is absent from the
|
|
158
|
+
// base Browser type; narrow it here rather than at the call site.
|
|
159
|
+
const proc = (browser as Browser & { process?: () => { kill: (s: string) => void } | null })
|
|
160
|
+
.process?.();
|
|
161
|
+
await Promise.race([
|
|
162
|
+
browser.close().catch(() => {}),
|
|
163
|
+
new Promise((r) => setTimeout(r, 3_000)),
|
|
164
|
+
]);
|
|
165
|
+
proc?.kill("SIGKILL");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SharedBrowser {
|
|
169
|
+
newPage: () => Promise<Page>;
|
|
170
|
+
/** Drop the current browser (hard) and start fresh. Call after any render
|
|
171
|
+
failure: a timed-out render leaves the browser wedged, and reusing it
|
|
172
|
+
cascades into a 30s timeout on every following shot. */
|
|
173
|
+
recycle: () => Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function withSharedBrowser<T>(fn: (browser: SharedBrowser) => Promise<T>): Promise<T> {
|
|
177
|
+
applyNixLdOnce();
|
|
178
|
+
|
|
179
|
+
// Recycle the process every N pages. Each render pulls megabytes of icon data
|
|
180
|
+
// into a fresh context, and a single long-lived browser degrades and
|
|
181
|
+
// eventually wedges; recycling keeps cumulative state bounded.
|
|
182
|
+
const PAGES_PER_BROWSER = 8;
|
|
183
|
+
|
|
184
|
+
const launch = () => chromium.launch({ args: NO_EGRESS_ARGS });
|
|
185
|
+
let browser = await launch();
|
|
186
|
+
let served = 0;
|
|
187
|
+
|
|
188
|
+
const recycle = async (): Promise<void> => {
|
|
189
|
+
await hardClose(browser);
|
|
190
|
+
browser = await launch();
|
|
191
|
+
served = 0;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const newPage = async (): Promise<Page> => {
|
|
195
|
+
// Recycle on the counter or a dead browser. A wedged-but-connected browser
|
|
196
|
+
// is dropped explicitly by the caller via recycle() after a render failure.
|
|
197
|
+
if (served >= PAGES_PER_BROWSER || !browser.isConnected()) {
|
|
198
|
+
await recycle();
|
|
199
|
+
}
|
|
200
|
+
served++;
|
|
201
|
+
try {
|
|
202
|
+
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
203
|
+
return await context.newPage();
|
|
204
|
+
} catch {
|
|
205
|
+
// Lost between the liveness check and the context: relaunch once so the
|
|
206
|
+
// crash costs one render rather than the rest of the pass.
|
|
207
|
+
await recycle();
|
|
208
|
+
served = 1;
|
|
209
|
+
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
210
|
+
return context.newPage();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
return await fn({ newPage, recycle });
|
|
216
|
+
} finally {
|
|
217
|
+
await hardClose(browser);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fresh browser process per render.
|
|
223
|
+
*
|
|
224
|
+
* The worker must not reuse a process across widgets — one widget's residue
|
|
225
|
+
* (globals, caches, service workers) must never reach another's shot.
|
|
226
|
+
*/
|
|
227
|
+
export async function withFreshBrowser<T>(fn: (page: Page, browser: Browser) => Promise<T>): Promise<T> {
|
|
228
|
+
// On NixOS the Playwright-downloaded Chromium can't find its system libs;
|
|
229
|
+
// point nix-ld at them so the bundled browser launches unmodified.
|
|
230
|
+
applyNixLdOnce();
|
|
231
|
+
|
|
232
|
+
const browser = await chromium.launch({ args: NO_EGRESS_ARGS });
|
|
233
|
+
try {
|
|
234
|
+
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
235
|
+
const page = await context.newPage();
|
|
236
|
+
return await fn(page, browser);
|
|
237
|
+
} finally {
|
|
238
|
+
await browser.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { ReactiveWidgetContext, WidgetDefinition } from "@glasshome/widget-sdk";
|
|
2
|
+
import { instantiateWidget } from "@glasshome/widget-sdk/host";
|
|
3
|
+
import { loadDemoData } from "@glasshome/sync-layer";
|
|
4
|
+
import { getEntityView } from "@glasshome/sync-layer";
|
|
5
|
+
import { byDomain, useAreas, useEntities } from "@glasshome/sync-layer/solid";
|
|
6
|
+
import type { EntityDataAdapter } from "@glasshome/ui/solid";
|
|
7
|
+
import { provideEntityData } from "@glasshome/ui/solid";
|
|
8
|
+
import type { IconifyJSON } from "@iconify/types";
|
|
9
|
+
// `?url` + runtime fetch, NOT a JSON import. Vite turns an imported JSON file
|
|
10
|
+
// into an ES module — a multi-megabyte object literal the JS engine has to
|
|
11
|
+
// evaluate on every page load, which cost ~200s per render. Fetching the same
|
|
12
|
+
// file and letting the browser JSON.parse it natively is orders of magnitude
|
|
13
|
+
// faster, and the request is local so the egress lock still holds.
|
|
14
|
+
import lucideUrl from "@iconify-json/lucide/icons.json?url";
|
|
15
|
+
import mdiUrl from "@iconify-json/mdi/icons.json?url";
|
|
16
|
+
import { addAPIProvider, addCollection } from "iconify-icon";
|
|
17
|
+
// The real built bundle + its separate CSS asset — the same artifacts the hub
|
|
18
|
+
// serves. Rendering these (not the source) keeps previews honest.
|
|
19
|
+
// App theme tokens (--radius, --border, --background, …), the same import dash's
|
|
20
|
+
// styles.css uses. They land on :root and INHERIT into widget shadow roots
|
|
21
|
+
// (custom properties cross the boundary; class rules don't). Without them,
|
|
22
|
+
// shadow-side calc(var(--radius)+…) collapses — sharp corners, no border.
|
|
23
|
+
import "@glasshome/ui/styles";
|
|
24
|
+
// Fonts must load at the document (an @font-face in an adopted/shadow sheet
|
|
25
|
+
// won't); the family name then inherits into the widget shadow. Same set dash's
|
|
26
|
+
// styles.css loads.
|
|
27
|
+
import "@fontsource-variable/geist";
|
|
28
|
+
import "@fontsource-variable/geist-mono";
|
|
29
|
+
import "@fontsource-variable/caveat";
|
|
30
|
+
|
|
31
|
+
// Preload every collection into iconify's shared icon storage before any widget
|
|
32
|
+
// mounts. `iconify-icon` is a declared singleton host-provided module, so one
|
|
33
|
+
// registration serves every widget. Full collections rather than a scanned
|
|
34
|
+
// subset, because icon names are built at runtime (weather picks by condition,
|
|
35
|
+
// light by on/off) and a static scan misses exactly those. All three prefixes
|
|
36
|
+
// are needed: widgets use mdi, but @glasshome/ui components pull lucide.
|
|
37
|
+
// (simple-icons is deliberately absent — nothing references it, and at 4.6MB it
|
|
38
|
+
// was the single largest cost per page load.)
|
|
39
|
+
async function preloadIcons(): Promise<void> {
|
|
40
|
+
const [mdi, lucide] = await Promise.all([
|
|
41
|
+
fetch(mdiUrl).then((r) => r.json() as Promise<IconifyJSON>),
|
|
42
|
+
fetch(lucideUrl).then((r) => r.json() as Promise<IconifyJSON>),
|
|
43
|
+
]);
|
|
44
|
+
addCollection(mdi);
|
|
45
|
+
addCollection(lucide);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// No network fallback. Dash repoints this provider at its own /iconify/ proxy;
|
|
49
|
+
// the render worker has no egress at all, so an unresolved icon must fail
|
|
50
|
+
// visibly (blank) rather than reach out to api.iconify.design.
|
|
51
|
+
addAPIProvider("", { resources: [] });
|
|
52
|
+
|
|
53
|
+
// @glasshome/ui's smart-home components (pickers, entity rows) resolve data
|
|
54
|
+
// through an injected adapter; dash binds the same four sync-layer functions in
|
|
55
|
+
// main.tsx. Without it those components throw and the widget renders empty.
|
|
56
|
+
// Host setup like this is duplicated per host today — see the mount
|
|
57
|
+
// consolidation memo.
|
|
58
|
+
const entityDataAdapter: EntityDataAdapter = {
|
|
59
|
+
entityIdsByDomain: byDomain,
|
|
60
|
+
useEntities,
|
|
61
|
+
getEntityView,
|
|
62
|
+
useAreas,
|
|
63
|
+
};
|
|
64
|
+
provideEntityData(entityDataAdapter);
|
|
65
|
+
|
|
66
|
+
// Every built widget bundle + its CSS, selected by the `?widget=` param. Glob
|
|
67
|
+
// keeps the imports static-analyzable while staying widget-agnostic — the same
|
|
68
|
+
// real dist artifacts the hub serves.
|
|
69
|
+
const bundles = import.meta.glob<{ default: WidgetDefinition }>("../dist/*.js");
|
|
70
|
+
const styles = import.meta.glob<string>("../dist/*.css", {
|
|
71
|
+
query: "?raw",
|
|
72
|
+
import: "default",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function loadWidget(name: string): Promise<{ def: WidgetDefinition; css: string | null }> {
|
|
76
|
+
const jsKey = `../dist/${name}.js`;
|
|
77
|
+
const loadJs = bundles[jsKey];
|
|
78
|
+
if (!loadJs) throw new Error(`no built bundle for widget "${name}" (${jsKey})`);
|
|
79
|
+
const def = (await loadJs()).default;
|
|
80
|
+
const loadCss = styles[`../dist/${name}.css`];
|
|
81
|
+
const css = loadCss ? await loadCss() : null;
|
|
82
|
+
return { def, css };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Grid geometry mirrored from the dash route (rowHeight 70, margin 16, lg 12
|
|
86
|
+
// cols at a 1024px reference container). A preview approximates one dashboard
|
|
87
|
+
// width rather than mirroring every breakpoint.
|
|
88
|
+
const CONTAINER = 1024;
|
|
89
|
+
const COLS = 12;
|
|
90
|
+
const GAP = 16;
|
|
91
|
+
const ROW_H = 70;
|
|
92
|
+
const COL_W = (CONTAINER - GAP * (COLS - 1)) / COLS;
|
|
93
|
+
|
|
94
|
+
function tilePx(size: { w: number; h: number }): { width: number; height: number } {
|
|
95
|
+
return {
|
|
96
|
+
width: Math.round(COL_W * size.w + GAP * (size.w - 1)),
|
|
97
|
+
height: ROW_H * size.h + GAP * (size.h - 1),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mount via the shared SDK recipe (`@glasshome/widget-sdk/host`): closed shadow
|
|
102
|
+
root, injected tokens, adopted widget CSS, WidgetCtx provider, `dark`
|
|
103
|
+
mirrored onto the host. No perf-blur/a11y override sheet — not needed for a
|
|
104
|
+
static shot, so no `extraSheets`; `dark` is the default `mirrorClasses`. The
|
|
105
|
+
`dark` document class is toggled by the caller before this runs. */
|
|
106
|
+
function mount(
|
|
107
|
+
host: HTMLElement,
|
|
108
|
+
def: WidgetDefinition,
|
|
109
|
+
config: Record<string, unknown>,
|
|
110
|
+
ctx: ReactiveWidgetContext,
|
|
111
|
+
cssText: string | null,
|
|
112
|
+
): void {
|
|
113
|
+
instantiateWidget(host, {
|
|
114
|
+
definition: def,
|
|
115
|
+
config: () => config,
|
|
116
|
+
ctx,
|
|
117
|
+
cssText,
|
|
118
|
+
onCrash: (err) => console.error("[harness] widget threw:", err),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main(): Promise<void> {
|
|
123
|
+
const params = new URLSearchParams(location.search);
|
|
124
|
+
const widgetName = params.get("widget") ?? "light";
|
|
125
|
+
const exIndex = Number(params.get("ex") ?? "0");
|
|
126
|
+
const dark = params.get("theme") === "dark";
|
|
127
|
+
|
|
128
|
+
const { def, css } = await loadWidget(widgetName);
|
|
129
|
+
const examples = def.manifest.examples ?? [];
|
|
130
|
+
// Publish the shot-list so the capture driver can enumerate without importing
|
|
131
|
+
// the widget's TSX source under a non-Solid JSX runtime.
|
|
132
|
+
document.documentElement.dataset.harnessExamples = JSON.stringify(
|
|
133
|
+
examples.map((e) => ({ label: e.label, size: e.size })),
|
|
134
|
+
);
|
|
135
|
+
const example = examples[exIndex];
|
|
136
|
+
if (!example) {
|
|
137
|
+
document.title = "harness-error: no example";
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
142
|
+
|
|
143
|
+
// Icons must be registered before the widget mounts, or its first paint
|
|
144
|
+
// triggers the (now dead) network lookup and renders blank.
|
|
145
|
+
await preloadIcons();
|
|
146
|
+
|
|
147
|
+
await loadDemoData();
|
|
148
|
+
|
|
149
|
+
// Background and padding are the renderer's job: the preview is the widget
|
|
150
|
+
// alone, tight and transparent (omitBackground on capture).
|
|
151
|
+
const { width, height } = tilePx(example.size);
|
|
152
|
+
const stage = document.getElementById("stage");
|
|
153
|
+
if (!stage) return;
|
|
154
|
+
stage.style.width = `${width}px`;
|
|
155
|
+
stage.style.height = `${height}px`;
|
|
156
|
+
|
|
157
|
+
const ctx: ReactiveWidgetContext = {
|
|
158
|
+
isEditMode: () => false,
|
|
159
|
+
updateConfig: () => {},
|
|
160
|
+
dimensions: () => ({ width, height }),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
mount(stage, def, example.config as Record<string, unknown>, ctx, css);
|
|
164
|
+
|
|
165
|
+
await document.fonts.ready;
|
|
166
|
+
// Signal to the capture driver that mount + fonts settled.
|
|
167
|
+
document.documentElement.setAttribute("data-harness-ready", "1");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
void main();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Widget Preview Harness</title>
|
|
7
|
+
<style>
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
background: transparent;
|
|
13
|
+
}
|
|
14
|
+
/* The capture target: the widget tile alone. Background and padding are
|
|
15
|
+
the renderer's job, so the screenshot is tight and transparent. */
|
|
16
|
+
#stage {
|
|
17
|
+
position: fixed;
|
|
18
|
+
top: 0;
|
|
19
|
+
left: 0;
|
|
20
|
+
display: block;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div id="stage"></div>
|
|
26
|
+
<script type="module" src="./harness.tsx"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Playwright-downloaded Chromium ships a glibc dynamic linker
|
|
6
|
+
* (`/lib64/ld-linux-x86-64.so.2`) and expects system libs (glib, nss, X11, …)
|
|
7
|
+
* that do not exist on NixOS, so it dies with `libglib-2.0.so.0: cannot open
|
|
8
|
+
* shared object file`. NixOS's nix-ld provides the interpreter shim via
|
|
9
|
+
* `NIX_LD`; it just needs a library search path. This harvests that path from
|
|
10
|
+
* the installed nix Chromium's runtime closure — version-independent and
|
|
11
|
+
* offline — so the downloaded browser resolves every lib.
|
|
12
|
+
*
|
|
13
|
+
* Returns the `:`-joined path (existing `NIX_LD_LIBRARY_PATH` prepended), or
|
|
14
|
+
* null when not on NixOS or no nix Chromium is installed (caller should then
|
|
15
|
+
* launch normally).
|
|
16
|
+
*/
|
|
17
|
+
export function nixLdLibraryPath(): string | null {
|
|
18
|
+
if (!existsSync("/etc/NIXOS")) return null;
|
|
19
|
+
|
|
20
|
+
let chromePath: string;
|
|
21
|
+
try {
|
|
22
|
+
chromePath = execFileSync("bash", ["-lc", 'readlink -f "$(command -v chromium)"'], {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (!chromePath || !existsSync(chromePath)) return null;
|
|
29
|
+
|
|
30
|
+
let closure: string[];
|
|
31
|
+
try {
|
|
32
|
+
closure = execFileSync("nix-store", ["-qR", chromePath], { encoding: "utf-8" })
|
|
33
|
+
.split("\n")
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const dirs: string[] = [];
|
|
40
|
+
for (const p of closure) {
|
|
41
|
+
for (const sub of [`${p}/lib`, `${p}/lib64`]) {
|
|
42
|
+
if (existsSync(sub)) dirs.push(sub);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (dirs.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
const existing = process.env.NIX_LD_LIBRARY_PATH;
|
|
48
|
+
return existing ? `${existing}:${dirs.join(":")}` : dirs.join(":");
|
|
49
|
+
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { log, outro, spinner } from "@clack/prompts";
|
|
2
|
-
import { trpcMutate, trpcQuery } from "../utils/api";
|
|
3
|
-
|
|
4
|
-
interface DevStatusResponse {
|
|
5
|
-
devMode: boolean;
|
|
6
|
-
registries: Array<{ url: string; name: string; isDefault: boolean }>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export async function runAddDevRegistry(options: {
|
|
10
|
-
registryUrl?: string;
|
|
11
|
-
apiUrl?: string;
|
|
12
|
-
}): Promise<void> {
|
|
13
|
-
const registryUrl = options.registryUrl || "http://localhost:5173/__widgets/registry.json";
|
|
14
|
-
const apiUrl = options.apiUrl || "http://localhost:3333";
|
|
15
|
-
|
|
16
|
-
const s = spinner();
|
|
17
|
-
|
|
18
|
-
// Step 1: Check if dev mode is enabled
|
|
19
|
-
s.start("Checking dev mode status...");
|
|
20
|
-
let devEnabled = false;
|
|
21
|
-
try {
|
|
22
|
-
const status = await trpcQuery<DevStatusResponse>({
|
|
23
|
-
apiUrl,
|
|
24
|
-
path: "appConfig.get",
|
|
25
|
-
});
|
|
26
|
-
devEnabled = status.devMode;
|
|
27
|
-
} catch (err) {
|
|
28
|
-
s.stop("Failed to connect");
|
|
29
|
-
log.error(
|
|
30
|
-
`Could not reach dashboard API at ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
31
|
-
);
|
|
32
|
-
log.info("Make sure the dashboard API server is running and the URL is correct.");
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Step 2: Enable dev mode if needed
|
|
37
|
-
if (!devEnabled) {
|
|
38
|
-
s.message("Enabling dev mode...");
|
|
39
|
-
try {
|
|
40
|
-
const result = await trpcMutate<{ devMode: boolean }>({
|
|
41
|
-
apiUrl,
|
|
42
|
-
path: "appConfig.toggleDevMode",
|
|
43
|
-
input: {},
|
|
44
|
-
});
|
|
45
|
-
if (!result.devMode) {
|
|
46
|
-
s.stop("Failed");
|
|
47
|
-
log.error("Could not enable dev mode");
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
} catch (err) {
|
|
51
|
-
s.stop("Failed");
|
|
52
|
-
log.error(`Failed to enable dev mode: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
s.stop("Dev mode enabled");
|
|
57
|
-
|
|
58
|
-
// Step 3: Add the registry via /dev/registries endpoint
|
|
59
|
-
s.start("Adding dev registry...");
|
|
60
|
-
try {
|
|
61
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/dev/registries`, {
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: { "Content-Type": "application/json" },
|
|
64
|
-
body: JSON.stringify({ url: registryUrl, name: "Local Dev Server" }),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
if (res.status === 409) {
|
|
68
|
-
s.stop("Registry already registered");
|
|
69
|
-
log.info(`Registry ${registryUrl} is already configured`);
|
|
70
|
-
} else if (res.ok) {
|
|
71
|
-
s.stop("Registry added");
|
|
72
|
-
log.success(`Added registry: ${registryUrl}`);
|
|
73
|
-
} else {
|
|
74
|
-
const err = (await res.json()) as { error: string };
|
|
75
|
-
s.stop("Failed");
|
|
76
|
-
log.error(`Failed to add registry: ${err.error}`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
} catch (err) {
|
|
80
|
-
s.stop("Failed");
|
|
81
|
-
log.error(`Failed to add registry: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
outro("Done! Refresh your dashboard to see dev widgets.");
|
|
86
|
-
}
|