@glasshome/widget-cli 0.13.6 → 0.14.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 +30 -2
- package/package.json +5 -4
- package/src/commands/preview.ts +92 -4
- package/src/preview/capture.ts +7 -73
- package/src/preview/constraints.ts +13 -7
- package/src/preview/harness/harness.tsx +32 -4
- package/src/preview/serve.ts +94 -0
- package/src/preview/sweep.ts +244 -0
package/bin/cli.ts
CHANGED
|
@@ -29,6 +29,13 @@ Commands:
|
|
|
29
29
|
--re-auth discards stored credentials and re-runs device approval
|
|
30
30
|
preview [name...] Screenshot widgets' examples (light + dark) into preview/
|
|
31
31
|
--isolate gives every render its own browser process
|
|
32
|
+
--sizes <WxH,...|grid> renders at pixel sizes into preview/sweep/, with a
|
|
33
|
+
contact sheet per example (grid: common dashboard sizes)
|
|
34
|
+
--theme light|dark, --example <n,...> narrow the render
|
|
35
|
+
--config <json> merges over the example's config
|
|
36
|
+
--service <domain.service|entity_id|json> changes demo state first (repeatable)
|
|
37
|
+
--at <iso time> sets the clock, --click <selector> taps before the shot
|
|
38
|
+
--eval <expr> prints an expression per render (root = widget shadow root)
|
|
32
39
|
validate [name] Validate all widgets or a specific one
|
|
33
40
|
publish [hub-url] Build and publish a widget to Hub
|
|
34
41
|
login [hub-url] Authenticate with GlassHome Hub
|
|
@@ -46,6 +53,8 @@ Examples:
|
|
|
46
53
|
glasshome-widget connect http://localhost:3333
|
|
47
54
|
glasshome-widget preview
|
|
48
55
|
glasshome-widget preview clock
|
|
56
|
+
glasshome-widget preview light --sizes 150x156,340x242 --theme dark
|
|
57
|
+
glasshome-widget preview clock --at 2026-06-15T21:00:00 --config '{"style":"analog"}'
|
|
49
58
|
glasshome-widget validate
|
|
50
59
|
glasshome-widget validate clock
|
|
51
60
|
glasshome-widget info
|
|
@@ -72,6 +81,14 @@ const { values: flags, positionals } = parseArgs({
|
|
|
72
81
|
to: { type: "string" },
|
|
73
82
|
"re-auth": { type: "boolean" },
|
|
74
83
|
isolate: { type: "boolean" },
|
|
84
|
+
sizes: { type: "string" },
|
|
85
|
+
theme: { type: "string" },
|
|
86
|
+
example: { type: "string" },
|
|
87
|
+
at: { type: "string" },
|
|
88
|
+
config: { type: "string" },
|
|
89
|
+
service: { type: "string", multiple: true },
|
|
90
|
+
click: { type: "string" },
|
|
91
|
+
eval: { type: "string" },
|
|
75
92
|
dry: { type: "boolean" },
|
|
76
93
|
help: { type: "boolean", short: "h" },
|
|
77
94
|
},
|
|
@@ -144,8 +161,19 @@ switch (effectiveCommand) {
|
|
|
144
161
|
case "preview": {
|
|
145
162
|
const widgetDir = resolveWidgetDir();
|
|
146
163
|
await notifySdkUpdate(widgetDir);
|
|
147
|
-
const { runPreview } = await import("../src/commands/preview");
|
|
148
|
-
|
|
164
|
+
const { runPreview, runSweepPreview, wantsSweep } = await import("../src/commands/preview");
|
|
165
|
+
const sweep = {
|
|
166
|
+
sizes: flags.sizes,
|
|
167
|
+
theme: str(flags.theme),
|
|
168
|
+
example: str(flags.example),
|
|
169
|
+
at: str(flags.at),
|
|
170
|
+
config: str(flags.config),
|
|
171
|
+
service: flags.service?.flatMap((s) => str(s) ?? []),
|
|
172
|
+
click: str(flags.click),
|
|
173
|
+
eval: str(flags.eval),
|
|
174
|
+
};
|
|
175
|
+
if (wantsSweep(sweep)) await runSweepPreview(widgetDir, args, sweep);
|
|
176
|
+
else await runPreview(widgetDir, args, flags.isolate === true);
|
|
149
177
|
break;
|
|
150
178
|
}
|
|
151
179
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
"@fontsource-variable/caveat": "^5.2.8",
|
|
29
29
|
"@fontsource-variable/geist": "^5.2.9",
|
|
30
30
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
31
|
-
"@glasshome/sync-layer": "0.
|
|
32
|
-
"@glasshome/widget-contract": "0.
|
|
33
|
-
"@glasshome/widget-sdk": "1.
|
|
31
|
+
"@glasshome/sync-layer": "0.9.0",
|
|
32
|
+
"@glasshome/widget-contract": "0.3.0",
|
|
33
|
+
"@glasshome/widget-sdk": "1.17.0",
|
|
34
34
|
"@iconify-json/lucide": "^1.2.0",
|
|
35
35
|
"@iconify-json/mdi": "^1.2.3",
|
|
36
|
+
"@iconify/utils": "^3.1.4",
|
|
36
37
|
"@tailwindcss/vite": "^4.1.13",
|
|
37
38
|
"open": "^11.0.0",
|
|
38
39
|
"picocolors": "^1.1.1",
|
package/src/commands/preview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { log } from "@clack/prompts";
|
|
2
2
|
import color from "picocolors";
|
|
3
3
|
import { runPreview as capturePreview } from "../preview/capture";
|
|
4
|
+
import { type Box, DEFAULT_SIZES, runSweep, type Theme } from "../preview/sweep";
|
|
4
5
|
import { withQuietStdout } from "../utils/quiet";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -12,7 +13,7 @@ import { withQuietStdout } from "../utils/quiet";
|
|
|
12
13
|
* detect it first and give an actionable install hint rather than a raw
|
|
13
14
|
* module-not-found when it is absent.
|
|
14
15
|
*/
|
|
15
|
-
|
|
16
|
+
async function requirePlaywright(): Promise<void> {
|
|
16
17
|
try {
|
|
17
18
|
await import("playwright");
|
|
18
19
|
} catch {
|
|
@@ -20,10 +21,14 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
|
|
|
20
21
|
log.info(`Install them with:\n ${color.bold("bun add -d playwright && bunx playwright install chromium")}`);
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
24
|
+
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const progress = (m: string) => {
|
|
27
|
+
process.stdout.write(`${color.gray("│")} ${color.dim(m)}\n`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
|
|
31
|
+
await requirePlaywright();
|
|
27
32
|
|
|
28
33
|
log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
|
|
29
34
|
|
|
@@ -92,3 +97,86 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
|
|
|
92
97
|
// idle forever after the verdict instead of exiting. Leave deliberately.
|
|
93
98
|
process.exit(hangs.length || integrity.length ? 1 : 0);
|
|
94
99
|
}
|
|
100
|
+
|
|
101
|
+
export interface SweepFlags {
|
|
102
|
+
sizes?: string | boolean;
|
|
103
|
+
theme?: string;
|
|
104
|
+
example?: string;
|
|
105
|
+
at?: string;
|
|
106
|
+
config?: string;
|
|
107
|
+
service?: string[];
|
|
108
|
+
click?: string;
|
|
109
|
+
eval?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function wantsSweep(flags: SweepFlags): boolean {
|
|
113
|
+
return Object.values(flags).some((v) => v !== undefined && !(Array.isArray(v) && v.length === 0));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fail(message: string): never {
|
|
117
|
+
log.error(message);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseSizes(value: string | boolean | undefined): Box[] {
|
|
122
|
+
if (value === undefined) return [];
|
|
123
|
+
if (value === "grid") return DEFAULT_SIZES;
|
|
124
|
+
return String(value)
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((s) => {
|
|
127
|
+
const m = s.trim().match(/^(\d+)x(\d+)$/);
|
|
128
|
+
if (!m) fail(`--sizes takes grid, or WIDTHxHEIGHT pairs in pixels like 150x156,340x242 (got "${s}")`);
|
|
129
|
+
return { width: Number(m[1]), height: Number(m[2]) };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseThemes(value: string | undefined): Theme[] {
|
|
134
|
+
if (!value) return ["light", "dark"];
|
|
135
|
+
const themes = value.split(",");
|
|
136
|
+
for (const t of themes) if (t !== "light" && t !== "dark") fail(`--theme is light, dark or both (got "${t}")`);
|
|
137
|
+
return themes as Theme[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseConfig(value: string | undefined): Record<string, unknown> | undefined {
|
|
141
|
+
if (!value) return undefined;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(value);
|
|
144
|
+
} catch {
|
|
145
|
+
fail(`--config must be a JSON object (got ${value})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render widgets at chosen pixel sizes, with the config, demo state or clock
|
|
151
|
+
* overridden, into `<project>/preview/sweep/`, plus a contact sheet per example
|
|
152
|
+
* and theme when several sizes are rendered.
|
|
153
|
+
*/
|
|
154
|
+
export async function runSweepPreview(cwd: string, names: string[], flags: SweepFlags): Promise<void> {
|
|
155
|
+
await requirePlaywright();
|
|
156
|
+
const at = flags.at ? new Date(flags.at) : undefined;
|
|
157
|
+
if (at && Number.isNaN(at.getTime())) fail(`--at takes an ISO time like 2026-06-15T21:00:00 (got "${flags.at}")`);
|
|
158
|
+
const examples = flags.example ? flags.example.split(",").map(Number) : [];
|
|
159
|
+
if (examples.some((n) => !Number.isInteger(n) || n < 0)) fail("--example takes example numbers, starting at 0");
|
|
160
|
+
|
|
161
|
+
const summary = await withQuietStdout(() =>
|
|
162
|
+
runSweep({
|
|
163
|
+
projectDir: cwd,
|
|
164
|
+
only: names,
|
|
165
|
+
themes: parseThemes(flags.theme),
|
|
166
|
+
examples,
|
|
167
|
+
sizes: parseSizes(flags.sizes),
|
|
168
|
+
at,
|
|
169
|
+
config: parseConfig(flags.config),
|
|
170
|
+
services: flags.service ?? [],
|
|
171
|
+
click: flags.click,
|
|
172
|
+
evaluate: flags.eval,
|
|
173
|
+
onProgress: progress,
|
|
174
|
+
onResult: (line) => process.stdout.write(`${line}\n`),
|
|
175
|
+
}),
|
|
176
|
+
).catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));
|
|
177
|
+
|
|
178
|
+
const sheets = summary.sheets ? ` and ${summary.sheets} contact sheet(s)` : "";
|
|
179
|
+
log.success(`Rendered ${summary.shots} image(s)${sheets} into ${color.cyan("preview/sweep/")}`);
|
|
180
|
+
if (summary.failures.length) log.warn(summary.failures.map((f) => ` · ${f}`).join("\n"));
|
|
181
|
+
process.exit(summary.failures.length ? 1 : 0);
|
|
182
|
+
}
|
package/src/preview/capture.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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";
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
7
3
|
import {
|
|
8
4
|
freezeClock,
|
|
9
5
|
hashWidgetArtifacts,
|
|
@@ -14,6 +10,7 @@ import {
|
|
|
14
10
|
withRenderTimeout,
|
|
15
11
|
withSharedBrowser,
|
|
16
12
|
} from "./constraints";
|
|
13
|
+
import { serveHarness } from "./serve";
|
|
17
14
|
|
|
18
15
|
const THEMES = ["light", "dark"] as const;
|
|
19
16
|
|
|
@@ -45,37 +42,17 @@ export interface PreviewOptions {
|
|
|
45
42
|
|
|
46
43
|
// Filenames become CDN path segments, so keep them to [a-z0-9-]: an example
|
|
47
44
|
// labelled "Solar, battery and EV" must not put a comma in a URL.
|
|
48
|
-
function slug(s: string): string {
|
|
45
|
+
export function slug(s: string): string {
|
|
49
46
|
return s
|
|
50
47
|
.toLowerCase()
|
|
51
48
|
.replace(/[^a-z0-9]+/g, "-")
|
|
52
49
|
.replace(/^-|-$/g, "");
|
|
53
50
|
}
|
|
54
51
|
|
|
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
52
|
/**
|
|
71
53
|
* Build every widget, serve the harness, and screenshot each authored example
|
|
72
54
|
* in light and dark under the render worker's constraints (frozen clock, DNS
|
|
73
55
|
* 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
56
|
*/
|
|
80
57
|
export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary> {
|
|
81
58
|
const projectDir = resolve(opts.projectDir);
|
|
@@ -83,57 +60,16 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
|
|
|
83
60
|
const isolate = opts.isolate ?? false;
|
|
84
61
|
const progress = opts.onProgress ?? (() => {});
|
|
85
62
|
|
|
86
|
-
const distDir = resolve(projectDir, "dist");
|
|
87
63
|
const outDir = resolve(projectDir, "preview");
|
|
88
|
-
const
|
|
89
|
-
|
|
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"));
|
|
64
|
+
const server = await serveHarness(projectDir, only, progress);
|
|
65
|
+
const { base, origin, distDir, widgets: widgetNames } = server;
|
|
114
66
|
|
|
115
67
|
const skipped: string[] = [];
|
|
116
68
|
let shot = 0;
|
|
117
69
|
const failures: Failure[] = [];
|
|
118
70
|
const shotLists = new Map<string, ShotListEntry[]>();
|
|
119
71
|
|
|
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
72
|
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
73
|
mkdirSync(outDir, { recursive: true });
|
|
138
74
|
|
|
139
75
|
// 3. Enumerate each widget's shot list (one locked browser for the whole pass).
|
|
@@ -250,9 +186,7 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
|
|
|
250
186
|
if (isolate) await runAll(null);
|
|
251
187
|
else await withSharedBrowser((shared) => runAll(shared));
|
|
252
188
|
} finally {
|
|
253
|
-
await server.close()
|
|
254
|
-
// Temp root is throwaway staging; never leave it in the project tree.
|
|
255
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
189
|
+
await server.close();
|
|
256
190
|
}
|
|
257
191
|
|
|
258
192
|
return { shots: shot, widgets: shotLists.size, skipped, failures };
|
|
@@ -44,6 +44,10 @@ export const NO_EGRESS_ARGS = [
|
|
|
44
44
|
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost",
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
+
/** `CHROMIUM_PATH` points at a system Chromium where Playwright's own build is absent or cannot run. */
|
|
48
|
+
const launchLocked = () =>
|
|
49
|
+
chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined, args: NO_EGRESS_ARGS });
|
|
50
|
+
|
|
47
51
|
/**
|
|
48
52
|
* Record (do not intercept) every non-local request the page attempts.
|
|
49
53
|
*
|
|
@@ -153,6 +157,9 @@ function applyNixLdOnce(): void {
|
|
|
153
157
|
* it never returns, so the next shot's recycle hangs on it forever. Race the
|
|
154
158
|
* graceful close against a short deadline, then SIGKILL the process outright.
|
|
155
159
|
*/
|
|
160
|
+
// A still shows the settled state: the theme zeroes transitions under reduced motion.
|
|
161
|
+
const CONTEXT_OPTIONS = { deviceScaleFactor: 2, reducedMotion: "reduce" } as const;
|
|
162
|
+
|
|
156
163
|
async function hardClose(browser: Browser): Promise<void> {
|
|
157
164
|
// `process()` exists at runtime on a launched Chromium but is absent from the
|
|
158
165
|
// base Browser type; narrow it here rather than at the call site.
|
|
@@ -181,13 +188,12 @@ export async function withSharedBrowser<T>(fn: (browser: SharedBrowser) => Promi
|
|
|
181
188
|
// eventually wedges; recycling keeps cumulative state bounded.
|
|
182
189
|
const PAGES_PER_BROWSER = 8;
|
|
183
190
|
|
|
184
|
-
|
|
185
|
-
let browser = await launch();
|
|
191
|
+
let browser = await launchLocked();
|
|
186
192
|
let served = 0;
|
|
187
193
|
|
|
188
194
|
const recycle = async (): Promise<void> => {
|
|
189
195
|
await hardClose(browser);
|
|
190
|
-
browser = await
|
|
196
|
+
browser = await launchLocked();
|
|
191
197
|
served = 0;
|
|
192
198
|
};
|
|
193
199
|
|
|
@@ -199,14 +205,14 @@ export async function withSharedBrowser<T>(fn: (browser: SharedBrowser) => Promi
|
|
|
199
205
|
}
|
|
200
206
|
served++;
|
|
201
207
|
try {
|
|
202
|
-
const context = await browser.newContext(
|
|
208
|
+
const context = await browser.newContext(CONTEXT_OPTIONS);
|
|
203
209
|
return await context.newPage();
|
|
204
210
|
} catch {
|
|
205
211
|
// Lost between the liveness check and the context: relaunch once so the
|
|
206
212
|
// crash costs one render rather than the rest of the pass.
|
|
207
213
|
await recycle();
|
|
208
214
|
served = 1;
|
|
209
|
-
const context = await browser.newContext(
|
|
215
|
+
const context = await browser.newContext(CONTEXT_OPTIONS);
|
|
210
216
|
return context.newPage();
|
|
211
217
|
}
|
|
212
218
|
};
|
|
@@ -229,9 +235,9 @@ export async function withFreshBrowser<T>(fn: (page: Page, browser: Browser) =>
|
|
|
229
235
|
// point nix-ld at them so the bundled browser launches unmodified.
|
|
230
236
|
applyNixLdOnce();
|
|
231
237
|
|
|
232
|
-
const browser = await
|
|
238
|
+
const browser = await launchLocked();
|
|
233
239
|
try {
|
|
234
|
-
const context = await browser.newContext(
|
|
240
|
+
const context = await browser.newContext(CONTEXT_OPTIONS);
|
|
235
241
|
const page = await context.newPage();
|
|
236
242
|
return await fn(page, browser);
|
|
237
243
|
} finally {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ReactiveWidgetContext, WidgetDefinition } from "@glasshome/widget-sdk";
|
|
2
2
|
import { instantiateWidget } from "@glasshome/widget-sdk/host";
|
|
3
|
-
import { claimHostApi, getEntityView } from "@glasshome/sync-layer";
|
|
3
|
+
import { callService, claimHostApi, getEntityView } from "@glasshome/sync-layer";
|
|
4
4
|
import { byDomain, useAreas, useEntities } from "@glasshome/sync-layer/solid";
|
|
5
5
|
import type { EntityDataAdapter } from "@glasshome/ui/solid";
|
|
6
|
-
import { provideEntityData } from "@glasshome/ui/solid";
|
|
6
|
+
import { provideEntityData, provideIcons } from "@glasshome/ui/solid";
|
|
7
7
|
import type { IconifyJSON } from "@iconify/types";
|
|
8
|
+
import { getIconData } from "@iconify/utils";
|
|
8
9
|
// `?url` + runtime fetch, NOT a JSON import. Vite turns an imported JSON file
|
|
9
10
|
// into an ES module — a multi-megabyte object literal the JS engine has to
|
|
10
11
|
// evaluate on every page load, which cost ~200s per render. Fetching the same
|
|
@@ -43,6 +44,18 @@ async function preloadIcons(): Promise<void> {
|
|
|
43
44
|
]);
|
|
44
45
|
addCollection(mdi);
|
|
45
46
|
addCollection(lucide);
|
|
47
|
+
const collections: Record<string, IconifyJSON> = { mdi, lucide };
|
|
48
|
+
provideIcons({
|
|
49
|
+
bundled: {},
|
|
50
|
+
load: async (names) =>
|
|
51
|
+
Object.fromEntries(
|
|
52
|
+
names.map((name) => {
|
|
53
|
+
const [prefix = "", ...rest] = name.split(":");
|
|
54
|
+
const set = collections[prefix];
|
|
55
|
+
return [name, set ? (getIconData(set, rest.join(":")) ?? null) : null];
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
// No network fallback. Dash repoints this provider at its own /iconify/ proxy;
|
|
@@ -162,6 +175,10 @@ async function main(): Promise<void> {
|
|
|
162
175
|
document.documentElement.dataset.harnessExamples = JSON.stringify(
|
|
163
176
|
examples.map((e) => ({ label: e.label, size: e.size })),
|
|
164
177
|
);
|
|
178
|
+
document.documentElement.dataset.harnessBounds = JSON.stringify({
|
|
179
|
+
min: def.manifest.minSize,
|
|
180
|
+
max: def.manifest.maxSize,
|
|
181
|
+
});
|
|
165
182
|
const example = examples[exIndex];
|
|
166
183
|
if (!example) {
|
|
167
184
|
document.title = "harness-error: no example";
|
|
@@ -179,10 +196,17 @@ async function main(): Promise<void> {
|
|
|
179
196
|
const hostApi = claimHostApi();
|
|
180
197
|
if (!hostApi) throw new Error("harness: host API already claimed");
|
|
181
198
|
await hostApi.loadDemoData();
|
|
199
|
+
for (const call of params.getAll("svc")) {
|
|
200
|
+
const [service = "", entityId, data] = call.split("|");
|
|
201
|
+
const [domain = "", name = ""] = service.split(".");
|
|
202
|
+
await callService(domain, name, JSON.parse(data ?? "{}"), { entity_id: entityId });
|
|
203
|
+
}
|
|
182
204
|
|
|
183
205
|
// Background and padding are the renderer's job: the preview is the widget
|
|
184
206
|
// alone, tight and transparent (omitBackground on capture).
|
|
185
|
-
const { width, height } =
|
|
207
|
+
const { width, height } = params.has("pw")
|
|
208
|
+
? { width: Number(params.get("pw")), height: Number(params.get("ph")) }
|
|
209
|
+
: tilePx(example.size);
|
|
186
210
|
const stage = document.getElementById("stage");
|
|
187
211
|
if (!stage) return;
|
|
188
212
|
stage.style.width = `${width}px`;
|
|
@@ -193,7 +217,11 @@ async function main(): Promise<void> {
|
|
|
193
217
|
dimensions: () => ({ width, height }),
|
|
194
218
|
};
|
|
195
219
|
|
|
196
|
-
|
|
220
|
+
const config = {
|
|
221
|
+
...(example.config as Record<string, unknown>),
|
|
222
|
+
...JSON.parse(params.get("cfg") ?? "{}"),
|
|
223
|
+
};
|
|
224
|
+
mount(stage, def, config, ctx, css);
|
|
197
225
|
|
|
198
226
|
await document.fonts.ready;
|
|
199
227
|
// Signal to the capture driver that mount + fonts settled.
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
|
|
8
|
+
export interface HarnessServer {
|
|
9
|
+
base: string;
|
|
10
|
+
origin: string;
|
|
11
|
+
distDir: string;
|
|
12
|
+
widgets: string[];
|
|
13
|
+
close: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Every ancestor of the project dir, so vite's fs guard allows the harness to
|
|
17
|
+
// reach node_modules hoisted anywhere up the workspace (bun hoists widget-cli's
|
|
18
|
+
// deps to the project root, which may be several levels above a widget project).
|
|
19
|
+
function ancestors(dir: string): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
let d = dir;
|
|
22
|
+
for (;;) {
|
|
23
|
+
out.push(d);
|
|
24
|
+
const parent = dirname(d);
|
|
25
|
+
if (parent === d) break;
|
|
26
|
+
d = parent;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the widget bundles and serve the render harness over them.
|
|
33
|
+
*
|
|
34
|
+
* The vite root is a temp dir created UNDER the project so that: the harness's
|
|
35
|
+
* `../dist/*.js` glob resolves to `<projectDir>/dist`, and every bare import
|
|
36
|
+
* (@glasshome/*, iconify, solid) resolves from the project's own node_modules —
|
|
37
|
+
* a single solid/ui instance, which the built bundle's external imports require.
|
|
38
|
+
*/
|
|
39
|
+
export async function serveHarness(
|
|
40
|
+
projectDir: string,
|
|
41
|
+
only: string[],
|
|
42
|
+
progress: (message: string) => void,
|
|
43
|
+
): Promise<HarnessServer> {
|
|
44
|
+
const distDir = resolve(projectDir, "dist");
|
|
45
|
+
const harnessSrc = resolve(import.meta.dirname, "harness");
|
|
46
|
+
const tempRoot = resolve(projectDir, ".glasshome-preview");
|
|
47
|
+
|
|
48
|
+
progress(only.length ? `Building ${only.join(", ")}...` : "Building widgets...");
|
|
49
|
+
process.chdir(projectDir);
|
|
50
|
+
await buildWidgets({
|
|
51
|
+
srcDir: "src",
|
|
52
|
+
outDir: "dist",
|
|
53
|
+
...(only.length ? { only } : {}),
|
|
54
|
+
plugins: [solid({ solid: { delegateEvents: false } })],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const widgets = readdirSync(distDir)
|
|
58
|
+
.filter((f) => f.endsWith(".js"))
|
|
59
|
+
.map((f) => f.slice(0, -3))
|
|
60
|
+
.filter((n) => (only.length ? only.includes(n) : true))
|
|
61
|
+
.sort();
|
|
62
|
+
|
|
63
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(tempRoot, { recursive: true });
|
|
65
|
+
copyFileSync(resolve(harnessSrc, "harness.tsx"), resolve(tempRoot, "harness.tsx"));
|
|
66
|
+
copyFileSync(resolve(harnessSrc, "index.html"), resolve(tempRoot, "index.html"));
|
|
67
|
+
|
|
68
|
+
const server = await createServer({
|
|
69
|
+
root: tempRoot,
|
|
70
|
+
configFile: false,
|
|
71
|
+
// delegateEvents: false — widgets mount in closed shadow roots where Solid's
|
|
72
|
+
// document-level event delegation cannot see the target (matches the widgets
|
|
73
|
+
// build and dash mount). tailwindcss() compiles @glasshome/ui/styles so the
|
|
74
|
+
// app theme tokens land on :root exactly as they do in dash.
|
|
75
|
+
plugins: [tailwindcss(), solid({ solid: { delegateEvents: false } })],
|
|
76
|
+
server: { fs: { allow: [tempRoot, ...ancestors(projectDir)] } },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const close = async () => {
|
|
80
|
+
await server.close().catch(() => {});
|
|
81
|
+
// Temp root is throwaway staging; never leave it in the project tree.
|
|
82
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await server.listen();
|
|
87
|
+
const base = server.resolvedUrls?.local[0];
|
|
88
|
+
if (!base) throw new Error("vite dev server has no local url");
|
|
89
|
+
return { base, origin: new URL(base).origin, distDir, widgets, close };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await close();
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { Page } from "playwright";
|
|
4
|
+
import { slug } from "./capture";
|
|
5
|
+
import {
|
|
6
|
+
freezeClock,
|
|
7
|
+
settleAnimations,
|
|
8
|
+
watchEgress,
|
|
9
|
+
withRenderTimeout,
|
|
10
|
+
withSharedBrowser,
|
|
11
|
+
} from "./constraints";
|
|
12
|
+
import { serveHarness } from "./serve";
|
|
13
|
+
|
|
14
|
+
export type Theme = "light" | "dark";
|
|
15
|
+
|
|
16
|
+
export interface Box {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SweepOptions {
|
|
22
|
+
projectDir: string;
|
|
23
|
+
only: string[];
|
|
24
|
+
themes: Theme[];
|
|
25
|
+
/** Example indexes to render; all when empty. */
|
|
26
|
+
examples: number[];
|
|
27
|
+
/** Pixel boxes to render; each example's own size when empty. */
|
|
28
|
+
sizes: Box[];
|
|
29
|
+
at?: Date;
|
|
30
|
+
config?: Record<string, unknown>;
|
|
31
|
+
/** `domain.service|entity_id|json`, replayed against the demo home before mount. */
|
|
32
|
+
services: string[];
|
|
33
|
+
click?: string;
|
|
34
|
+
evaluate?: string;
|
|
35
|
+
onProgress?: (message: string) => void;
|
|
36
|
+
onResult?: (line: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SweepSummary {
|
|
40
|
+
shots: number;
|
|
41
|
+
sheets: number;
|
|
42
|
+
outDir: string;
|
|
43
|
+
failures: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Example {
|
|
47
|
+
label?: string;
|
|
48
|
+
size: { w: number; h: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Bounds {
|
|
52
|
+
min: { w: number; h: number };
|
|
53
|
+
max: { w: number; h: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_SIZES: Box[] = [70, 156, 242, 328].flatMap((height) =>
|
|
57
|
+
[84, 150, 200, 270, 340, 420].map((width) => ({ width, height })),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Inverse of the harness's tilePx height: rows * 70 + (rows - 1) * 16.
|
|
61
|
+
const rowsFor = (height: number) => Math.round((height + 16) / 86);
|
|
62
|
+
|
|
63
|
+
// Roots stay closed as in dash, so a handler that cannot fire there fails here too; --eval and --click reach in through this.
|
|
64
|
+
function keepShadowRoots(): void {
|
|
65
|
+
const attach = Element.prototype.attachShadow;
|
|
66
|
+
Element.prototype.attachShadow = function (init) {
|
|
67
|
+
const root = attach.call(this, init);
|
|
68
|
+
Object.defineProperty(this, "__widgetRoot", { value: root });
|
|
69
|
+
return root;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runSweep(opts: SweepOptions): Promise<SweepSummary> {
|
|
74
|
+
const progress = opts.onProgress ?? (() => {});
|
|
75
|
+
const report = opts.onResult ?? (() => {});
|
|
76
|
+
const projectDir = resolve(opts.projectDir);
|
|
77
|
+
const outDir = resolve(projectDir, "preview", "sweep");
|
|
78
|
+
const server = await serveHarness(projectDir, opts.only, progress);
|
|
79
|
+
const failures: string[] = [];
|
|
80
|
+
let shots = 0;
|
|
81
|
+
let sheets = 0;
|
|
82
|
+
|
|
83
|
+
const query = (widget: string, ex: number, theme: Theme, box?: Box) => {
|
|
84
|
+
const q = new URLSearchParams({ widget, ex: String(ex), theme });
|
|
85
|
+
if (box) {
|
|
86
|
+
q.set("pw", String(box.width));
|
|
87
|
+
q.set("ph", String(box.height));
|
|
88
|
+
}
|
|
89
|
+
if (opts.config) q.set("cfg", JSON.stringify(opts.config));
|
|
90
|
+
for (const s of opts.services) q.append("svc", s);
|
|
91
|
+
return `${server.base}?${q}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const render = async (page: Page, url: string, file: string): Promise<unknown> => {
|
|
95
|
+
watchEgress(page, server.origin);
|
|
96
|
+
await page.addInitScript(keepShadowRoots);
|
|
97
|
+
if (opts.at) await page.clock.install({ time: opts.at });
|
|
98
|
+
else await freezeClock(page);
|
|
99
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
100
|
+
await page.waitForSelector("html[data-harness-ready='1']", {
|
|
101
|
+
state: "attached",
|
|
102
|
+
timeout: 20_000,
|
|
103
|
+
});
|
|
104
|
+
await settleAnimations(page);
|
|
105
|
+
if (opts.click) {
|
|
106
|
+
// Playwright's actionability checks wait on animation frames, which a frozen clock never runs.
|
|
107
|
+
await page.clock.resume();
|
|
108
|
+
const at = await page.evaluate((selector) => {
|
|
109
|
+
const stage = document.getElementById("stage") as { __widgetRoot?: ShadowRoot } | null;
|
|
110
|
+
const box = stage?.__widgetRoot?.querySelector(selector)?.getBoundingClientRect();
|
|
111
|
+
return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
|
|
112
|
+
}, opts.click);
|
|
113
|
+
if (!at) throw new Error(`--click: nothing matches ${opts.click} inside the widget`);
|
|
114
|
+
await page.mouse.click(at.x, at.y);
|
|
115
|
+
await page.waitForTimeout(1_500);
|
|
116
|
+
}
|
|
117
|
+
await page.locator("#stage").screenshot({ path: file, omitBackground: true });
|
|
118
|
+
if (!opts.evaluate) return undefined;
|
|
119
|
+
return page.evaluate((code) => {
|
|
120
|
+
const root = (document.getElementById("stage") as { __widgetRoot?: ShadowRoot } | null)
|
|
121
|
+
?.__widgetRoot;
|
|
122
|
+
return new Function("root", `return (${code});`)(root);
|
|
123
|
+
}, opts.evaluate);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(outDir, { recursive: true });
|
|
128
|
+
await withSharedBrowser(async (browser) => {
|
|
129
|
+
for (const widget of server.widgets) {
|
|
130
|
+
const probe = await browser.newPage();
|
|
131
|
+
let examples: Example[] = [];
|
|
132
|
+
let bounds: Bounds | undefined;
|
|
133
|
+
try {
|
|
134
|
+
await probe.goto(query(widget, 0, "light"), { waitUntil: "domcontentloaded" });
|
|
135
|
+
await probe.waitForSelector("html[data-harness-bounds]", {
|
|
136
|
+
state: "attached",
|
|
137
|
+
timeout: 30_000,
|
|
138
|
+
});
|
|
139
|
+
examples = JSON.parse((await probe.getAttribute("html", "data-harness-examples")) ?? "[]");
|
|
140
|
+
bounds = JSON.parse((await probe.getAttribute("html", "data-harness-bounds")) ?? "null");
|
|
141
|
+
} finally {
|
|
142
|
+
await probe.context().close();
|
|
143
|
+
}
|
|
144
|
+
if (!examples.length || !bounds) {
|
|
145
|
+
failures.push(`${widget}: no examples to render`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const fits = (box: Box) => {
|
|
149
|
+
const rows = rowsFor(box.height);
|
|
150
|
+
return bounds !== undefined && rows >= bounds.min.h && rows <= bounds.max.h;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const indexes = opts.examples.length ? opts.examples : examples.map((_, i) => i);
|
|
154
|
+
for (const ex of indexes) {
|
|
155
|
+
const example = examples[ex];
|
|
156
|
+
if (!example) {
|
|
157
|
+
failures.push(`${widget}: no example ${ex}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const label = slug(example.label ?? `example-${ex}`);
|
|
161
|
+
const boxes = opts.sizes.length ? opts.sizes.filter(fits) : [undefined];
|
|
162
|
+
for (const theme of opts.themes) {
|
|
163
|
+
progress(`Rendering ${widget} / ${label} / ${theme}...`);
|
|
164
|
+
const shot: { box: Box | undefined; file: string }[] = [];
|
|
165
|
+
for (const box of boxes) {
|
|
166
|
+
const size = box ? `-${box.width}x${box.height}` : "";
|
|
167
|
+
const file = resolve(outDir, `${widget}-${label}-${theme}${size}.png`);
|
|
168
|
+
const page = await browser.newPage();
|
|
169
|
+
try {
|
|
170
|
+
const result = await withRenderTimeout(`${widget} ${label} ${theme}${size}`, () =>
|
|
171
|
+
render(page, query(widget, ex, theme, box), file),
|
|
172
|
+
);
|
|
173
|
+
shots++;
|
|
174
|
+
shot.push({ box, file });
|
|
175
|
+
if (opts.evaluate) {
|
|
176
|
+
report(`${widget} ${label} ${theme}${size}: ${JSON.stringify(result)}`);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
failures.push(`${widget} ${label} ${theme}${size}: ${(err as Error).message}`);
|
|
180
|
+
await browser.recycle().catch(() => {});
|
|
181
|
+
} finally {
|
|
182
|
+
await page
|
|
183
|
+
.context()
|
|
184
|
+
.close()
|
|
185
|
+
.catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (shot.length > 1) {
|
|
189
|
+
const page = await browser.newPage();
|
|
190
|
+
try {
|
|
191
|
+
await writeSheet(page, theme, shot, resolve(outDir, `${widget}-${label}-${theme}.png`));
|
|
192
|
+
sheets++;
|
|
193
|
+
} finally {
|
|
194
|
+
await page
|
|
195
|
+
.context()
|
|
196
|
+
.close()
|
|
197
|
+
.catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
await server.close();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { shots, sheets, outDir, failures };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// One row per height, widths left to right, each shot at its CSS size.
|
|
212
|
+
async function writeSheet(
|
|
213
|
+
page: Page,
|
|
214
|
+
theme: Theme,
|
|
215
|
+
shots: { box: Box | undefined; file: string }[],
|
|
216
|
+
path: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const rows = new Map<number, { box: Box; file: string }[]>();
|
|
219
|
+
for (const s of shots) {
|
|
220
|
+
if (!s.box) continue;
|
|
221
|
+
rows.set(s.box.height, [...(rows.get(s.box.height) ?? []), { box: s.box, file: s.file }]);
|
|
222
|
+
}
|
|
223
|
+
const background =
|
|
224
|
+
theme === "light"
|
|
225
|
+
? "linear-gradient(135deg,#e9edf3,#cfd8e3)"
|
|
226
|
+
: "linear-gradient(135deg,#1b2230,#0d1117)";
|
|
227
|
+
const body = [...rows.keys()]
|
|
228
|
+
.sort((a, b) => a - b)
|
|
229
|
+
.map((height) => {
|
|
230
|
+
const cells = (rows.get(height) ?? [])
|
|
231
|
+
.sort((a, b) => a.box.width - b.box.width)
|
|
232
|
+
.map(
|
|
233
|
+
({ box, file }) =>
|
|
234
|
+
`<figure><img width="${box.width}" height="${box.height}" src="data:image/png;base64,${readFileSync(file).toString("base64")}"><figcaption>${box.width}x${box.height}</figcaption></figure>`,
|
|
235
|
+
)
|
|
236
|
+
.join("");
|
|
237
|
+
return `<div class="row">${cells}</div>`;
|
|
238
|
+
})
|
|
239
|
+
.join("");
|
|
240
|
+
await page.setContent(
|
|
241
|
+
`<style>body{margin:0}#sheet{display:inline-block;padding:20px 20px 4px;background:${background};font:12px sans-serif;color:#888}.row{display:flex;gap:16px;align-items:flex-start;margin-bottom:16px}figure{margin:0}img{display:block}</style><div id="sheet">${body}</div>`,
|
|
242
|
+
);
|
|
243
|
+
await page.locator("#sheet").screenshot({ path });
|
|
244
|
+
}
|