phyll 0.4.1
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 +44 -0
- package/bin/phyll.mjs +6 -0
- package/package.json +42 -0
- package/skill/data/tells.json +1641 -0
- package/skill/schema/config.schema.json +51 -0
- package/skill/schema/tells.schema.json +156 -0
- package/skill/scripts/capture.mjs +224 -0
- package/skill/scripts/lib/cli.mjs +16 -0
- package/skill/scripts/lib/config.mjs +19 -0
- package/skill/scripts/lib/detectors.mjs +301 -0
- package/skill/scripts/lib/files.mjs +134 -0
- package/skill/scripts/lib/score.mjs +51 -0
- package/skill/scripts/lib/structure.mjs +287 -0
- package/skill/scripts/lib/version.mjs +3 -0
- package/skill/scripts/probe.js +384 -0
- package/skill/scripts/scan.mjs +175 -0
- package/src/browser.mjs +317 -0
- package/src/cli.mjs +165 -0
- package/src/credentials.mjs +40 -0
- package/src/engine.mjs +40 -0
- package/src/mcp.mjs +145 -0
- package/src/paths.mjs +19 -0
- package/src/review.mjs +226 -0
- package/src/setup.mjs +69 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/carlosphyll/phyll/blob/main/skills/phyll/schema/config.schema.json",
|
|
4
|
+
"title": "Phyll project configuration",
|
|
5
|
+
"description": "Optional settings in .phyll/config.json at the root of the reviewed project. Every field is optional.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"language": {
|
|
11
|
+
"description": "Language for the report. Defaults to the language of the product's interface.",
|
|
12
|
+
"type": "string"
|
|
13
|
+
},
|
|
14
|
+
"entryUrl": {
|
|
15
|
+
"description": "Where the running app can be opened, such as http://localhost:3000.",
|
|
16
|
+
"type": "string"
|
|
17
|
+
},
|
|
18
|
+
"startCommand": {
|
|
19
|
+
"description": "Command that starts the app, such as npm run dev.",
|
|
20
|
+
"type": "string"
|
|
21
|
+
},
|
|
22
|
+
"endUser": {
|
|
23
|
+
"description": "Who uses the product, in one sentence.",
|
|
24
|
+
"type": "string"
|
|
25
|
+
},
|
|
26
|
+
"coreJobs": {
|
|
27
|
+
"description": "The two or three things the end user comes to do.",
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": { "type": "string" },
|
|
30
|
+
"maxItems": 5
|
|
31
|
+
},
|
|
32
|
+
"ignore": {
|
|
33
|
+
"description": "Globs, relative to the project root, that the static scan skips.",
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": { "type": "string" }
|
|
36
|
+
},
|
|
37
|
+
"telemetry": {
|
|
38
|
+
"description": "Opt in to sending anonymous numbers (tool version, mode, index, counts) to cloud.apiUrl. Off by default, and nothing is sent without a URL.",
|
|
39
|
+
"type": "boolean"
|
|
40
|
+
},
|
|
41
|
+
"cloud": {
|
|
42
|
+
"description": "Phyll Cloud settings. The service does not exist yet; with both fields empty nothing leaves the machine. PHYLL_API_URL and PHYLL_API_KEY override these values.",
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"apiUrl": { "type": "string" },
|
|
47
|
+
"apiKey": { "type": "string" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/carlosphyll/phyll/blob/main/skills/phyll/schema/tells.schema.json",
|
|
4
|
+
"title": "Phyll tell catalog",
|
|
5
|
+
"description": "Tells are recurring signs that an interface or flow was produced by default choices of a code generator. Each tell has metadata for scoring and optional static detectors.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["version", "tells"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": { "type": "string" },
|
|
11
|
+
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
|
12
|
+
"tells": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"minItems": 1,
|
|
15
|
+
"items": { "$ref": "#/$defs/tell" }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"$defs": {
|
|
19
|
+
"category": {
|
|
20
|
+
"description": "File categories a detector reads. markup: html, jsx, tsx, vue, svelte, astro, mdx. script: js, ts, mjs, cjs. style: css, scss, sass, less.",
|
|
21
|
+
"enum": ["markup", "script", "style"]
|
|
22
|
+
},
|
|
23
|
+
"categories": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"minItems": 1,
|
|
26
|
+
"uniqueItems": true,
|
|
27
|
+
"items": { "$ref": "#/$defs/category" }
|
|
28
|
+
},
|
|
29
|
+
"tell": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["id", "name", "dimension", "severity", "weight", "cap", "detection", "summary"],
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": { "type": "string", "pattern": "^[PFALCS]\\d{2}$" },
|
|
35
|
+
"name": { "type": "string", "minLength": 3, "maxLength": 80 },
|
|
36
|
+
"dimension": { "enum": ["purpose", "flow", "actions", "look", "copy", "states"] },
|
|
37
|
+
"kind": {
|
|
38
|
+
"description": "function: the tell gets in the way of using the product, counts in the AI tell index and fix mode fixes it. style: the tell is about the generated look; it is reported as a style note, counts only in the style index, and fix mode leaves it alone unless the owner asks. Defaults to function.",
|
|
39
|
+
"enum": ["function", "style"]
|
|
40
|
+
},
|
|
41
|
+
"severity": {
|
|
42
|
+
"description": "Typical severity when the tell shows up on a core job. A finding can raise or lower it.",
|
|
43
|
+
"enum": ["blocker", "major", "minor", "polish"]
|
|
44
|
+
},
|
|
45
|
+
"weight": {
|
|
46
|
+
"description": "How much the tell counts in the AI tell index: 1 cosmetic, 2 noticeable, 3 gets in the way of the job.",
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"minimum": 1,
|
|
49
|
+
"maximum": 3
|
|
50
|
+
},
|
|
51
|
+
"cap": {
|
|
52
|
+
"description": "Number of hits at which the tell counts in full.",
|
|
53
|
+
"type": "integer",
|
|
54
|
+
"minimum": 1,
|
|
55
|
+
"maximum": 20
|
|
56
|
+
},
|
|
57
|
+
"detection": {
|
|
58
|
+
"description": "static: source patterns are enough. dynamic: needs the running app. both: source gives a hint the reviewer confirms in the app.",
|
|
59
|
+
"enum": ["static", "dynamic", "both"]
|
|
60
|
+
},
|
|
61
|
+
"summary": { "type": "string", "minLength": 10, "maxLength": 240 },
|
|
62
|
+
"detectors": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": { "$ref": "#/$defs/detector" }
|
|
65
|
+
},
|
|
66
|
+
"examples": {
|
|
67
|
+
"description": "Snippets the detectors must match (hit) and must not match (miss). The test suite runs them.",
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"required": ["hit", "miss"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"hit": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/example" } },
|
|
73
|
+
"miss": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/example" } }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"example": {
|
|
79
|
+
"description": "Source code, read as a .jsx file unless a file name with another extension is given.",
|
|
80
|
+
"oneOf": [
|
|
81
|
+
{ "type": "string", "minLength": 1 },
|
|
82
|
+
{
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["code", "file"],
|
|
85
|
+
"additionalProperties": false,
|
|
86
|
+
"properties": {
|
|
87
|
+
"code": { "type": "string", "minLength": 1 },
|
|
88
|
+
"file": { "type": "string", "pattern": "\\.[a-z]+$" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"detector": {
|
|
94
|
+
"oneOf": [
|
|
95
|
+
{ "$ref": "#/$defs/regexDetector" },
|
|
96
|
+
{ "$ref": "#/$defs/classComboDetector" },
|
|
97
|
+
{ "$ref": "#/$defs/phraseDetector" },
|
|
98
|
+
{ "$ref": "#/$defs/metricDetector" }
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
"skipIf": {
|
|
102
|
+
"description": "Project traits that switch the detector off. darkTheme: most backgrounds in the source are dark, so light text classes are expected.",
|
|
103
|
+
"type": "array",
|
|
104
|
+
"minItems": 1,
|
|
105
|
+
"uniqueItems": true,
|
|
106
|
+
"items": { "enum": ["darkTheme"] }
|
|
107
|
+
},
|
|
108
|
+
"regexDetector": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"required": ["kind", "pattern"],
|
|
111
|
+
"additionalProperties": false,
|
|
112
|
+
"properties": {
|
|
113
|
+
"kind": { "const": "regex" },
|
|
114
|
+
"pattern": { "type": "string", "minLength": 1 },
|
|
115
|
+
"flags": { "type": "string", "pattern": "^[imsu]*$" },
|
|
116
|
+
"in": { "$ref": "#/$defs/categories" },
|
|
117
|
+
"skipIf": { "$ref": "#/$defs/skipIf" },
|
|
118
|
+
"note": { "type": "string" }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"classComboDetector": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"required": ["kind", "all"],
|
|
124
|
+
"additionalProperties": false,
|
|
125
|
+
"properties": {
|
|
126
|
+
"kind": { "const": "classCombo" },
|
|
127
|
+
"all": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
128
|
+
"none": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
129
|
+
"in": { "$ref": "#/$defs/categories" },
|
|
130
|
+
"note": { "type": "string" }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"phraseDetector": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"required": ["kind", "phrases"],
|
|
136
|
+
"additionalProperties": false,
|
|
137
|
+
"properties": {
|
|
138
|
+
"kind": { "const": "phrase" },
|
|
139
|
+
"phrases": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 2 } },
|
|
140
|
+
"in": { "$ref": "#/$defs/categories" },
|
|
141
|
+
"note": { "type": "string" }
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"metricDetector": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"required": ["kind", "metric"],
|
|
147
|
+
"additionalProperties": false,
|
|
148
|
+
"properties": {
|
|
149
|
+
"kind": { "const": "metric" },
|
|
150
|
+
"metric": { "enum": ["longForms", "modals"] },
|
|
151
|
+
"min": { "type": "integer", "minimum": 1 },
|
|
152
|
+
"note": { "type": "string" }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phyll capture: screenshots and probe measurements for each route, at desktop and phone sizes.
|
|
3
|
+
// Optional. Use it when the host agent has no browser tool, or to capture many screens at once.
|
|
4
|
+
// Needs Playwright in the reviewed project (npm i -D playwright && npx playwright install chromium).
|
|
5
|
+
//
|
|
6
|
+
// node capture.mjs --url http://localhost:5173 [--routes "/,/flows"] [--scan scan.json]
|
|
7
|
+
// [--out dir] [--viewports desktop,mobile] [--no-probe] [--wait 800]
|
|
8
|
+
// [--max-height 5000] [--no-full-page]
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import { fail, isMain } from "./lib/cli.mjs";
|
|
15
|
+
import { NAME, VERSION } from "./lib/version.mjs";
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export const VIEWPORTS = Object.freeze({
|
|
20
|
+
desktop: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 },
|
|
21
|
+
mobile: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// File name for a route. The query string stays in, so /new and /new?template=link do not collide.
|
|
25
|
+
export function routeSlug(route) {
|
|
26
|
+
const [pathAndQuery] = String(route ?? "").split("#");
|
|
27
|
+
const [path, query = ""] = pathAndQuery.split("?");
|
|
28
|
+
const base = path.replace(/^\/+|\/+$/g, "").replace(/\.html?$/i, "");
|
|
29
|
+
const slug = [base, query].filter(Boolean).join("-").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
30
|
+
return slug || "home";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Gives each route a unique slug, numbering repeats: flows, flows-2.
|
|
34
|
+
export function uniqueSlugs(routes) {
|
|
35
|
+
const seen = new Map();
|
|
36
|
+
return routes.map((route) => {
|
|
37
|
+
const slug = routeSlug(route);
|
|
38
|
+
const count = (seen.get(slug) ?? 0) + 1;
|
|
39
|
+
seen.set(slug, count);
|
|
40
|
+
return count === 1 ? slug : `${slug}-${count}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Routes the browser can open as they are: no :params, [params], wildcards or (groups).
|
|
45
|
+
export function staticRoutes(routes) {
|
|
46
|
+
return [...new Set(routes.filter((r) => typeof r === "string" && r.startsWith("/") && !/[:*[\]()]/.test(r)))];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseCaptureArgs(argv) {
|
|
50
|
+
const { values } = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
allowPositionals: false,
|
|
53
|
+
options: {
|
|
54
|
+
url: { type: "string" },
|
|
55
|
+
routes: { type: "string" },
|
|
56
|
+
scan: { type: "string" },
|
|
57
|
+
out: { type: "string" },
|
|
58
|
+
viewports: { type: "string", default: "desktop,mobile" },
|
|
59
|
+
"no-probe": { type: "boolean", default: false },
|
|
60
|
+
"no-full-page": { type: "boolean", default: false },
|
|
61
|
+
wait: { type: "string", default: "800" },
|
|
62
|
+
"max-height": { type: "string", default: "5000" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
if (!values.url) throw new Error("--url is required, for example --url http://localhost:5173");
|
|
66
|
+
const viewports = values.viewports.split(",").map((v) => v.trim()).filter(Boolean);
|
|
67
|
+
for (const v of viewports) {
|
|
68
|
+
if (!VIEWPORTS[v]) throw new Error(`unknown viewport ${v}; use ${Object.keys(VIEWPORTS).join(" or ")}`);
|
|
69
|
+
}
|
|
70
|
+
let routes = null;
|
|
71
|
+
if (values.routes) {
|
|
72
|
+
routes = [];
|
|
73
|
+
for (const raw of values.routes.split(",").map((r) => r.trim()).filter(Boolean)) {
|
|
74
|
+
// Git Bash on Windows rewrites arguments that start with "/" into Windows paths.
|
|
75
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`phyll: skipped route "${raw}", which looks like a path rewritten by Git Bash. ` +
|
|
78
|
+
"Run with MSYS_NO_PATHCONV=1, or write routes without the leading slash (dashboard instead of /dashboard).\n",
|
|
79
|
+
);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
routes.push(raw.startsWith("/") ? raw : `/${raw}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
url: values.url,
|
|
87
|
+
routes,
|
|
88
|
+
scan: values.scan ?? null,
|
|
89
|
+
out: values.out ?? null,
|
|
90
|
+
viewports,
|
|
91
|
+
probe: !values["no-probe"],
|
|
92
|
+
fullPage: !values["no-full-page"],
|
|
93
|
+
wait: Number(values.wait) || 0,
|
|
94
|
+
maxHeight: Number(values["max-height"]) || 5000,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Looks for Playwright in the reviewed project first, then next to this script.
|
|
99
|
+
export async function loadPlaywright(cwd = process.cwd()) {
|
|
100
|
+
const requires = [createRequire(join(cwd, "package.json")), createRequire(import.meta.url)];
|
|
101
|
+
for (const req of requires) {
|
|
102
|
+
for (const name of ["playwright", "@playwright/test", "playwright-core"]) {
|
|
103
|
+
try {
|
|
104
|
+
const mod = await import(pathToFileURL(req.resolve(name)).href);
|
|
105
|
+
const api = mod.chromium ? mod : mod.default;
|
|
106
|
+
if (api?.chromium) return api;
|
|
107
|
+
} catch {
|
|
108
|
+
// try the next candidate
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const timestamp = () => new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
116
|
+
const clip = (s) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
let args;
|
|
120
|
+
try {
|
|
121
|
+
args = parseCaptureArgs(process.argv.slice(2));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
fail(error.message, 2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pw = await loadPlaywright();
|
|
127
|
+
if (!pw) {
|
|
128
|
+
process.stderr.write(
|
|
129
|
+
[
|
|
130
|
+
"phyll: Playwright is not installed in this project.",
|
|
131
|
+
"Install it with:",
|
|
132
|
+
" npm i -D playwright",
|
|
133
|
+
" npx playwright install chromium",
|
|
134
|
+
"Or use your agent's browser tool and run probe.js in the page (see references/walkthrough.md).",
|
|
135
|
+
"",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
);
|
|
138
|
+
process.exit(3);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let routes = args.routes;
|
|
142
|
+
if (!routes && args.scan) {
|
|
143
|
+
const scan = JSON.parse(readFileSync(resolve(args.scan), "utf8"));
|
|
144
|
+
routes = staticRoutes((scan.structure?.routes ?? []).map((r) => r.path));
|
|
145
|
+
}
|
|
146
|
+
routes = staticRoutes(routes?.length ? routes : ["/"]);
|
|
147
|
+
|
|
148
|
+
const out = resolve(args.out ?? join(".phyll", "reports", timestamp()));
|
|
149
|
+
mkdirSync(join(out, "screens"), { recursive: true });
|
|
150
|
+
if (args.probe) mkdirSync(join(out, "probe"), { recursive: true });
|
|
151
|
+
const probeSource = readFileSync(join(HERE, "probe.js"), "utf8");
|
|
152
|
+
|
|
153
|
+
const slugs = uniqueSlugs(routes);
|
|
154
|
+
const pages = [];
|
|
155
|
+
const browser = await pw.chromium.launch();
|
|
156
|
+
try {
|
|
157
|
+
for (const name of args.viewports) {
|
|
158
|
+
const context = await browser.newContext(VIEWPORTS[name]);
|
|
159
|
+
const page = await context.newPage();
|
|
160
|
+
const consoleErrors = [];
|
|
161
|
+
page.on("console", (m) => {
|
|
162
|
+
if (m.type() === "error") consoleErrors.push(clip(m.text()));
|
|
163
|
+
});
|
|
164
|
+
page.on("pageerror", (e) => consoleErrors.push(clip(e.message)));
|
|
165
|
+
|
|
166
|
+
for (const [i, route] of routes.entries()) {
|
|
167
|
+
const url = new URL(route, args.url).href;
|
|
168
|
+
const slug = slugs[i];
|
|
169
|
+
const entry = { route, viewport: name, url, status: null };
|
|
170
|
+
try {
|
|
171
|
+
let response;
|
|
172
|
+
try {
|
|
173
|
+
response = await page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
|
|
174
|
+
} catch {
|
|
175
|
+
response = await page.goto(url, { waitUntil: "load", timeout: 20000 });
|
|
176
|
+
}
|
|
177
|
+
entry.status = response?.status() ?? null;
|
|
178
|
+
if (args.wait) await page.waitForTimeout(args.wait);
|
|
179
|
+
|
|
180
|
+
const shot = `screens/${name}-${slug}.png`;
|
|
181
|
+
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
182
|
+
const options = { path: join(out, shot), fullPage: args.fullPage };
|
|
183
|
+
if (args.fullPage && height > args.maxHeight) {
|
|
184
|
+
options.clip = { x: 0, y: 0, width: VIEWPORTS[name].viewport.width, height: args.maxHeight };
|
|
185
|
+
}
|
|
186
|
+
await page.screenshot(options);
|
|
187
|
+
entry.screenshot = shot;
|
|
188
|
+
|
|
189
|
+
if (args.probe) {
|
|
190
|
+
const data = await page.evaluate(probeSource);
|
|
191
|
+
const probePath = `probe/${name}-${slug}.json`;
|
|
192
|
+
writeFileSync(join(out, probePath), JSON.stringify(data, null, 2) + "\n");
|
|
193
|
+
entry.probe = probePath;
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
entry.error = clip(error.message);
|
|
197
|
+
}
|
|
198
|
+
entry.consoleErrors = consoleErrors.splice(0);
|
|
199
|
+
pages.push(entry);
|
|
200
|
+
}
|
|
201
|
+
await context.close();
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
await browser.close();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const manifest = {
|
|
208
|
+
tool: { name: NAME, version: VERSION },
|
|
209
|
+
createdAt: new Date().toISOString(),
|
|
210
|
+
baseUrl: args.url,
|
|
211
|
+
viewports: args.viewports,
|
|
212
|
+
pages,
|
|
213
|
+
};
|
|
214
|
+
writeFileSync(join(out, "capture.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
215
|
+
|
|
216
|
+
const failed = pages.filter((p) => p.error);
|
|
217
|
+
const noisy = pages.filter((p) => p.consoleErrors?.length);
|
|
218
|
+
process.stdout.write(`Captured ${pages.length - failed.length} of ${pages.length} screens into ${out}\n`);
|
|
219
|
+
for (const p of failed) process.stdout.write(` could not capture ${p.viewport} ${p.route}: ${p.error}\n`);
|
|
220
|
+
if (noisy.length) process.stdout.write(` ${noisy.length} screens logged JavaScript errors; see capture.json\n`);
|
|
221
|
+
if (!existsSync(join(out, "capture.json"))) process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isMain(import.meta.url)) main();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Small helpers shared by the command-line scripts.
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// True when the module at metaUrl is the script Node was asked to run.
|
|
6
|
+
export function isMain(metaUrl) {
|
|
7
|
+
if (!process.argv[1]) return false;
|
|
8
|
+
const self = fileURLToPath(metaUrl);
|
|
9
|
+
const entry = resolve(process.argv[1]);
|
|
10
|
+
return process.platform === "win32" ? self.toLowerCase() === entry.toLowerCase() : self === entry;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function fail(message, code = 1) {
|
|
14
|
+
process.stderr.write(`phyll: ${message}\n`);
|
|
15
|
+
process.exit(code);
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Reads .phyll/config.json from the reviewed project. Every field is optional.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = ".phyll";
|
|
6
|
+
export const CONFIG_FILE = "config.json";
|
|
7
|
+
|
|
8
|
+
export function configPath(projectDir) {
|
|
9
|
+
return join(projectDir, CONFIG_DIR, CONFIG_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readConfigFile(path) {
|
|
13
|
+
if (!path || !existsSync(path)) return {};
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`Could not read ${path}: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
}
|