@yargote/artifacts-cli 0.1.0-beta.1 → 0.1.0-beta.10

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.
Files changed (32) hide show
  1. package/README.md +150 -19
  2. package/package.json +9 -4
  3. package/skills/artifacts-cli/SKILL.md +78 -0
  4. package/skills/artifacts-cli/references/commands.md +240 -0
  5. package/skills/artifacts-cli/references/verify-retained.md +119 -0
  6. package/skills/artifacts-cli/scripts/prepare-retained.mjs +149 -0
  7. package/skills/artifacts-design/SKILL.md +92 -0
  8. package/skills/artifacts-design/THIRD-PARTY-NOTICES.md +155 -0
  9. package/skills/artifacts-design/fonts/OFL.txt +96 -0
  10. package/skills/artifacts-design/fonts/bricolage-grotesque.woff2 +0 -0
  11. package/skills/artifacts-design/fonts/jetbrains-mono.woff2 +0 -0
  12. package/skills/artifacts-design/fonts/newsreader-italic.woff2 +0 -0
  13. package/skills/artifacts-design/fonts/newsreader.woff2 +0 -0
  14. package/skills/artifacts-design/fonts/public-sans-italic.woff2 +0 -0
  15. package/skills/artifacts-design/fonts/public-sans.woff2 +0 -0
  16. package/skills/artifacts-design/references/canvas.md +100 -0
  17. package/skills/artifacts-design/references/catppuccin.md +38 -0
  18. package/skills/artifacts-design/references/interface-checks.md +60 -0
  19. package/skills/artifacts-design/references/local-checks.md +70 -0
  20. package/skills/artifacts-design/references/visual-explanations.md +13 -0
  21. package/skills/artifacts-design/scripts/embed-fonts.mjs +246 -0
  22. package/skills/artifacts-design/scripts/verify-local.mjs +333 -0
  23. package/skills/artifacts-design/templates/starter.html +170 -0
  24. package/src/auth.d.ts +4 -1
  25. package/src/auth.js +184 -45
  26. package/src/credentials.d.ts +2 -0
  27. package/src/credentials.js +22 -0
  28. package/src/index.js +121 -20
  29. package/src/operations.d.ts +68 -38
  30. package/src/operations.js +9 -1
  31. package/src/skills.d.ts +9 -0
  32. package/src/skills.js +181 -0
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const fonts = join(dirname(fileURLToPath(import.meta.url)), '../fonts');
7
+ const latin = 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD';
8
+ const faces = [
9
+ { family: 'Bricolage Grotesque', style: 'normal', weight: '500 800', file: 'bricolage-grotesque.woff2' },
10
+ { family: 'Public Sans', style: 'normal', weight: '400 700', file: 'public-sans.woff2' },
11
+ { family: 'Public Sans', style: 'italic', weight: '400 700', file: 'public-sans-italic.woff2' },
12
+ { family: 'JetBrains Mono', style: 'normal', weight: '400 700', file: 'jetbrains-mono.woff2' },
13
+ { family: 'Newsreader', style: 'normal', weight: '400 700', file: 'newsreader.woff2' },
14
+ { family: 'Newsreader', style: 'italic', weight: '400 700', file: 'newsreader-italic.woff2' },
15
+ ];
16
+ const limit = 1024 * 1024;
17
+
18
+ function htmlTypographySources(page) {
19
+ const sources = [];
20
+ const managed = [];
21
+ const tags = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<\/?([a-z][a-z0-9:-]*)(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
22
+ let italic = false;
23
+ let anchor;
24
+ let head;
25
+ let match;
26
+ while ((match = tags.exec(page))) {
27
+ const [tag, name] = match;
28
+ if (tag.startsWith('<!--')) continue;
29
+ if (/^<!doctype\b/i.test(tag)) head ??= tags.lastIndex;
30
+ if (!name || tag.startsWith('</')) continue;
31
+ const lower = name.toLowerCase();
32
+ if (lower === 'head') head = tags.lastIndex;
33
+ const attributes = new Map();
34
+ const attribute = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
35
+ const entities = { quot: '"', apos: "'", amp: '&', lt: '<', gt: '>', sol: '/' };
36
+ for (const entry of tag.slice(name.length + 1, -1).matchAll(attribute)) {
37
+ const key = entry[1].toLowerCase();
38
+ const value = (entry[2] ?? entry[3] ?? entry[4] ?? '').replace(/&(#(?:[xX][\da-fA-F]+|\d+));?|&(quot|QUOT|apos|amp|AMP|lt|LT|gt|GT|sol);/g, (entity, numeric, named) => {
39
+ if (named) return entities[named.toLowerCase()];
40
+ const code = numeric[1].toLowerCase() === 'x' ? parseInt(numeric.slice(2), 16) : Number(numeric.slice(1));
41
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
42
+ });
43
+ if (!attributes.has(key)) attributes.set(key, value);
44
+ }
45
+ if (attributes.has('style')) sources.push({ css: attributes.get('style'), inline: true });
46
+ if (['em', 'i', 'cite'].includes(lower)) italic = true;
47
+ if (lower === 'plaintext') break;
48
+ if (['style', 'script', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'].includes(lower)) {
49
+ const close = new RegExp(`</${lower}\\s*>`, 'gi');
50
+ close.lastIndex = tags.lastIndex;
51
+ const end = close.exec(page);
52
+ const contentEnd = end?.index ?? page.length;
53
+ if (lower === 'style') {
54
+ if (attributes.has('data-artifacts-fonts')) {
55
+ let after = end ? close.lastIndex : page.length;
56
+ if (page[after] === '\n') after++;
57
+ managed.push([match.index, after]);
58
+ } else if (!attributes.get('type') || /^text\/css$/i.test(attributes.get('type'))) {
59
+ anchor ??= match.index;
60
+ sources.push({ css: page.slice(tags.lastIndex, contentEnd), inline: false });
61
+ }
62
+ }
63
+ tags.lastIndex = end ? close.lastIndex : page.length;
64
+ }
65
+ }
66
+ return { sources, managed, italic, insertionAt: anchor ?? head ?? 0 };
67
+ }
68
+
69
+ function cssDeclarations(css, inline) {
70
+ const declarations = [];
71
+ const blocks = [];
72
+ let segment = '';
73
+ let quote = '';
74
+ let parentheses = 0;
75
+ let brackets = 0;
76
+ let valueBraces = 0;
77
+ function emit() {
78
+ const match = /^\s*(--[\w-]+|[-a-z]+)\s*:\s*([\s\S]*)$/i.exec(segment);
79
+ if (match && (inline || blocks.length) && !blocks.includes(true)) {
80
+ declarations.push({ property: match[1].startsWith('--') ? match[1] : match[1].toLowerCase(), value: match[2].replace(/\s*!\s*important\s*$/i, '') });
81
+ }
82
+ segment = '';
83
+ }
84
+ for (let index = 0; index < css.length; index++) {
85
+ const char = css[index];
86
+ if (char === '\\') {
87
+ segment += css.slice(index, index + 2);
88
+ index++;
89
+ } else if (quote) {
90
+ segment += char;
91
+ if (char === quote) quote = '';
92
+ } else if (char === '"' || char === "'") {
93
+ quote = char;
94
+ segment += char;
95
+ } else if (char === '/' && css[index + 1] === '*') {
96
+ const end = css.indexOf('*/', index + 2);
97
+ index = end < 0 ? css.length : end + 1;
98
+ segment += ' ';
99
+ } else if (char === '(' || char === '[') {
100
+ if (char === '(') parentheses++;
101
+ else brackets++;
102
+ segment += char;
103
+ } else if (char === ')' || char === ']') {
104
+ if (char === ')') parentheses--;
105
+ else brackets--;
106
+ segment += char;
107
+ } else if (!parentheses && !brackets && char === '{') {
108
+ if (valueBraces || /^\s*--[\w-]+\s*:/.test(segment)) {
109
+ valueBraces++;
110
+ segment += char;
111
+ } else {
112
+ blocks.push(/^\s*@font-face\b/i.test(segment));
113
+ segment = '';
114
+ }
115
+ } else if (!parentheses && !brackets && char === '}' && valueBraces) {
116
+ valueBraces--;
117
+ segment += char;
118
+ } else if (!parentheses && !brackets && !valueBraces && (char === ';' || char === '}')) {
119
+ emit();
120
+ if (char === '}') blocks.pop();
121
+ } else segment += char;
122
+ }
123
+ emit();
124
+ return declarations;
125
+ }
126
+
127
+ function splitValue(value, separator) {
128
+ const parts = [];
129
+ let start = 0;
130
+ let depth = 0;
131
+ let quote = '';
132
+ for (let index = 0; index < value.length; index++) {
133
+ const char = value[index];
134
+ if (char === '\\') index++;
135
+ else if (quote) { if (char === quote) quote = ''; }
136
+ else if (char === '"' || char === "'") quote = char;
137
+ else if (char === '(') depth++;
138
+ else if (char === ')') depth--;
139
+ else if (!depth && separator.test(char)) {
140
+ parts.push(value.slice(start, index));
141
+ if (char === '/') parts.push('/');
142
+ start = index + 1;
143
+ }
144
+ }
145
+ parts.push(value.slice(start));
146
+ return parts;
147
+ }
148
+
149
+ function fontValueAlternatives(value, properties, visiting = new Set()) {
150
+ let quote = '';
151
+ for (let index = 0; index < value.length; index++) {
152
+ const char = value[index];
153
+ if (char === '\\') index++;
154
+ else if (quote) { if (char === quote) quote = ''; }
155
+ else if (char === '"' || char === "'") quote = char;
156
+ else if (/^var\(/i.test(value.slice(index, index + 4)) && !/[\w-]/.test(value[index - 1] ?? '')) {
157
+ let depth = 1;
158
+ let end = index + 4;
159
+ let innerQuote = '';
160
+ for (; end < value.length && depth; end++) {
161
+ const inner = value[end];
162
+ if (inner === '\\') end++;
163
+ else if (innerQuote) { if (inner === innerQuote) innerQuote = ''; }
164
+ else if (inner === '"' || inner === "'") innerQuote = inner;
165
+ else if (inner === '(') depth++;
166
+ else if (inner === ')') depth--;
167
+ }
168
+ if (depth) return [];
169
+ const [name, ...fallback] = splitValue(value.slice(index + 4, end - 1), /,/);
170
+ const key = name.trim();
171
+ const candidates = [];
172
+ if (!visiting.has(key)) {
173
+ const path = new Set(visiting).add(key);
174
+ for (const definition of properties.get(key) ?? []) candidates.push(...fontValueAlternatives(definition, properties, path));
175
+ }
176
+ if (fallback.length) candidates.push(...fontValueAlternatives(fallback.join(','), properties, visiting));
177
+ return [...new Set(candidates.flatMap((candidate) => fontValueAlternatives(value.slice(0, index) + candidate + value.slice(end), properties, visiting)))];
178
+ }
179
+ }
180
+ return [value];
181
+ }
182
+
183
+ function typographyUsage(document) {
184
+ const declarations = document.sources.flatMap(({ css, inline }) => cssDeclarations(css, inline));
185
+ const properties = new Map();
186
+ for (const { property, value } of declarations) {
187
+ if (property.startsWith('--')) properties.set(property, [...properties.get(property) ?? [], value]);
188
+ }
189
+ const usage = { families: new Set(), italic: document.italic };
190
+ for (const { property, value } of declarations) {
191
+ if (!['font-family', 'font', 'font-style'].includes(property)) continue;
192
+ for (let resolved of fontValueAlternatives(value, properties)) {
193
+ resolved = resolved.trim();
194
+ if (property === 'font-style') {
195
+ if (/^italic$/i.test(resolved)) usage.italic = true;
196
+ continue;
197
+ }
198
+ if (property === 'font') {
199
+ const tokens = splitValue(resolved, /[\s/]/).filter(Boolean);
200
+ const size = tokens.findIndex((token) => /^(?:\d*\.?\d+(?:[a-z]+|%)|0|xx?-small|small|medium|large|x{1,3}-large|smaller|larger)$/i.test(token) || /^(?:calc|min|max|clamp)\(/i.test(token));
201
+ if (size < 0) continue;
202
+ if (tokens.slice(0, size).some((token) => /^italic$/i.test(token))) usage.italic = true;
203
+ resolved = tokens.slice(size + (tokens[size + 1] === '/' ? 3 : 1)).join(' ');
204
+ }
205
+ for (let family of splitValue(resolved, /,/)) {
206
+ family = family.trim();
207
+ if (/^(["'])[\s\S]*\1$/.test(family)) family = family.slice(1, -1);
208
+ family = family.replace(/\\([\da-f]{1,6})(?:\r\n|[\t\n\r\f ])?|\\([^\r\n\f])/gi, (_, hex, escaped) => {
209
+ const code = parseInt(hex, 16);
210
+ return hex ? code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd' : escaped;
211
+ }).replace(/\s+/g, ' ').toLowerCase();
212
+ const face = faces.find((candidate) => candidate.family.toLowerCase() === family);
213
+ if (face) usage.families.add(face.family);
214
+ }
215
+ }
216
+ }
217
+ return usage;
218
+ }
219
+
220
+ const paths = process.argv.slice(2);
221
+ if (paths.length !== 1) {
222
+ process.stderr.write('Usage: node embed-fonts.mjs PAGE.html\nEmbeds the bundled font faces that PAGE.html names, in place.\n');
223
+ process.exit(2);
224
+ }
225
+ const [path] = paths;
226
+ let page = await readFile(path, 'utf8');
227
+ for (const [start, end] of htmlTypographySources(page).managed.reverse()) page = page.slice(0, start) + page.slice(end);
228
+ const document = htmlTypographySources(page);
229
+ const usage = typographyUsage(document);
230
+ const used = faces.filter((face) => usage.families.has(face.family) && (face.style === 'normal' || usage.italic));
231
+ const rules = await Promise.all(used.map(async (face) => {
232
+ const data = (await readFile(join(fonts, face.file))).toString('base64');
233
+ return `@font-face{font-family:"${face.family}";font-style:${face.style};font-weight:${face.weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format("woff2");unicode-range:${latin}}`;
234
+ }));
235
+ const block = rules.length ? `<style data-artifacts-fonts>${rules.join('')}</style>\n` : '';
236
+
237
+ const at = document.insertionAt;
238
+ const output = page.slice(0, at) + block + page.slice(at);
239
+ const bytes = Buffer.byteLength(output);
240
+ if (bytes > limit) {
241
+ process.stderr.write(`Not written. ${path} would be ${bytes} bytes, above the 1 MiB revision limit. Drop a family or the italic faces, or shrink the content.\n`);
242
+ process.exit(1);
243
+ }
244
+ await writeFile(path, output);
245
+ const names = used.map((face) => `${face.family}${face.style === 'italic' ? ' italic' : ''}`);
246
+ process.stdout.write(`${names.length ? `Embedded ${names.join(', ')}` : 'No bundled family named; nothing embedded'}. ${path} is ${bytes} bytes.\n`);
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { once } from 'node:events';
6
+ import { access, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
7
+ import { constants } from 'node:fs';
8
+ import { createServer } from 'node:http';
9
+ import { createRequire } from 'node:module';
10
+ import { homedir } from 'node:os';
11
+ import { delimiter, join, resolve, sep } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+
14
+ const execute = promisify(execFile);
15
+ const framePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
16
+ const previewPolicy = `${framePolicy}; frame-ancestors 'self'; sandbox allow-scripts`;
17
+ const variants = [375, 1280].flatMap((width) => ['light', 'dark'].map((theme) => ({ width, height: 900, theme })));
18
+ const fields = { text: ['text'], count: ['count'], visible: ['visible'], visibleCount: ['count'], attribute: ['name', 'value'], click: [], press: ['key'], fill: ['value'], check: ['checked'], select: ['values'] };
19
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
20
+ const message = (error) => error instanceof Error ? error.message : String(error);
21
+ const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
22
+ const compatible = (ua) => !/Edg|OPR|SamsungBrowser|CriOS|FxiOS/.test(ua) && Number(/(?:Headless)?Chrome\/(\d+)\./.exec(ua)?.[1]) >= 152;
23
+
24
+ function object(value, required, optional, label) {
25
+ assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
26
+ assert(required.every((key) => Object.hasOwn(value, key)), `${label} has missing fields`);
27
+ assert(Object.keys(value).every((key) => required.includes(key) || optional.includes(key)), `${label} has unknown fields`);
28
+ return value;
29
+ }
30
+
31
+ function parseExpectations(value) {
32
+ object(value, ['expectations'], ['timeoutMs'], 'expectations document');
33
+ assert(Array.isArray(value.expectations) && value.expectations.length <= 100, 'expectations must contain at most 100 steps');
34
+ const timeoutMs = value.timeoutMs ?? 3000;
35
+ assert(Number.isInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 30000, 'timeoutMs must be an integer from 100 to 30000');
36
+ for (const step of value.expectations) {
37
+ assert(step && Object.hasOwn(fields, step.kind), 'Unknown expectation kind');
38
+ object(step, ['kind', 'selector', ...fields[step.kind]], ['when'], 'expectation');
39
+ assert(typeof step.selector === 'string' && step.selector.trim() && step.selector.length <= 8192, 'selector must be a nonempty string');
40
+ if (step.when !== undefined) {
41
+ object(step.when, [], ['width', 'theme'], 'when');
42
+ if (step.when.width !== undefined) assert([375, 1280].includes(step.when.width), 'when.width must be 375 or 1280');
43
+ if (step.when.theme !== undefined) assert(['light', 'dark'].includes(step.when.theme), 'when.theme must be light or dark');
44
+ }
45
+ for (const key of fields[step.kind]) {
46
+ if (['count'].includes(key)) assert(Number.isInteger(step[key]) && step[key] >= 0, 'count must be a nonnegative integer');
47
+ else if (['visible', 'checked'].includes(key)) assert(typeof step[key] === 'boolean', `${key} must be boolean`);
48
+ else if (key === 'values') assert(Array.isArray(step.values) && step.values.length <= 100 && step.values.every((entry) => typeof entry === 'string'), 'values must be strings');
49
+ else if (key === 'value' && step.kind === 'attribute') assert(step.value === null || typeof step.value === 'string', 'attribute value must be a string or null');
50
+ else assert(typeof step[key] === 'string' && (!['key', 'name'].includes(key) || step[key].trim()), `${key} must be a string`);
51
+ }
52
+ }
53
+ return { expectations: value.expectations, timeoutMs };
54
+ }
55
+
56
+ function invocation(args) {
57
+ const options = {};
58
+ let html;
59
+ for (let index = 0; index < args.length; index++) {
60
+ const arg = args[index];
61
+ if (arg.startsWith('--')) {
62
+ assert(['--evidence', '--expectations', '--browser'].includes(arg), `Unknown option ${arg}`);
63
+ assert(!Object.hasOwn(options, arg) && args[index + 1] && !args[index + 1].startsWith('--'), `Supply ${arg} once with a value`);
64
+ options[arg] = args[++index];
65
+ } else { assert(!html, 'Supply one HTML file'); html = arg; }
66
+ }
67
+ assert(html && options['--evidence'], 'Usage: verify-local.mjs HTML --evidence DIRECTORY [--expectations JSON] [--browser EXECUTABLE]');
68
+ return { html: resolve(html), evidence: resolve(options['--evidence']), expectations: options['--expectations'] && resolve(options['--expectations']), browser: options['--browser'] && resolve(options['--browser']) };
69
+ }
70
+
71
+ function playwright() {
72
+ for (const base of [join(process.cwd(), 'package.json'), import.meta.url]) {
73
+ const require = createRequire(base);
74
+ for (const name of ['playwright', 'playwright-core']) {
75
+ try { return require(name).chromium; }
76
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
77
+ }
78
+ }
79
+ throw new Error('No Playwright runtime found. Provide an existing playwright or playwright-core package in the working directory project, then rerun. The helper does not install packages or browsers.');
80
+ }
81
+
82
+ async function cachedExecutables(chromium) {
83
+ const expected = chromium.executablePath();
84
+ const browserDirectory = /[\\/](?:chromium|chromium_headless_shell)-\d+[\\/]/.exec(expected);
85
+ const roots = new Set([browserDirectory && expected.slice(0, browserDirectory.index), process.env.PLAYWRIGHT_BROWSERS_PATH && process.env.PLAYWRIGHT_BROWSERS_PATH !== '0' && resolve(process.env.PLAYWRIGHT_BROWSERS_PATH)].filter(Boolean));
86
+ const executables = [];
87
+ for (const root of roots) {
88
+ let entries;
89
+ try { entries = await readdir(root, { withFileTypes: true }); }
90
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
91
+ for (const entry of entries.filter((item) => item.isDirectory() && /^(?:chromium|chromium_headless_shell)-\d+$/.test(item.name)).sort((a, b) => b.name.localeCompare(a.name, 'en', { numeric: true })).slice(0, 50)) {
92
+ const directory = join(root, entry.name);
93
+ for (const relative of [
94
+ 'chrome-linux/chrome', 'chrome-linux64/chrome', 'chrome-linux/headless_shell', 'chrome-headless-shell-linux64/chrome-headless-shell',
95
+ 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
96
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell', 'chrome-headless-shell-mac-x64/chrome-headless-shell',
97
+ 'chrome-win/chrome.exe', 'chrome-win64/chrome.exe', 'chrome-headless-shell-win64/chrome-headless-shell.exe',
98
+ ]) {
99
+ const candidate = join(directory, relative);
100
+ try { await access(candidate, constants.X_OK); executables.push(candidate); } catch {}
101
+ }
102
+ }
103
+ }
104
+ return executables;
105
+ }
106
+
107
+ async function chooseBrowser(chromium, explicit, diagnostics) {
108
+ const candidates = explicit ? [explicit] : [
109
+ chromium.executablePath(), ...await cachedExecutables(chromium),
110
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium',
111
+ join(homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
112
+ ...['PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA'].filter((key) => process.env[key]).flatMap((key) => [join(process.env[key], 'Google/Chrome/Application/chrome.exe'), join(process.env[key], 'Chromium/Application/chrome.exe')]),
113
+ ...(process.env.PATH ?? '').split(delimiter).flatMap((directory) => ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome'].map((name) => join(directory, name))),
114
+ ];
115
+ for (const candidate of new Set(candidates)) {
116
+ let browser;
117
+ try {
118
+ await access(candidate, constants.X_OK);
119
+ const executable = await realpath(candidate);
120
+ const { stdout, stderr } = await execute(executable, ['--version'], { timeout: 5000, maxBuffer: 65536 });
121
+ const version = `${stdout} ${stderr}`.trim();
122
+ assert(/Chrom(?:e|ium)/i.test(version) && Number(/(\d+)\./.exec(version)?.[1]) >= 152, `Incompatible version ${version}. Chrome or Chromium 152+ is required`);
123
+ browser = await chromium.launch({ executablePath: executable, headless: true, chromiumSandbox: true, timeout: 15000 });
124
+ const page = await browser.newPage();
125
+ const userAgent = await page.evaluate(() => navigator.userAgent);
126
+ assert(compatible(userAgent), `Unsupported launched user agent ${userAgent}`);
127
+ await page.close();
128
+ return { browser, identity: { executable, selectedPath: candidate, version: browser.version(), probeVersion: version, userAgent } };
129
+ } catch (error) {
130
+ await browser?.close();
131
+ diagnostics.push({ executable: candidate, reason: message(error) });
132
+ }
133
+ }
134
+ throw new Error('No compatible browser could launch. Use --browser /absolute/path/to/an/existing/Chrome-or-Chromium-152+ executable. Check browserDiagnostics in report.json. No software was installed and browser sandboxing was not disabled.');
135
+ }
136
+
137
+ async function poll(read, expected, timeout) {
138
+ const deadline = Date.now() + timeout;
139
+ let actual;
140
+ do {
141
+ actual = await read();
142
+ try { assert.deepEqual(actual, expected); return; } catch {}
143
+ await new Promise((done) => setTimeout(done, 50));
144
+ } while (Date.now() < deadline);
145
+ assert.deepEqual(actual, expected);
146
+ }
147
+
148
+ async function expectation(frame, step, timeout) {
149
+ const locator = frame.locator(step.selector);
150
+ const actions = {
151
+ click: () => locator.click(), press: () => locator.press(step.key), fill: () => locator.fill(step.value),
152
+ check: () => locator.setChecked(step.checked), select: () => locator.selectOption(step.values),
153
+ };
154
+ if (Object.hasOwn(actions, step.kind)) { await actions[step.kind](); return; }
155
+ if (step.kind === 'count') return poll(() => locator.count(), step.count, timeout);
156
+ if (step.kind === 'visibleCount') return poll(async () => {
157
+ const items = await locator.all();
158
+ const visible = await Promise.all(items.map((item) => item.isVisible()));
159
+ return visible.filter(Boolean).length;
160
+ }, step.count, timeout);
161
+ if (step.kind === 'visible') return poll(async () => {
162
+ const count = await locator.count();
163
+ return { count, visible: count === 1 ? await locator.isVisible() : null };
164
+ }, { count: 1, visible: step.visible }, timeout);
165
+ if (step.kind === 'text') return poll(() => locator.innerText(), step.text, timeout);
166
+ return poll(() => locator.getAttribute(step.name), step.value, timeout);
167
+ }
168
+
169
+ async function dimensions(frame) {
170
+ return frame.evaluate(() => ({ viewportWidth: innerWidth, documentWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), documentHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0), viewportHeight: innerHeight }));
171
+ }
172
+
173
+ async function capture(page, frame, result, directory, phase) {
174
+ const size = await dimensions(frame);
175
+ const maximum = Math.max(0, size.documentHeight - size.viewportHeight);
176
+ const offsets = [0];
177
+ while (offsets.at(-1) < maximum && offsets.length < 12) offsets.push(Math.min(maximum, offsets.at(-1) + size.viewportHeight));
178
+ const actualOffsets = [];
179
+ for (const offset of offsets) {
180
+ const actual = await frame.evaluate((top) => { scrollTo({ top, left: 0, behavior: 'instant' }); return scrollY; }, offset);
181
+ actualOffsets.push(actual);
182
+ await page.waitForTimeout(50);
183
+ const path = `${result.variant.width}-${result.variant.theme}-${phase}-${offset}.png`;
184
+ await page.screenshot({ path: join(directory, path), timeout: 5000 });
185
+ result.screenshots.push({ path, phase, scrollY: actual, width: result.variant.width, height: result.variant.height });
186
+ }
187
+ const finalSize = await dimensions(frame);
188
+ const capturedThrough = Math.min(finalSize.documentHeight, Math.max(...actualOffsets) + size.viewportHeight);
189
+ const reachedOffsets = actualOffsets.every((actual, index) => Math.abs(actual - offsets[index]) <= 1);
190
+ result.captureCoverage.push({ phase, documentHeight: finalSize.documentHeight, capturedThrough, truncated: !reachedOffsets || capturedThrough < finalSize.documentHeight });
191
+ await frame.evaluate(() => scrollTo({ top: 0, left: 0, behavior: 'instant' }));
192
+ }
193
+
194
+ async function runVariant(browser, origin, config, variant, directory) {
195
+ const deadline = { timeoutMs: Math.min(60000, Math.max(10000, config.timeoutMs * (config.expectations.length + 1))), exceeded: false };
196
+ const result = { variant, status: 'passed', deadline, checks: [], screenshots: [], captureCoverage: [], consoleErrors: [], runtimeErrors: [], externalAttempts: [], errors: [] };
197
+ const context = await browser.newContext({ viewport: { width: variant.width, height: variant.height }, colorScheme: variant.theme, serviceWorkers: 'block' });
198
+ context.setDefaultTimeout(config.timeoutMs);
199
+ let closing;
200
+ const close = () => closing ??= context.close().catch((error) => { result.errors.push(`Browser context cleanup failed. ${message(error)}`); });
201
+ const watchdog = setTimeout(() => {
202
+ deadline.exceeded = true;
203
+ result.errors.push(`Variant deadline exceeded after ${deadline.timeoutMs} ms. Remaining checks, CSP observations, and captures are incomplete.`);
204
+ void close();
205
+ }, deadline.timeoutMs);
206
+ let frame;
207
+ const overflow = async (phase) => {
208
+ const size = await dimensions(frame);
209
+ const passed = size.documentWidth <= size.viewportWidth + 1;
210
+ result.checks.push({ kind: 'overflow', phase, status: passed ? 'passed' : 'failed', ...size });
211
+ };
212
+ try {
213
+ const page = await context.newPage();
214
+ page.on('console', (entry) => { if (entry.type() === 'error') result.consoleErrors.push(entry.text()); });
215
+ page.on('pageerror', (error) => result.runtimeErrors.push(message(error)));
216
+ await context.route('**/*', async (route) => {
217
+ const request = route.request();
218
+ if ([`${origin}/`, `${origin}/content`].includes(request.url()) && request.method() === 'GET' && request.resourceType() === 'document') await route.continue();
219
+ else { result.externalAttempts.push({ source: 'request', url: request.url(), resourceType: request.resourceType() }); await route.abort('blockedbyclient'); }
220
+ });
221
+ await context.addInitScript(() => {
222
+ globalThis.__artifactsLocalViolations = [];
223
+ addEventListener('securitypolicyviolation', (event) => globalThis.__artifactsLocalViolations.push({ source: 'csp', url: event.blockedURI, directive: event.effectiveDirective }));
224
+ });
225
+ const responsePromise = page.waitForResponse((response) => response.url() === `${origin}/content`);
226
+ const [, response] = await Promise.all([page.goto(`${origin}/`, { waitUntil: 'load', timeout: 10000 }), responsePromise]);
227
+ const headers = response.headers();
228
+ result.sandbox = { csp: headers['content-security-policy'], connectionAllowlist: headers['connection-allowlist'], dnsPrefetchControl: headers['x-dns-prefetch-control'], iframeSandbox: await page.locator('iframe').getAttribute('sandbox'), contentSha256: hash(await response.body()) };
229
+ assert.equal(result.sandbox.csp, previewPolicy);
230
+ assert.equal(result.sandbox.connectionAllowlist, '()');
231
+ assert.equal(result.sandbox.dnsPrefetchControl, 'off');
232
+ assert.equal(result.sandbox.iframeSandbox, 'allow-scripts');
233
+ frame = page.frames().find((item) => item.url() === `${origin}/content`);
234
+ assert(frame, 'The local sandboxed content frame must load');
235
+ await frame.evaluate(async (timeout) => {
236
+ await Promise.race([document.fonts.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Fonts readiness timed out')), timeout))]);
237
+ }, config.timeoutMs);
238
+ await page.waitForTimeout(200);
239
+ await overflow('initial');
240
+ await capture(page, frame, result, directory, 'initial');
241
+ for (const [index, step] of config.expectations.entries()) {
242
+ if (step.when && ((step.when.width !== undefined && step.when.width !== variant.width) || (step.when.theme !== undefined && step.when.theme !== variant.theme))) continue;
243
+ try {
244
+ await expectation(frame, step, config.timeoutMs);
245
+ result.checks.push({ kind: step.kind, step: index, status: 'passed' });
246
+ } catch (error) { result.checks.push({ kind: step.kind, step: index, status: 'failed', message: message(error) }); break; }
247
+ }
248
+ assert.equal(page.url(), `${origin}/`, 'The document must not navigate the wrapper');
249
+ assert.equal(frame.url(), `${origin}/content`, 'The document must not replace its content frame');
250
+ await page.waitForTimeout(200);
251
+ await overflow('final');
252
+ await capture(page, frame, result, directory, 'final');
253
+ } catch (error) { result.errors.push(message(error)); }
254
+ finally {
255
+ if (!deadline.exceeded && frame && !frame.isDetached()) {
256
+ try { result.externalAttempts.push(...await frame.evaluate(() => globalThis.__artifactsLocalViolations ?? [])); }
257
+ catch (error) { result.errors.push(`CSP evidence unavailable. ${message(error)}`); }
258
+ }
259
+ await close();
260
+ clearTimeout(watchdog);
261
+ }
262
+ if (result.checks.some((item) => item.status === 'failed') || result.errors.length || result.consoleErrors.length || result.runtimeErrors.length || result.externalAttempts.length) result.status = 'failed';
263
+ return result;
264
+ }
265
+
266
+ async function main() {
267
+ const report = {
268
+ schemaVersion: 1, evidenceKind: 'renderizado local', authenticatedPublication: 'not-verified', status: 'blocked',
269
+ visualReview: { status: 'required', checks: ['Content fidelity and reading order', 'Composition, alignment, legibility, and focus visibility at both widths and themes', 'Inspect screenshot tiles and any truncated capture coverage'] },
270
+ input: null, browser: null, browserDiagnostics: [],
271
+ sandbox: { csp: previewPolicy, iframeSandbox: 'allow-scripts', connectionAllowlist: '()', dnsPrefetchControl: 'off', referrerPolicy: 'no-referrer', chromiumSandbox: true },
272
+ observation: 'Each fresh variant waits for load and fonts, observes 200 ms before and after its scenario, and captures bounded viewport tiles. Delayed behavior beyond this run requires additional review.',
273
+ variants: [], errors: [],
274
+ };
275
+ let directory;
276
+ let server;
277
+ let browser;
278
+ let config;
279
+ try {
280
+ config = invocation(process.argv.slice(2));
281
+ for (const input of [config.html, config.expectations].filter(Boolean)) assert(config.evidence !== input && !config.evidence.startsWith(`${input}${sep}`), 'Evidence destination must not overwrite an input file');
282
+ await mkdir(config.evidence, { recursive: true });
283
+ directory = await mkdtemp(join(config.evidence, 'local-'));
284
+ report.evidenceDirectory = directory;
285
+ const bytes = await readFile(config.html);
286
+ report.input = { path: await realpath(config.html), sha256: hash(bytes), bytes: bytes.length };
287
+ const checks = config.expectations ? parseExpectations(JSON.parse(await readFile(config.expectations, 'utf8'))) : { expectations: [], timeoutMs: 3000 };
288
+ const content = `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${escape(framePolicy)}"><meta name="referrer" content="no-referrer"></head><body>${bytes.toString('utf8')}</body></html>`;
289
+ report.sandbox.contentSha256 = hash(content);
290
+ const chosen = await chooseBrowser(playwright(), config.browser, report.browserDiagnostics);
291
+ browser = chosen.browser;
292
+ report.browser = chosen.identity;
293
+ server = createServer((request, response) => {
294
+ if (request.method !== 'GET' || !['/', '/content'].includes(request.url)) { response.writeHead(404).end(); return; }
295
+ response.setHeader('content-type', 'text/html; charset=utf-8');
296
+ response.setHeader('cache-control', 'no-store');
297
+ response.setHeader('x-content-type-options', 'nosniff');
298
+ response.setHeader('referrer-policy', 'no-referrer');
299
+ response.setHeader('x-dns-prefetch-control', 'off');
300
+ if (request.url === '/content') response.setHeader('connection-allowlist', '()');
301
+ response.setHeader('content-security-policy', request.url === '/content' ? previewPolicy : "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'");
302
+ response.end(request.url === '/content' ? content : '<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}iframe{display:block;border:0;width:100%;height:100%}</style><iframe title="Local artifact" sandbox="allow-scripts" referrerpolicy="no-referrer" src="/content"></iframe>');
303
+ });
304
+ server.listen(0, '127.0.0.1');
305
+ await once(server, 'listening');
306
+ const origin = `http://127.0.0.1:${server.address().port}`;
307
+ for (const variant of variants) report.variants.push(await runVariant(browser, origin, checks, variant, directory));
308
+ report.status = report.variants.every((item) => item.status === 'passed') ? 'passed' : 'failed';
309
+ } catch (error) { report.errors.push(message(error)); }
310
+ finally {
311
+ try { await browser?.close(); } catch (error) { report.errors.push(`Browser cleanup failed. ${message(error)}`); }
312
+ if (server) {
313
+ try { await new Promise((done, reject) => server.close((error) => error ? reject(error) : done())); }
314
+ catch (error) { report.errors.push(`Local server cleanup failed. ${message(error)}`); }
315
+ }
316
+ if (report.errors.length && report.status === 'passed') report.status = 'failed';
317
+ if (report.input) {
318
+ try {
319
+ report.input.sha256After = hash(await readFile(config.html));
320
+ assert.equal(report.input.sha256After, report.input.sha256, 'Input HTML changed during verification');
321
+ } catch (error) { report.status = 'failed'; report.errors.push(message(error)); }
322
+ }
323
+ if (directory) {
324
+ await writeFile(join(directory, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
325
+ const links = report.variants.flatMap((item) => item.screenshots.map((capture) => `- [${capture.path}](${capture.path}). Frame scroll offset ${capture.scrollY} px.`));
326
+ await writeFile(join(directory, 'index.md'), `# Local rendering evidence\n\nEvidence kind is renderizado local. Authenticated publication is not verified.\n\nAutomatic checks ${report.status}. Visual review remains required.\n\n${links.join('\n')}\n`);
327
+ }
328
+ console.log(JSON.stringify(report));
329
+ process.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2;
330
+ }
331
+ }
332
+
333
+ await main();