tether-ui 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli/format.d.ts +3 -0
- package/dist/cli/format.js +30 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +135 -0
- package/dist/contract/infer.d.ts +2 -0
- package/dist/contract/infer.js +27 -0
- package/dist/drift/compare.d.ts +5 -0
- package/dist/drift/compare.js +121 -0
- package/dist/report/server.d.ts +1 -0
- package/dist/report/server.js +80 -0
- package/dist/scanner/browser.d.ts +2 -0
- package/dist/scanner/browser.js +22 -0
- package/dist/scanner/collect.d.ts +10 -0
- package/dist/scanner/collect.js +171 -0
- package/dist/scanner/scan.d.ts +2 -0
- package/dist/scanner/scan.js +82 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +12 -0
- package/dist/utils/config.d.ts +5 -0
- package/dist/utils/config.js +85 -0
- package/dist/utils/normalize.d.ts +4 -0
- package/dist/utils/normalize.js +66 -0
- package/dist/utils/storage.d.ts +2 -0
- package/dist/utils/storage.js +17 -0
- package/package.json +60 -0
- package/report/out/404/index.html +1 -0
- package/report/out/404.html +1 -0
- package/report/out/__next.__PAGE__.txt +20 -0
- package/report/out/__next._full.txt +15 -0
- package/report/out/__next._tree.txt +2 -0
- package/report/out/_next/static/NEP_BMzhg1ixudz9w2f3I/_buildManifest.js +1 -0
- package/report/out/_next/static/NEP_BMzhg1ixudz9w2f3I/_ssgManifest.js +1 -0
- package/report/out/_next/static/chunks/100-cde31512e90ea4ee.js +1 -0
- package/report/out/_next/static/chunks/158-65f5491737ca4c2e.js +52 -0
- package/report/out/_next/static/chunks/979-2cc4e681ca63e0b3.js +1 -0
- package/report/out/_next/static/chunks/app/_global-error/page-b656bfa1d65ad738.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/page-e3039971fa1f5f8d.js +1 -0
- package/report/out/_next/static/chunks/app/contract/page-340f86944045adea.js +1 -0
- package/report/out/_next/static/chunks/app/drift/page-559460ac1a19903b.js +1 -0
- package/report/out/_next/static/chunks/app/layout-6c6cbe0311c0f643.js +1 -0
- package/report/out/_next/static/chunks/app/page-ab86e7319bb488c8.js +1 -0
- package/report/out/_next/static/chunks/app/viewports/page-8970bc5bd4e2e4f2.js +1 -0
- package/report/out/_next/static/chunks/c7879cf7-33323d49ba6da96a.js +1 -0
- package/report/out/_next/static/chunks/framework-daae672d050d1c9a.js +1 -0
- package/report/out/_next/static/chunks/main-42ac6ccb2a105cbb.js +5 -0
- package/report/out/_next/static/chunks/main-app-91d98c37495a2d56.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/app-error-b656bfa1d65ad738.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/forbidden-b656bfa1d65ad738.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-657c3d8e42266fe3.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/not-found-b656bfa1d65ad738.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/unauthorized-b656bfa1d65ad738.js +1 -0
- package/report/out/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/report/out/_next/static/chunks/webpack-21c0bda91597affb.js +1 -0
- package/report/out/_next/static/css/b85889345c486052.css +1 -0
- package/report/out/_not-found/__next._full.txt +16 -0
- package/report/out/_not-found/__next._not-found.__PAGE__.txt +22 -0
- package/report/out/_not-found/__next._tree.txt +2 -0
- package/report/out/_not-found/index.html +1 -0
- package/report/out/_not-found/index.txt +16 -0
- package/report/out/contract/__next._full.txt +17 -0
- package/report/out/contract/__next._tree.txt +2 -0
- package/report/out/contract/__next.contract.__PAGE__.txt +23 -0
- package/report/out/contract/index.html +1 -0
- package/report/out/contract/index.txt +17 -0
- package/report/out/drift/__next._full.txt +17 -0
- package/report/out/drift/__next._tree.txt +2 -0
- package/report/out/drift/__next.drift.__PAGE__.txt +23 -0
- package/report/out/drift/index.html +1 -0
- package/report/out/drift/index.txt +17 -0
- package/report/out/index.html +1 -0
- package/report/out/index.txt +15 -0
- package/report/out/knot.svg +1 -0
- package/report/out/viewports/__next._full.txt +17 -0
- package/report/out/viewports/__next._tree.txt +2 -0
- package/report/out/viewports/__next.viewports.__PAGE__.txt +23 -0
- package/report/out/viewports/index.html +1 -0
- package/report/out/viewports/index.txt +17 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve, extname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { exists } from "../utils/config.js";
|
|
7
|
+
const webRoot = fileURLToPath(new URL("../../report/out/", import.meta.url));
|
|
8
|
+
export async function serveReport(directory, port, open) {
|
|
9
|
+
if (!(await exists(join(directory, "report.json"))))
|
|
10
|
+
throw new Error("No report found. Run tether init or tether check first.");
|
|
11
|
+
if (!(await exists(join(webRoot, "index.html"))))
|
|
12
|
+
throw new Error("The report has not been built. Run npm run build from the tether repository.");
|
|
13
|
+
const server = createServer(async (request, response) => {
|
|
14
|
+
if (!["GET", "HEAD"].includes(request.method ?? "")) {
|
|
15
|
+
response.writeHead(405).end();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(request.headers.host ?? "")) {
|
|
19
|
+
response.writeHead(403).end();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
|
|
24
|
+
const data = pathname === "/report.json" || pathname.startsWith("/screenshots/");
|
|
25
|
+
const root = data ? resolve(directory) : resolve(webRoot);
|
|
26
|
+
let path = resolve(root, "." + pathname);
|
|
27
|
+
if (path !== root && !path.startsWith(root + sep)) {
|
|
28
|
+
response.writeHead(403).end();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if ((await stat(path)).isDirectory())
|
|
32
|
+
path = join(path, "index.html");
|
|
33
|
+
const types = {
|
|
34
|
+
".html": "text/html; charset=utf-8",
|
|
35
|
+
".js": "application/javascript",
|
|
36
|
+
".css": "text/css",
|
|
37
|
+
".json": "application/json",
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".svg": "image/svg+xml",
|
|
40
|
+
".txt": "text/plain",
|
|
41
|
+
".ico": "image/x-icon",
|
|
42
|
+
".woff2": "font/woff2",
|
|
43
|
+
};
|
|
44
|
+
const body = await readFile(path);
|
|
45
|
+
response.writeHead(200, {
|
|
46
|
+
"Content-Type": types[extname(path)] ?? "application/octet-stream",
|
|
47
|
+
"Cache-Control": "no-store",
|
|
48
|
+
"X-Content-Type-Options": "nosniff",
|
|
49
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
50
|
+
});
|
|
51
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
response.writeHead(404).end("Not found");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
await new Promise((done, reject) => {
|
|
58
|
+
server.once("error", reject);
|
|
59
|
+
server.listen(port, "127.0.0.1", done);
|
|
60
|
+
}).catch(() => {
|
|
61
|
+
throw new Error(`Couldn't open report port ${port}. Try tether report --port ${port + 1}.`);
|
|
62
|
+
});
|
|
63
|
+
const address = server.address();
|
|
64
|
+
if (!address || typeof address === "string")
|
|
65
|
+
throw new Error("Missing report address");
|
|
66
|
+
const url = `http://localhost:${address.port}`;
|
|
67
|
+
console.log(`\ntether report → ${url}\n\nPress Ctrl+C to stop.\n`);
|
|
68
|
+
if (open) {
|
|
69
|
+
const command = process.platform === "darwin"
|
|
70
|
+
? "open"
|
|
71
|
+
: process.platform === "win32"
|
|
72
|
+
? "cmd"
|
|
73
|
+
: "xdg-open";
|
|
74
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
75
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
76
|
+
child.on("error", () => console.log(`Open ${url} in your browser.`));
|
|
77
|
+
child.unref();
|
|
78
|
+
}
|
|
79
|
+
return server;
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
/** An optional dedicated CDP browser lets restricted CI runners reuse a browser host. */
|
|
3
|
+
export async function openBrowser() {
|
|
4
|
+
const endpoint = process.env.TETHER_BROWSER_ENDPOINT;
|
|
5
|
+
if (endpoint) {
|
|
6
|
+
try {
|
|
7
|
+
return await chromium.connectOverCDP(endpoint);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error(`Couldn't connect to the dedicated browser at ${endpoint}. Check TETHER_BROWSER_ENDPOINT and keep the browser running.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return await chromium.launch();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : "";
|
|
18
|
+
if (message.includes("Executable doesn't exist"))
|
|
19
|
+
throw new Error("Chromium is unavailable. Run npx playwright install chromium and try again.");
|
|
20
|
+
throw new Error("Chromium could not start. Check browser permissions and sandbox restrictions. A dedicated browser can be supplied with TETHER_BROWSER_ENDPOINT.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ElementRecord, LayoutProblem } from "../types.js";
|
|
2
|
+
/** Pure DOM reader, serialized by Playwright into the page. */
|
|
3
|
+
export declare function collect({ maxElements, ignore, }: {
|
|
4
|
+
maxElements: number;
|
|
5
|
+
ignore: string[];
|
|
6
|
+
}): {
|
|
7
|
+
elements: ElementRecord[];
|
|
8
|
+
layout: LayoutProblem[];
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Pure DOM reader, serialized by Playwright into the page. */
|
|
2
|
+
export function collect({ maxElements, ignore, }) {
|
|
3
|
+
for (const selector of ignore) {
|
|
4
|
+
try {
|
|
5
|
+
document.querySelector(selector);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error(`Invalid ignore selector: ${selector}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const excluded = ignore.length ? ignore.join(",") : null;
|
|
12
|
+
function visible(el) {
|
|
13
|
+
if (excluded && el.closest(excluded))
|
|
14
|
+
return false;
|
|
15
|
+
if (el.closest("svg, script, style, noscript, template"))
|
|
16
|
+
return false;
|
|
17
|
+
if (el.namespaceURI !== "http://www.w3.org/1999/xhtml")
|
|
18
|
+
return false;
|
|
19
|
+
for (let node = el; node; node = node.parentElement) {
|
|
20
|
+
const style = getComputedStyle(node);
|
|
21
|
+
if (style.display === "none" ||
|
|
22
|
+
style.visibility === "hidden" ||
|
|
23
|
+
style.visibility === "collapse" ||
|
|
24
|
+
style.contentVisibility === "hidden" ||
|
|
25
|
+
Number(style.opacity) === 0)
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const rect = el.getBoundingClientRect();
|
|
29
|
+
return rect.width > 0 && rect.height > 0;
|
|
30
|
+
}
|
|
31
|
+
function escapeCss(value) {
|
|
32
|
+
return [...value]
|
|
33
|
+
.map((char, index) => /[a-zA-Z_-]/.test(char) || (index > 0 && /[0-9]/.test(char))
|
|
34
|
+
? char
|
|
35
|
+
: `\\${char.codePointAt(0).toString(16)} `)
|
|
36
|
+
.join("");
|
|
37
|
+
}
|
|
38
|
+
function selectorFor(el) {
|
|
39
|
+
if (el.id && document.querySelectorAll(`#${escapeCss(el.id)}`).length === 1)
|
|
40
|
+
return `#${escapeCss(el.id)}`;
|
|
41
|
+
const testid = el.getAttribute("data-testid");
|
|
42
|
+
if (testid) {
|
|
43
|
+
const selector = `[data-testid="${escapeCss(testid)}"]`;
|
|
44
|
+
if (document.querySelectorAll(selector).length === 1)
|
|
45
|
+
return selector;
|
|
46
|
+
}
|
|
47
|
+
const classes = [...el.classList].filter((c) => /^[a-zA-Z][\w-]*$/.test(c));
|
|
48
|
+
for (const name of classes)
|
|
49
|
+
if (document.querySelectorAll(`.${escapeCss(name)}`).length === 1)
|
|
50
|
+
return `.${escapeCss(name)}`;
|
|
51
|
+
const parts = [];
|
|
52
|
+
let node = el;
|
|
53
|
+
while (node && node !== document.body) {
|
|
54
|
+
const parent = node.parentElement;
|
|
55
|
+
const siblings = parent
|
|
56
|
+
? [...parent.children].filter((c) => c.tagName === node.tagName)
|
|
57
|
+
: [];
|
|
58
|
+
parts.unshift(`${node.tagName.toLowerCase()}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(node) + 1})` : ""}`);
|
|
59
|
+
node = parent;
|
|
60
|
+
}
|
|
61
|
+
return parts.length ? `body > ${parts.join(" > ")}` : "body";
|
|
62
|
+
}
|
|
63
|
+
function scrollContainer(el) {
|
|
64
|
+
let p = el.parentElement;
|
|
65
|
+
while (p && p !== document.body && p !== document.documentElement) {
|
|
66
|
+
if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(p).overflowX))
|
|
67
|
+
return true;
|
|
68
|
+
p = p.parentElement;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const elements = [];
|
|
73
|
+
const layout = [];
|
|
74
|
+
const candidates = document.body.querySelectorAll("*");
|
|
75
|
+
let visited = 0;
|
|
76
|
+
const offscreenParents = [];
|
|
77
|
+
const documentWidth = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth);
|
|
78
|
+
if (documentWidth > innerWidth + 2)
|
|
79
|
+
layout.push({
|
|
80
|
+
selector: "html",
|
|
81
|
+
kind: "overflow",
|
|
82
|
+
value: `${documentWidth - innerWidth}px horizontal overflow`,
|
|
83
|
+
severity: "high",
|
|
84
|
+
});
|
|
85
|
+
for (const el of candidates) {
|
|
86
|
+
if (++visited > maxElements)
|
|
87
|
+
break;
|
|
88
|
+
if (!visible(el))
|
|
89
|
+
continue;
|
|
90
|
+
const style = getComputedStyle(el);
|
|
91
|
+
const rect = el.getBoundingClientRect();
|
|
92
|
+
const selector = selectorFor(el);
|
|
93
|
+
const observations = [];
|
|
94
|
+
function add(category, property, value = style.getPropertyValue(property)) {
|
|
95
|
+
if (value &&
|
|
96
|
+
![
|
|
97
|
+
"normal",
|
|
98
|
+
"none",
|
|
99
|
+
"auto",
|
|
100
|
+
"0px",
|
|
101
|
+
"rgba(0, 0, 0, 0)",
|
|
102
|
+
"transparent",
|
|
103
|
+
].includes(value))
|
|
104
|
+
observations.push({ category, property, value });
|
|
105
|
+
}
|
|
106
|
+
const hasText = [...el.childNodes].some((n) => n.nodeType === 3 && !!n.textContent?.trim());
|
|
107
|
+
const control = el.matches("button, input:not([type=hidden]), select, textarea, [role=button], [role=switch]");
|
|
108
|
+
if (hasText || control) {
|
|
109
|
+
add("colors", "color");
|
|
110
|
+
add("fontFamilies", "font-family");
|
|
111
|
+
add("fontSizes", "font-size");
|
|
112
|
+
add("fontWeights", "font-weight");
|
|
113
|
+
add("lineHeights", "line-height");
|
|
114
|
+
}
|
|
115
|
+
add("colors", "background-color");
|
|
116
|
+
for (const side of ["top", "right", "bottom", "left"]) {
|
|
117
|
+
if (Number.parseFloat(style.getPropertyValue(`border-${side}-width`)) > 0 &&
|
|
118
|
+
style.getPropertyValue(`border-${side}-style`) !== "none") {
|
|
119
|
+
add("borders", "border", `${style.getPropertyValue(`border-${side}-width`)} ${style.getPropertyValue(`border-${side}-style`)} ${style.getPropertyValue(`border-${side}-color`)}`);
|
|
120
|
+
add("colors", "border-color", style.getPropertyValue(`border-${side}-color`));
|
|
121
|
+
}
|
|
122
|
+
add("spacing", `padding-${side}`);
|
|
123
|
+
// Browsers resolve auto margins to large layout-dependent pixel values.
|
|
124
|
+
// Keep the token scale focused on local spacing rather than centering offsets.
|
|
125
|
+
const margin = style.getPropertyValue(`margin-${side}`);
|
|
126
|
+
if (Math.abs(Number.parseFloat(margin)) <= 96)
|
|
127
|
+
add("spacing", `margin-${side}`, margin);
|
|
128
|
+
}
|
|
129
|
+
for (const corner of [
|
|
130
|
+
"top-left",
|
|
131
|
+
"top-right",
|
|
132
|
+
"bottom-right",
|
|
133
|
+
"bottom-left",
|
|
134
|
+
])
|
|
135
|
+
add("radii", "border-radius", style.getPropertyValue(`border-${corner}-radius`));
|
|
136
|
+
if (["flex", "inline-flex", "grid", "inline-grid"].includes(style.display)) {
|
|
137
|
+
add("spacing", "row-gap");
|
|
138
|
+
add("spacing", "column-gap");
|
|
139
|
+
}
|
|
140
|
+
add("shadows", "box-shadow");
|
|
141
|
+
if (control)
|
|
142
|
+
add("elementHeights", "height", `${rect.height}px`);
|
|
143
|
+
elements.push({ selector, tag: el.tagName.toLowerCase(), observations });
|
|
144
|
+
const outside = Math.max(0, rect.right - innerWidth, -rect.left);
|
|
145
|
+
if (outside > 4 &&
|
|
146
|
+
!scrollContainer(el) &&
|
|
147
|
+
!offscreenParents.some((p) => p.contains(el))) {
|
|
148
|
+
layout.push({
|
|
149
|
+
selector,
|
|
150
|
+
kind: "offscreen",
|
|
151
|
+
value: `${Math.round(outside)}px outside ${innerWidth}px viewport`,
|
|
152
|
+
severity: outside > 24 ? "high" : "medium",
|
|
153
|
+
});
|
|
154
|
+
offscreenParents.push(el);
|
|
155
|
+
}
|
|
156
|
+
// Intentional ellipsis, line clamping and scrollable regions are not clipping errors.
|
|
157
|
+
if (hasText &&
|
|
158
|
+
style.textOverflow !== "ellipsis" &&
|
|
159
|
+
style.webkitLineClamp === "none" &&
|
|
160
|
+
["hidden", "clip"].includes(style.overflowY) &&
|
|
161
|
+
el.scrollHeight > el.clientHeight + 4) {
|
|
162
|
+
layout.push({
|
|
163
|
+
selector,
|
|
164
|
+
kind: "clipped",
|
|
165
|
+
value: `${el.scrollHeight - el.clientHeight}px clipped content`,
|
|
166
|
+
severity: "medium",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { elements, layout, truncated: candidates.length > maxElements };
|
|
171
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { openBrowser } from "./browser.js";
|
|
2
|
+
import { collect } from "./collect.js";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { normalizeValue } from "../utils/normalize.js";
|
|
7
|
+
import { targetUrl } from "../utils/config.js";
|
|
8
|
+
export async function scan(config, directory, phase) {
|
|
9
|
+
await mkdir(join(directory, "screenshots", phase), { recursive: true });
|
|
10
|
+
const browser = await openBrowser();
|
|
11
|
+
const result = {
|
|
12
|
+
version: 1,
|
|
13
|
+
createdAt: new Date().toISOString(),
|
|
14
|
+
url: config.url,
|
|
15
|
+
snapshots: [],
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
for (const route of config.routes)
|
|
19
|
+
for (const viewport of config.viewports) {
|
|
20
|
+
const context = await browser.newContext({
|
|
21
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
22
|
+
deviceScaleFactor: 1,
|
|
23
|
+
colorScheme: "light",
|
|
24
|
+
reducedMotion: "reduce",
|
|
25
|
+
});
|
|
26
|
+
const page = await context.newPage();
|
|
27
|
+
const url = targetUrl(config.url, route);
|
|
28
|
+
try {
|
|
29
|
+
const response = await page.goto(url, {
|
|
30
|
+
waitUntil: "load",
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
});
|
|
33
|
+
if (response && response.status() >= 400)
|
|
34
|
+
throw new Error(`HTTP ${response.status()}`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`tether couldn't reach ${url}.\nStart your application, check the route, and try again.`);
|
|
38
|
+
}
|
|
39
|
+
await page.addStyleTag({
|
|
40
|
+
content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }",
|
|
41
|
+
});
|
|
42
|
+
await page.evaluate(() => Promise.race([
|
|
43
|
+
document.fonts.ready,
|
|
44
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
45
|
+
]));
|
|
46
|
+
await page.waitForTimeout(config.settleMs);
|
|
47
|
+
const collected = await page.evaluate(collect, {
|
|
48
|
+
maxElements: config.maxElements,
|
|
49
|
+
ignore: config.ignore,
|
|
50
|
+
});
|
|
51
|
+
if (!collected.elements.length)
|
|
52
|
+
throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
|
|
53
|
+
for (const element of collected.elements) {
|
|
54
|
+
element.observations = [
|
|
55
|
+
...new Map(element.observations.map((o) => {
|
|
56
|
+
const normalized = {
|
|
57
|
+
...o,
|
|
58
|
+
value: normalizeValue(o.property, o.value),
|
|
59
|
+
};
|
|
60
|
+
return [
|
|
61
|
+
`${o.category}\0${o.property}\0${normalized.value}`,
|
|
62
|
+
normalized,
|
|
63
|
+
];
|
|
64
|
+
})).values(),
|
|
65
|
+
].filter((o) => o.value !== "transparent" && o.value !== "0px");
|
|
66
|
+
}
|
|
67
|
+
const filename = `${createHash("sha256").update(route).digest("hex").slice(0, 12)}-${viewport.name}.png`;
|
|
68
|
+
const screenshot = `screenshots/${phase}/${filename}`;
|
|
69
|
+
await page.screenshot({
|
|
70
|
+
path: join(directory, screenshot),
|
|
71
|
+
fullPage: false,
|
|
72
|
+
animations: "disabled",
|
|
73
|
+
});
|
|
74
|
+
result.snapshots.push({ route, viewport, ...collected, screenshot });
|
|
75
|
+
await context.close();
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await browser.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export declare const categories: readonly ["colors", "fontFamilies", "fontSizes", "fontWeights", "lineHeights", "spacing", "radii", "shadows", "borders", "elementHeights"];
|
|
2
|
+
export type Category = (typeof categories)[number];
|
|
3
|
+
export type Severity = "low" | "medium" | "high";
|
|
4
|
+
export interface Viewport {
|
|
5
|
+
name: string;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Config {
|
|
10
|
+
url: string;
|
|
11
|
+
routes: string[];
|
|
12
|
+
viewports: Viewport[];
|
|
13
|
+
minOccurrences: number;
|
|
14
|
+
maxElements: number;
|
|
15
|
+
settleMs: number;
|
|
16
|
+
ignore: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface Observation {
|
|
19
|
+
category: Category;
|
|
20
|
+
property: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ElementRecord {
|
|
24
|
+
selector: string;
|
|
25
|
+
tag: string;
|
|
26
|
+
observations: Observation[];
|
|
27
|
+
}
|
|
28
|
+
export interface LayoutProblem {
|
|
29
|
+
selector: string;
|
|
30
|
+
kind: "overflow" | "offscreen" | "clipped";
|
|
31
|
+
value: string;
|
|
32
|
+
severity: Severity;
|
|
33
|
+
}
|
|
34
|
+
export interface Snapshot {
|
|
35
|
+
route: string;
|
|
36
|
+
viewport: Viewport;
|
|
37
|
+
elements: ElementRecord[];
|
|
38
|
+
layout: LayoutProblem[];
|
|
39
|
+
screenshot: string;
|
|
40
|
+
truncated: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface Scan {
|
|
43
|
+
version: 1;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
url: string;
|
|
46
|
+
snapshots: Snapshot[];
|
|
47
|
+
}
|
|
48
|
+
export interface Token {
|
|
49
|
+
value: string;
|
|
50
|
+
count: number;
|
|
51
|
+
}
|
|
52
|
+
export type Contract = {
|
|
53
|
+
version: 1;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
minOccurrences: number;
|
|
56
|
+
} & Record<Category, Token[]>;
|
|
57
|
+
export interface Issue {
|
|
58
|
+
id: string;
|
|
59
|
+
category: Category | "layout";
|
|
60
|
+
severity: Severity;
|
|
61
|
+
title: string;
|
|
62
|
+
selector: string;
|
|
63
|
+
property: string;
|
|
64
|
+
current: string;
|
|
65
|
+
expected: string[];
|
|
66
|
+
route: string;
|
|
67
|
+
viewports: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface Report {
|
|
70
|
+
version: 1;
|
|
71
|
+
baselineAt: string;
|
|
72
|
+
scan: Scan;
|
|
73
|
+
contract: Contract;
|
|
74
|
+
issues: Issue[];
|
|
75
|
+
score: number;
|
|
76
|
+
elementCount: number;
|
|
77
|
+
agentMarkdown: string;
|
|
78
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Config } from "../types.js";
|
|
2
|
+
export declare const defaults: Config;
|
|
3
|
+
export declare function exists(path: string): Promise<boolean>;
|
|
4
|
+
export declare function loadConfig(cwd: string, url?: string, file?: string): Promise<Config>;
|
|
5
|
+
export declare function targetUrl(base: string, route: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
export const defaults = {
|
|
5
|
+
url: "http://localhost:3000",
|
|
6
|
+
routes: ["/"],
|
|
7
|
+
viewports: [
|
|
8
|
+
{ name: "desktop", width: 1440, height: 1000 },
|
|
9
|
+
{ name: "laptop", width: 1024, height: 900 },
|
|
10
|
+
{ name: "tablet", width: 768, height: 900 },
|
|
11
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
12
|
+
],
|
|
13
|
+
minOccurrences: 2,
|
|
14
|
+
maxElements: 5000,
|
|
15
|
+
settleMs: 250,
|
|
16
|
+
ignore: ["[data-tether-ignore]"],
|
|
17
|
+
};
|
|
18
|
+
export async function exists(path) {
|
|
19
|
+
return access(path).then(() => true, () => false);
|
|
20
|
+
}
|
|
21
|
+
export async function loadConfig(cwd, url, file) {
|
|
22
|
+
const path = resolve(cwd, file ?? "tether.config.ts");
|
|
23
|
+
let input = {};
|
|
24
|
+
if (file && !(await exists(path)))
|
|
25
|
+
throw new Error(`Configuration not found: ${path}`);
|
|
26
|
+
if (await exists(path)) {
|
|
27
|
+
try {
|
|
28
|
+
input = await createJiti(import.meta.url).import(path, { default: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error("Could not load tether.config.ts. Check its syntax and default export.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
35
|
+
throw new Error("Configuration must export an object.");
|
|
36
|
+
const config = { ...defaults, ...input, ...(url ? { url } : {}) };
|
|
37
|
+
try {
|
|
38
|
+
if (!["http:", "https:"].includes(new URL(config.url).protocol))
|
|
39
|
+
throw 0;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error("Config url must be an http:// or https:// URL.");
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(config.routes) ||
|
|
45
|
+
!config.routes.length ||
|
|
46
|
+
config.routes.some((r) => typeof r !== "string" || !r.startsWith("/") || r.startsWith("//")))
|
|
47
|
+
throw new Error('Config routes must contain local paths beginning with /, such as ["/", "/settings"].');
|
|
48
|
+
if (!Array.isArray(config.viewports) ||
|
|
49
|
+
!config.viewports.length ||
|
|
50
|
+
config.viewports.some((v) => !v ||
|
|
51
|
+
!/^[a-zA-Z0-9_-]+$/.test(v.name) ||
|
|
52
|
+
!Number.isInteger(v.width) ||
|
|
53
|
+
!Number.isInteger(v.height) ||
|
|
54
|
+
v.width < 200 ||
|
|
55
|
+
v.width > 7680 ||
|
|
56
|
+
v.height < 200 ||
|
|
57
|
+
v.height > 7680))
|
|
58
|
+
throw new Error("Viewports need unique names and integer dimensions between 200 and 7680.");
|
|
59
|
+
if (new Set(config.viewports.map((v) => v.name)).size !==
|
|
60
|
+
config.viewports.length)
|
|
61
|
+
throw new Error("Viewport names must be unique.");
|
|
62
|
+
for (const [key, min, max] of [
|
|
63
|
+
["minOccurrences", 1, 1000],
|
|
64
|
+
["maxElements", 1, 50000],
|
|
65
|
+
["settleMs", 0, 30000],
|
|
66
|
+
]) {
|
|
67
|
+
if (!Number.isInteger(config[key]) ||
|
|
68
|
+
config[key] < min ||
|
|
69
|
+
config[key] > max)
|
|
70
|
+
throw new Error(`Config ${key} must be an integer between ${min} and ${max}.`);
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(config.ignore) ||
|
|
73
|
+
config.ignore.some((s) => typeof s !== "string" || !s.trim()))
|
|
74
|
+
throw new Error("Config ignore must be a list of CSS selectors.");
|
|
75
|
+
config.routes = [...new Set(config.routes)];
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
export function targetUrl(base, route) {
|
|
79
|
+
const result = new URL(route, base);
|
|
80
|
+
const original = new URL(base);
|
|
81
|
+
for (const [key, value] of original.searchParams)
|
|
82
|
+
if (!result.searchParams.has(key))
|
|
83
|
+
result.searchParams.append(key, value);
|
|
84
|
+
return result.toString();
|
|
85
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function normalizeNumber(value: number): number;
|
|
2
|
+
export declare function normalizeColor(input: string): string;
|
|
3
|
+
export declare function normalizeValue(property: string, input: string): string;
|
|
4
|
+
export declare function colorDistance(a: string, b: string): number;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function normalizeNumber(value) {
|
|
2
|
+
const integer = Math.round(value);
|
|
3
|
+
return Math.abs(value - integer) <= 0.05
|
|
4
|
+
? integer
|
|
5
|
+
: Math.round(value * 10) / 10;
|
|
6
|
+
}
|
|
7
|
+
export function normalizeColor(input) {
|
|
8
|
+
const value = input.trim().toLowerCase();
|
|
9
|
+
if (value === "transparent")
|
|
10
|
+
return "transparent";
|
|
11
|
+
let channels;
|
|
12
|
+
if (/^#[\da-f]{3,8}$/.test(value)) {
|
|
13
|
+
let hex = value.slice(1);
|
|
14
|
+
if (hex.length === 3 || hex.length === 4)
|
|
15
|
+
hex = [...hex].map((c) => c + c).join("");
|
|
16
|
+
if (hex.length === 6 || hex.length === 8)
|
|
17
|
+
channels = [0, 2, 4]
|
|
18
|
+
.map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
19
|
+
.concat(hex.length === 8 ? parseInt(hex.slice(6), 16) / 255 : 1);
|
|
20
|
+
}
|
|
21
|
+
else if (/^rgba?\(/.test(value)) {
|
|
22
|
+
const parts = value
|
|
23
|
+
.slice(value.indexOf("(") + 1, -1)
|
|
24
|
+
.split(/[\s,/]+/)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
channels = parts
|
|
27
|
+
.slice(0, 3)
|
|
28
|
+
.map((p) => parseFloat(p) * (p.endsWith("%") ? 2.55 : 1));
|
|
29
|
+
channels.push(parts[3] ? parseFloat(parts[3]) / (parts[3].endsWith("%") ? 100 : 1) : 1);
|
|
30
|
+
}
|
|
31
|
+
if (!channels ||
|
|
32
|
+
channels.length !== 4 ||
|
|
33
|
+
channels.some((n) => !Number.isFinite(n)))
|
|
34
|
+
return value;
|
|
35
|
+
const [r, g, b, a] = channels;
|
|
36
|
+
if (a <= 0.001)
|
|
37
|
+
return "transparent";
|
|
38
|
+
const hex = "#" +
|
|
39
|
+
[r, g, b]
|
|
40
|
+
.map((n) => Math.min(255, Math.max(0, Math.round(n)))
|
|
41
|
+
.toString(16)
|
|
42
|
+
.padStart(2, "0"))
|
|
43
|
+
.join("")
|
|
44
|
+
.toUpperCase();
|
|
45
|
+
return a >= 0.999
|
|
46
|
+
? hex
|
|
47
|
+
: `${hex}${Math.round(Math.max(0, Math.min(1, a)) * 255)
|
|
48
|
+
.toString(16)
|
|
49
|
+
.padStart(2, "0")
|
|
50
|
+
.toUpperCase()}`;
|
|
51
|
+
}
|
|
52
|
+
export function normalizeValue(property, input) {
|
|
53
|
+
let value = input.trim().replace(/\s+/g, " ");
|
|
54
|
+
if (property === "font-weight")
|
|
55
|
+
return ({ normal: "400", bold: "700" }[value] ?? value);
|
|
56
|
+
value = value.replace(/rgba?\([^)]*\)|#[\da-fA-F]{3,8}\b/g, normalizeColor);
|
|
57
|
+
value = value.replace(/(-?\d*\.?\d+)px/g, (_, n) => `${normalizeNumber(Number(n))}px`);
|
|
58
|
+
if (value.toLowerCase() === "transparent")
|
|
59
|
+
return "transparent";
|
|
60
|
+
return value.replace(/,\s*/g, ", ").replace(/\s*\/\s*/g, " / ");
|
|
61
|
+
}
|
|
62
|
+
export function colorDistance(a, b) {
|
|
63
|
+
if (!/^#[\dA-F]{6}$/.test(a) || !/^#[\dA-F]{6}$/.test(b))
|
|
64
|
+
return a === b ? 0 : Infinity;
|
|
65
|
+
return Math.max(...[1, 3, 5].map((i) => Math.abs(parseInt(a.slice(i, i + 2), 16) - parseInt(b.slice(i, i + 2), 16))));
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export async function readJson(path) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error(`Could not read ${path}. Run tether init to create a valid baseline.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function writeJson(path, data) {
|
|
13
|
+
await mkdir(dirname(path), { recursive: true });
|
|
14
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
15
|
+
await writeFile(temporary, JSON.stringify(data, null, 2) + "\n");
|
|
16
|
+
await rename(temporary, path);
|
|
17
|
+
}
|