basis-design 0.1.1 → 0.2.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/README.md +2 -1
- package/capture.html +12 -0
- package/package.json +72 -70
- package/server/brief.ts +14 -3
- package/server/capture.ts +128 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +112 -1
- package/server/project.ts +7 -1
- package/server/template.ts +168 -168
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +29 -1
- package/src/Workbench.tsx +1 -0
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +20 -18
- package/src/shell/Shell.module.css +1146 -1076
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -25
- package/template/components/specimen.ts +24 -1
- package/template/history.json +1 -0
package/server/template.ts
CHANGED
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import type { TemplateUpdate } from "../shared/protocol.ts";
|
|
7
|
-
import { repoRoot, SOURCE_DIRS } from "./paths.ts";
|
|
8
|
-
|
|
9
|
-
/*
|
|
10
|
-
* Keeping workspaces up to date with Basis' starter template (template/). A workspace owns its copy, so Basis
|
|
11
|
-
* replaces only files nobody changed: those that still match an earlier version of the template, listed in
|
|
12
|
-
* template/history.json. For files the agent has changed, Basis offers a guided turn that brings in what
|
|
13
|
-
* template/CHANGES.md describes, from a copy of the template in .basis/template/.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Earlier versions of each starter file, by workspace-relative path (written by scripts/template-history.ts). */
|
|
17
|
-
export const TEMPLATE_HISTORY_FILE = "template/history.json";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The version of the starter template. Raise it, with a section in template/CHANGES.md, when the template changes
|
|
21
|
-
* in a way worth bringing into workspaces whose agent has changed their components.
|
|
22
|
-
*/
|
|
23
|
-
export const TEMPLATE_VERSION = 1;
|
|
24
|
-
|
|
25
|
-
/** Where a workspace records the template version it has caught up with. */
|
|
26
|
-
const VERSION_FILE = path.join(".basis", "template.json");
|
|
27
|
-
|
|
28
|
-
/** The copy of the current template the agent compares against during the guided turn. */
|
|
29
|
-
export const TEMPLATE_COPY = ".basis/template";
|
|
30
|
-
|
|
31
|
-
export function hashText(text: string): string {
|
|
32
|
-
return createHash("sha256").update(text.replaceAll("\r\n", "\n")).digest("hex");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The starter files, as workspace-relative forward-slash paths ("components/Button.tsx"). */
|
|
36
|
-
export async function templateFiles(root = repoRoot): Promise<string[]> {
|
|
37
|
-
const files: string[] = [];
|
|
38
|
-
for (const dir of SOURCE_DIRS) {
|
|
39
|
-
const names = await readdir(path.join(root, "template", dir)).catch(() => [] as string[]);
|
|
40
|
-
files.push(...names.sort().map((name) => `${dir}/${name}`));
|
|
41
|
-
}
|
|
42
|
-
return files;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The hashes of every earlier version of each starter file, read from git. Only the script that writes
|
|
47
|
-
* template/history.json and its test use it; an installed Basis has no git history.
|
|
48
|
-
*/
|
|
49
|
-
export function earlierTemplateHashes(root: string): Record<string, string[]> {
|
|
50
|
-
const git = (args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 });
|
|
51
|
-
const history: Record<string, string[]> = {};
|
|
52
|
-
const current = git(["ls-files", ...SOURCE_DIRS.map((dir) => `template/${dir}`)])
|
|
53
|
-
.split("\n")
|
|
54
|
-
.filter((file) => file && file.split("/").length === 3);
|
|
55
|
-
for (const file of current) {
|
|
56
|
-
// The file as it is now, committed or not: the version a workspace should end up with.
|
|
57
|
-
const now = hashText(readFileSync(path.join(root, file), "utf8"));
|
|
58
|
-
const hashes = new Set<string>();
|
|
59
|
-
for (const commit of git(["log", "--format=%H", "--", file]).split("\n").filter(Boolean)) {
|
|
60
|
-
let text: string;
|
|
61
|
-
try {
|
|
62
|
-
text = git(["show", `${commit}:${file}`]);
|
|
63
|
-
} catch {
|
|
64
|
-
continue; // The commit removed the file.
|
|
65
|
-
}
|
|
66
|
-
const hash = hashText(text);
|
|
67
|
-
if (hash !== now) hashes.add(hash);
|
|
68
|
-
}
|
|
69
|
-
if (hashes.size) history[file.slice("template/".length)] = [...hashes].sort();
|
|
70
|
-
}
|
|
71
|
-
return history;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The files that belong together: a component's source, stylesheet and specimen share the part of their name
|
|
76
|
-
* before the first dot ("patterns/Navbar"), and a new Navbar.tsx needs the stylesheet written for it.
|
|
77
|
-
*/
|
|
78
|
-
function unitOf(file: string): string {
|
|
79
|
-
const slash = file.lastIndexOf("/");
|
|
80
|
-
return file.slice(0, slash + 1) + file.slice(slash + 1).split(".")[0];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Replaces starter files that still match an earlier version of the template with the current one, a unit at a
|
|
85
|
-
* time (see unitOf): a unit is updated only when none of its files was changed, so a component is never left with a
|
|
86
|
-
* new source and an old stylesheet. Returns the paths it replaced. Changed or removed files are left alone.
|
|
87
|
-
*/
|
|
88
|
-
export async function updateUntouchedStarterFiles(dir: string): Promise<string[]> {
|
|
89
|
-
const history = JSON.parse(await readFile(path.join(repoRoot, TEMPLATE_HISTORY_FILE), "utf8")) as Record<
|
|
90
|
-
string,
|
|
91
|
-
string[]
|
|
92
|
-
>;
|
|
93
|
-
const units = new Map<string, { file: string; current: string; ours: string }[]>();
|
|
94
|
-
for (const file of await templateFiles()) {
|
|
95
|
-
const target = path.join(dir, file);
|
|
96
|
-
if (!existsSync(target)) continue;
|
|
97
|
-
const current = (await readFile(path.join(repoRoot, "template", file), "utf8")).replaceAll("\r\n", "\n");
|
|
98
|
-
const files = units.get(unitOf(file)) ?? [];
|
|
99
|
-
files.push({ file, current, ours: hashText(await readFile(target, "utf8")) });
|
|
100
|
-
units.set(unitOf(file), files);
|
|
101
|
-
}
|
|
102
|
-
const updated: string[] = [];
|
|
103
|
-
for (const files of units.values()) {
|
|
104
|
-
const untouched = files.every(
|
|
105
|
-
({ file, current, ours }) => ours === hashText(current) || history[file]?.includes(ours),
|
|
106
|
-
);
|
|
107
|
-
if (!untouched) continue;
|
|
108
|
-
for (const { file, current, ours } of files) {
|
|
109
|
-
if (ours === hashText(current)) continue;
|
|
110
|
-
await writeFile(path.join(dir, file), current);
|
|
111
|
-
updated.push(file);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return updated;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function recordedTemplateVersion(dir: string): Promise<number> {
|
|
118
|
-
try {
|
|
119
|
-
return Number(JSON.parse(await readFile(path.join(dir, VERSION_FILE), "utf8")).version) || 0;
|
|
120
|
-
} catch {
|
|
121
|
-
return 0;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function recordTemplateVersion(dir: string, version = TEMPLATE_VERSION): Promise<void> {
|
|
126
|
-
await mkdir(path.join(dir, ".basis"), { recursive: true });
|
|
127
|
-
await writeFile(path.join(dir, VERSION_FILE), `${JSON.stringify({ version })}\n`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Whether the workspace is behind the template. A workspace whose starter files all match the current template
|
|
132
|
-
* has nothing to bring in, so it is recorded as caught up.
|
|
133
|
-
*/
|
|
134
|
-
export async function templateUpdate(dir: string): Promise<TemplateUpdate | null> {
|
|
135
|
-
const from = await recordedTemplateVersion(dir);
|
|
136
|
-
if (from >= TEMPLATE_VERSION) return null;
|
|
137
|
-
const files: string[] = [];
|
|
138
|
-
for (const file of await templateFiles()) {
|
|
139
|
-
const target = path.join(dir, file);
|
|
140
|
-
if (!existsSync(target)) continue;
|
|
141
|
-
const ours = hashText(await readFile(target, "utf8"));
|
|
142
|
-
if (ours !== hashText(await readFile(path.join(repoRoot, "template", file), "utf8"))) files.push(file);
|
|
143
|
-
}
|
|
144
|
-
if (files.length === 0) {
|
|
145
|
-
await recordTemplateVersion(dir);
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
return { from, to: TEMPLATE_VERSION, files };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** The sections of template/CHANGES.md newer than `from`. */
|
|
152
|
-
export async function templateChanges(from: number): Promise<string> {
|
|
153
|
-
const text = (await readFile(path.join(repoRoot, "template", "CHANGES.md"), "utf8")).replaceAll("\r\n", "\n");
|
|
154
|
-
return text
|
|
155
|
-
.split(/^(?=## )/m)
|
|
156
|
-
.filter((section) => Number(section.match(/^## (\d+)/)?.[1] ?? 0) > from)
|
|
157
|
-
.join("")
|
|
158
|
-
.trim();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Copies the current template into the workspace for the guided turn to read (it is generated, not history). */
|
|
162
|
-
export async function writeTemplateCopy(dir: string): Promise<void> {
|
|
163
|
-
const target = path.join(dir, TEMPLATE_COPY);
|
|
164
|
-
await rm(target, { recursive: true, force: true });
|
|
165
|
-
for (const folder of SOURCE_DIRS) {
|
|
166
|
-
await cp(path.join(repoRoot, "template", folder), path.join(target, folder), { recursive: true });
|
|
167
|
-
}
|
|
168
|
-
}
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { TemplateUpdate } from "../shared/protocol.ts";
|
|
7
|
+
import { repoRoot, SOURCE_DIRS } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Keeping workspaces up to date with Basis' starter template (template/). A workspace owns its copy, so Basis
|
|
11
|
+
* replaces only files nobody changed: those that still match an earlier version of the template, listed in
|
|
12
|
+
* template/history.json. For files the agent has changed, Basis offers a guided turn that brings in what
|
|
13
|
+
* template/CHANGES.md describes, from a copy of the template in .basis/template/.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Earlier versions of each starter file, by workspace-relative path (written by scripts/template-history.ts). */
|
|
17
|
+
export const TEMPLATE_HISTORY_FILE = "template/history.json";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The version of the starter template. Raise it, with a section in template/CHANGES.md, when the template changes
|
|
21
|
+
* in a way worth bringing into workspaces whose agent has changed their components.
|
|
22
|
+
*/
|
|
23
|
+
export const TEMPLATE_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** Where a workspace records the template version it has caught up with. */
|
|
26
|
+
const VERSION_FILE = path.join(".basis", "template.json");
|
|
27
|
+
|
|
28
|
+
/** The copy of the current template the agent compares against during the guided turn. */
|
|
29
|
+
export const TEMPLATE_COPY = ".basis/template";
|
|
30
|
+
|
|
31
|
+
export function hashText(text: string): string {
|
|
32
|
+
return createHash("sha256").update(text.replaceAll("\r\n", "\n")).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The starter files, as workspace-relative forward-slash paths ("components/Button.tsx"). */
|
|
36
|
+
export async function templateFiles(root = repoRoot): Promise<string[]> {
|
|
37
|
+
const files: string[] = [];
|
|
38
|
+
for (const dir of SOURCE_DIRS) {
|
|
39
|
+
const names = await readdir(path.join(root, "template", dir)).catch(() => [] as string[]);
|
|
40
|
+
files.push(...names.sort().map((name) => `${dir}/${name}`));
|
|
41
|
+
}
|
|
42
|
+
return files;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The hashes of every earlier version of each starter file, read from git. Only the script that writes
|
|
47
|
+
* template/history.json and its test use it; an installed Basis has no git history.
|
|
48
|
+
*/
|
|
49
|
+
export function earlierTemplateHashes(root: string): Record<string, string[]> {
|
|
50
|
+
const git = (args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 });
|
|
51
|
+
const history: Record<string, string[]> = {};
|
|
52
|
+
const current = git(["ls-files", ...SOURCE_DIRS.map((dir) => `template/${dir}`)])
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter((file) => file && file.split("/").length === 3);
|
|
55
|
+
for (const file of current) {
|
|
56
|
+
// The file as it is now, committed or not: the version a workspace should end up with.
|
|
57
|
+
const now = hashText(readFileSync(path.join(root, file), "utf8"));
|
|
58
|
+
const hashes = new Set<string>();
|
|
59
|
+
for (const commit of git(["log", "--format=%H", "--", file]).split("\n").filter(Boolean)) {
|
|
60
|
+
let text: string;
|
|
61
|
+
try {
|
|
62
|
+
text = git(["show", `${commit}:${file}`]);
|
|
63
|
+
} catch {
|
|
64
|
+
continue; // The commit removed the file.
|
|
65
|
+
}
|
|
66
|
+
const hash = hashText(text);
|
|
67
|
+
if (hash !== now) hashes.add(hash);
|
|
68
|
+
}
|
|
69
|
+
if (hashes.size) history[file.slice("template/".length)] = [...hashes].sort();
|
|
70
|
+
}
|
|
71
|
+
return history;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The files that belong together: a component's source, stylesheet and specimen share the part of their name
|
|
76
|
+
* before the first dot ("patterns/Navbar"), and a new Navbar.tsx needs the stylesheet written for it.
|
|
77
|
+
*/
|
|
78
|
+
function unitOf(file: string): string {
|
|
79
|
+
const slash = file.lastIndexOf("/");
|
|
80
|
+
return file.slice(0, slash + 1) + file.slice(slash + 1).split(".")[0];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Replaces starter files that still match an earlier version of the template with the current one, a unit at a
|
|
85
|
+
* time (see unitOf): a unit is updated only when none of its files was changed, so a component is never left with a
|
|
86
|
+
* new source and an old stylesheet. Returns the paths it replaced. Changed or removed files are left alone.
|
|
87
|
+
*/
|
|
88
|
+
export async function updateUntouchedStarterFiles(dir: string): Promise<string[]> {
|
|
89
|
+
const history = JSON.parse(await readFile(path.join(repoRoot, TEMPLATE_HISTORY_FILE), "utf8")) as Record<
|
|
90
|
+
string,
|
|
91
|
+
string[]
|
|
92
|
+
>;
|
|
93
|
+
const units = new Map<string, { file: string; current: string; ours: string }[]>();
|
|
94
|
+
for (const file of await templateFiles()) {
|
|
95
|
+
const target = path.join(dir, file);
|
|
96
|
+
if (!existsSync(target)) continue;
|
|
97
|
+
const current = (await readFile(path.join(repoRoot, "template", file), "utf8")).replaceAll("\r\n", "\n");
|
|
98
|
+
const files = units.get(unitOf(file)) ?? [];
|
|
99
|
+
files.push({ file, current, ours: hashText(await readFile(target, "utf8")) });
|
|
100
|
+
units.set(unitOf(file), files);
|
|
101
|
+
}
|
|
102
|
+
const updated: string[] = [];
|
|
103
|
+
for (const files of units.values()) {
|
|
104
|
+
const untouched = files.every(
|
|
105
|
+
({ file, current, ours }) => ours === hashText(current) || history[file]?.includes(ours),
|
|
106
|
+
);
|
|
107
|
+
if (!untouched) continue;
|
|
108
|
+
for (const { file, current, ours } of files) {
|
|
109
|
+
if (ours === hashText(current)) continue;
|
|
110
|
+
await writeFile(path.join(dir, file), current);
|
|
111
|
+
updated.push(file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function recordedTemplateVersion(dir: string): Promise<number> {
|
|
118
|
+
try {
|
|
119
|
+
return Number(JSON.parse(await readFile(path.join(dir, VERSION_FILE), "utf8")).version) || 0;
|
|
120
|
+
} catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function recordTemplateVersion(dir: string, version = TEMPLATE_VERSION): Promise<void> {
|
|
126
|
+
await mkdir(path.join(dir, ".basis"), { recursive: true });
|
|
127
|
+
await writeFile(path.join(dir, VERSION_FILE), `${JSON.stringify({ version })}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the workspace is behind the template. A workspace whose starter files all match the current template
|
|
132
|
+
* has nothing to bring in, so it is recorded as caught up.
|
|
133
|
+
*/
|
|
134
|
+
export async function templateUpdate(dir: string): Promise<TemplateUpdate | null> {
|
|
135
|
+
const from = await recordedTemplateVersion(dir);
|
|
136
|
+
if (from >= TEMPLATE_VERSION) return null;
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
for (const file of await templateFiles()) {
|
|
139
|
+
const target = path.join(dir, file);
|
|
140
|
+
if (!existsSync(target)) continue;
|
|
141
|
+
const ours = hashText(await readFile(target, "utf8"));
|
|
142
|
+
if (ours !== hashText(await readFile(path.join(repoRoot, "template", file), "utf8"))) files.push(file);
|
|
143
|
+
}
|
|
144
|
+
if (files.length === 0) {
|
|
145
|
+
await recordTemplateVersion(dir);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return { from, to: TEMPLATE_VERSION, files };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The sections of template/CHANGES.md newer than `from`. */
|
|
152
|
+
export async function templateChanges(from: number): Promise<string> {
|
|
153
|
+
const text = (await readFile(path.join(repoRoot, "template", "CHANGES.md"), "utf8")).replaceAll("\r\n", "\n");
|
|
154
|
+
return text
|
|
155
|
+
.split(/^(?=## )/m)
|
|
156
|
+
.filter((section) => Number(section.match(/^## (\d+)/)?.[1] ?? 0) > from)
|
|
157
|
+
.join("")
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Copies the current template into the workspace for the guided turn to read (it is generated, not history). */
|
|
162
|
+
export async function writeTemplateCopy(dir: string): Promise<void> {
|
|
163
|
+
const target = path.join(dir, TEMPLATE_COPY);
|
|
164
|
+
await rm(target, { recursive: true, force: true });
|
|
165
|
+
for (const folder of SOURCE_DIRS) {
|
|
166
|
+
await cp(path.join(repoRoot, "template", folder), path.join(target, folder), { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Takes the verification kit's screenshots on this machine, with the settings Basis used (basis-verify.json):
|
|
4
|
+
*
|
|
5
|
+
* node basis-verify/capture.mjs design [--out <dir>] the design, into expected/
|
|
6
|
+
* node basis-verify/capture.mjs app [--targets <file>] [--out <dir>] the implementation, into actual/
|
|
7
|
+
*
|
|
8
|
+
* Options for both: --only <view>[/<screen>] to capture part of the kit, --channel chrome|msedge to use an
|
|
9
|
+
* installed browser instead of Playwright's Chromium.
|
|
10
|
+
*
|
|
11
|
+
* Needs Playwright in the project (`npm i -D playwright` and `npx playwright install chromium`). Capture both
|
|
12
|
+
* sides with the same machine and browser: fonts and antialiasing differ between systems, so only images taken
|
|
13
|
+
* the same way can match pixel for pixel. Compare them with compare.mjs.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const kit = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const mode = args[0];
|
|
24
|
+
|
|
25
|
+
function option(name, fallback) {
|
|
26
|
+
const index = args.indexOf(name);
|
|
27
|
+
return index === -1 ? fallback : args[index + 1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function playwright() {
|
|
31
|
+
for (const name of ["playwright", "playwright-core", "@playwright/test"]) {
|
|
32
|
+
try {
|
|
33
|
+
const module = await import(name);
|
|
34
|
+
return module.chromium ?? module.default?.chromium;
|
|
35
|
+
} catch {
|
|
36
|
+
// Not installed here; try the next one.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Playwright is not installed in this project. Run `npm i -D playwright` and `npx playwright install chromium`.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TYPES = {
|
|
45
|
+
".html": "text/html; charset=utf-8",
|
|
46
|
+
".js": "text/javascript; charset=utf-8",
|
|
47
|
+
".css": "text/css; charset=utf-8",
|
|
48
|
+
".json": "application/json",
|
|
49
|
+
".svg": "image/svg+xml",
|
|
50
|
+
".png": "image/png",
|
|
51
|
+
".jpg": "image/jpeg",
|
|
52
|
+
".woff2": "font/woff2",
|
|
53
|
+
".woff": "font/woff",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Serves the kit's copy of the design on a free local port. */
|
|
57
|
+
async function serveDesign() {
|
|
58
|
+
const root = path.join(kit, "design");
|
|
59
|
+
const server = http.createServer(async (req, res) => {
|
|
60
|
+
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname);
|
|
61
|
+
const file = path.join(root, pathname === "/" ? "index.html" : pathname);
|
|
62
|
+
if (!file.startsWith(root + path.sep)) {
|
|
63
|
+
res.writeHead(403).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const body = await readFile(file);
|
|
68
|
+
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream" });
|
|
69
|
+
res.end(body);
|
|
70
|
+
} catch {
|
|
71
|
+
res.writeHead(404).end();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
75
|
+
return { server, url: `http://127.0.0.1:${server.address().port}/index.html` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Waits until fonts are loaded, images decoded and the layout keeps its size, as the design's page does. */
|
|
79
|
+
async function settle(page) {
|
|
80
|
+
await page.evaluate(async () => {
|
|
81
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
await document.fonts.ready;
|
|
83
|
+
await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)));
|
|
84
|
+
let last = "";
|
|
85
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
86
|
+
await wait(50);
|
|
87
|
+
const size = `${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`;
|
|
88
|
+
if (size === last) break;
|
|
89
|
+
last = size;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function targetFor(targets, view, screen) {
|
|
95
|
+
const entry = targets.screens?.[`${view}/${screen}`];
|
|
96
|
+
if (!entry) return undefined;
|
|
97
|
+
const target = typeof entry === "string" ? { url: entry } : entry;
|
|
98
|
+
return { ...target, url: new URL(target.url, targets.baseUrl ?? undefined).href };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
if (mode !== "design" && mode !== "app") {
|
|
103
|
+
console.error("Usage: node capture.mjs design|app [--targets <file>] [--out <dir>] [--only <view>[/<screen>]]");
|
|
104
|
+
process.exitCode = 2;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const manifest = JSON.parse(await readFile(path.join(kit, "basis-verify.json"), "utf8"));
|
|
108
|
+
const { render } = manifest;
|
|
109
|
+
const out = option("--out")
|
|
110
|
+
? path.resolve(option("--out"))
|
|
111
|
+
: path.join(kit, mode === "design" ? "expected" : "actual");
|
|
112
|
+
const only = option("--only");
|
|
113
|
+
let targets = {};
|
|
114
|
+
const targetsFile = option("--targets") ? path.resolve(option("--targets")) : path.join(kit, "targets.json");
|
|
115
|
+
if (mode === "app") {
|
|
116
|
+
if (!existsSync(targetsFile)) {
|
|
117
|
+
throw new Error(`No targets file at ${targetsFile}. See README.md, "Capture the implementation".`);
|
|
118
|
+
}
|
|
119
|
+
targets = JSON.parse(await readFile(targetsFile, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chromium = await playwright();
|
|
123
|
+
const browser = await chromium.launch({ headless: true, args: render.args, channel: option("--channel") });
|
|
124
|
+
console.log(
|
|
125
|
+
`Browser: ${browser.version()}${browser.version() === manifest.browser.version ? "" : ` (Basis used ${manifest.browser.version})`}`,
|
|
126
|
+
);
|
|
127
|
+
const design = mode === "design" ? await serveDesign() : undefined;
|
|
128
|
+
const context = await browser.newContext({
|
|
129
|
+
deviceScaleFactor: render.deviceScaleFactor,
|
|
130
|
+
reducedMotion: render.reducedMotion,
|
|
131
|
+
colorScheme: render.colorScheme,
|
|
132
|
+
viewport: { width: 1440, height: 900 },
|
|
133
|
+
...(mode === "app" && targets.storageState
|
|
134
|
+
? { storageState: path.resolve(path.dirname(targetsFile), targets.storageState) }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
const page = await context.newPage();
|
|
138
|
+
let taken = 0;
|
|
139
|
+
let skipped = 0;
|
|
140
|
+
const unstable = [];
|
|
141
|
+
try {
|
|
142
|
+
for (const view of manifest.views) {
|
|
143
|
+
for (const screen of view.screens) {
|
|
144
|
+
const key = `${view.id}/${screen.id}`;
|
|
145
|
+
if (only && only !== view.id && only !== key) continue;
|
|
146
|
+
const target = mode === "app" ? targetFor(targets, view.id, screen.id) : undefined;
|
|
147
|
+
if (mode === "app" && !target) {
|
|
148
|
+
console.log(`skip ${key}: no target in targets.json`);
|
|
149
|
+
skipped++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const shot of screen.shots) {
|
|
153
|
+
await page.setViewportSize({ width: shot.width, height: shot.viewportHeight });
|
|
154
|
+
await page.emulateMedia({ colorScheme: shot.theme });
|
|
155
|
+
const take = async () => {
|
|
156
|
+
if (design) {
|
|
157
|
+
const query = `?view=${encodeURIComponent(view.id)}&screen=${encodeURIComponent(screen.id)}&theme=${shot.theme}`;
|
|
158
|
+
await page.goto(design.url + query);
|
|
159
|
+
await page.waitForSelector(manifest.design.ready, { state: "attached", timeout: 60_000 });
|
|
160
|
+
} else {
|
|
161
|
+
await page.goto(target.url, { waitUntil: "networkidle" });
|
|
162
|
+
if (target.waitFor) await page.waitForSelector(target.waitFor, { timeout: 60_000 });
|
|
163
|
+
if (targets.themeAttribute) {
|
|
164
|
+
await page.evaluate(
|
|
165
|
+
([name, value]) => document.documentElement.setAttribute(name, value),
|
|
166
|
+
[targets.themeAttribute, shot.theme],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
await page.addStyleTag({ content: render.neutralCss });
|
|
170
|
+
await settle(page);
|
|
171
|
+
}
|
|
172
|
+
return page.screenshot({ fullPage: shot.fullPage, animations: "disabled", caret: "hide" });
|
|
173
|
+
};
|
|
174
|
+
const first = await take();
|
|
175
|
+
const second = await take();
|
|
176
|
+
const name = shot.file.replace(/^screens\//, "");
|
|
177
|
+
const file = path.join(out, name);
|
|
178
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
179
|
+
await writeFile(file, first);
|
|
180
|
+
taken++;
|
|
181
|
+
const stable = first.equals(second);
|
|
182
|
+
if (!stable) unstable.push(name);
|
|
183
|
+
console.log(`${stable ? "ok " : "UNSTABLE"} ${name}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
await context.close();
|
|
189
|
+
await browser.close();
|
|
190
|
+
design?.server.close();
|
|
191
|
+
}
|
|
192
|
+
console.log(
|
|
193
|
+
`\n${taken} shots in ${path.relative(process.cwd(), out) || "."}${skipped ? `, ${skipped} screens without a target` : ""}.`,
|
|
194
|
+
);
|
|
195
|
+
if (unstable.length) {
|
|
196
|
+
console.log(
|
|
197
|
+
`${unstable.length} shots differed between two captures, so they can never match exactly: ${unstable.join(", ")}. ` +
|
|
198
|
+
"Look for clocks, random values, animations or content that loads late.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((error) => {
|
|
204
|
+
console.error(error instanceof Error ? error.message : error);
|
|
205
|
+
process.exitCode = 2;
|
|
206
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Image {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
data: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export interface Region {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Exception {
|
|
13
|
+
name: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
view: string;
|
|
16
|
+
screen?: string;
|
|
17
|
+
widths?: number[];
|
|
18
|
+
themes?: string[];
|
|
19
|
+
region?: Region;
|
|
20
|
+
}
|
|
21
|
+
export function decodePng(buffer: Buffer): Image;
|
|
22
|
+
export function encodePng(image: Image): Buffer;
|
|
23
|
+
export function compareImages(
|
|
24
|
+
expected: Image,
|
|
25
|
+
actual: Image,
|
|
26
|
+
options?: { tolerance?: number; regions?: Region[] },
|
|
27
|
+
): {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
sameSize: boolean;
|
|
31
|
+
differing: number;
|
|
32
|
+
counted: number;
|
|
33
|
+
percent: number;
|
|
34
|
+
diff: Image;
|
|
35
|
+
};
|
|
36
|
+
export function exceptionsFor<T extends Exception>(
|
|
37
|
+
exceptions: T[],
|
|
38
|
+
shot: { view: string; screen: string; width: number; theme: string },
|
|
39
|
+
): T[];
|