@yargote/artifacts-cli 0.1.0-beta.6 → 0.1.0-beta.8
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 +44 -13
- package/package.json +3 -3
- package/skills/artifacts-cli/SKILL.md +8 -4
- package/skills/artifacts-cli/references/commands.md +39 -6
- package/skills/artifacts-cli/references/verify-retained.md +119 -0
- package/skills/artifacts-cli/scripts/prepare-retained.mjs +149 -0
- package/skills/artifacts-design/SKILL.md +12 -2
- package/skills/artifacts-design/references/canvas.md +100 -0
- package/skills/artifacts-design/references/local-checks.md +70 -0
- package/skills/artifacts-design/scripts/embed-fonts.mjs +208 -7
- package/skills/artifacts-design/scripts/verify-local.mjs +333 -0
- package/src/auth.d.ts +4 -1
- package/src/auth.js +184 -45
- package/src/credentials.d.ts +2 -0
- package/src/credentials.js +22 -0
- package/src/index.js +48 -12
- package/src/operations.d.ts +3 -0
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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();
|
package/src/auth.d.ts
CHANGED
|
@@ -4,7 +4,10 @@ export declare function login(options: {
|
|
|
4
4
|
stateDir: string;
|
|
5
5
|
noBrowser: boolean;
|
|
6
6
|
onAuthorizationUrl: (url: string) => void;
|
|
7
|
-
}): Promise<
|
|
7
|
+
}): Promise<{
|
|
8
|
+
resource: string;
|
|
9
|
+
identity: Identity;
|
|
10
|
+
}>;
|
|
8
11
|
export declare function accessToken(options: {
|
|
9
12
|
resource: string;
|
|
10
13
|
stateDir: string;
|