@yargote/artifacts-cli 0.1.0-beta.5 → 0.1.0-beta.7
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 +35 -9
- package/package.json +3 -3
- package/skills/artifacts-cli/SKILL.md +16 -4
- package/skills/artifacts-cli/references/commands.md +40 -1
- 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/index.js +14 -3
- package/src/operations.d.ts +48 -2
- package/src/operations.js +7 -1
|
@@ -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/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
9
|
+
import { createSchema, listSharesSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
10
10
|
import { canonicalResource } from '@yargote/artifacts-contracts/http';
|
|
11
11
|
import { createArtifactHttpClient, HttpError, prepareArtifactEdit } from '@yargote/artifacts-client';
|
|
12
12
|
import { accessToken, login, logout } from "./auth.js";
|
|
@@ -46,6 +46,9 @@ Commands:
|
|
|
46
46
|
export ID --revision REV --out FILE
|
|
47
47
|
move ID --project NAME [--request-id ID]
|
|
48
48
|
move ID --no-project [--request-id ID]
|
|
49
|
+
share ID --email EMAIL [--notify] [--request-id ID]
|
|
50
|
+
shares ID
|
|
51
|
+
unshare ID SHARE_ID [--request-id ID]
|
|
49
52
|
retry REQUEST_ID
|
|
50
53
|
|
|
51
54
|
Mutation command files contain the complete create, update, or publish JSON.
|
|
@@ -75,6 +78,7 @@ const commandOptions = {
|
|
|
75
78
|
update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
|
|
76
79
|
edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
|
|
77
80
|
publish: ['revision', 'expected-version', 'request-id', 'command'],
|
|
81
|
+
share: ['email', 'notify', 'request-id'], shares: [], unshare: ['request-id'],
|
|
78
82
|
export: ['revision', 'out'], move: ['project', 'no-project', 'request-id'], retry: [],
|
|
79
83
|
skills: ['full', 'all', 'agent', 'global', 'force'],
|
|
80
84
|
};
|
|
@@ -112,7 +116,7 @@ async function main() {
|
|
|
112
116
|
summary: { type: 'boolean' }, out: { type: 'string' }, quiet: { type: 'boolean' },
|
|
113
117
|
full: { type: 'boolean' }, all: { type: 'boolean' }, agent: { type: 'string' },
|
|
114
118
|
global: { type: 'boolean' }, force: { type: 'boolean' },
|
|
115
|
-
project: { type: 'string' }, 'no-project': { type: 'boolean' },
|
|
119
|
+
project: { type: 'string' }, 'no-project': { type: 'boolean' }, email: { type: 'string' }, notify: { type: 'boolean' },
|
|
116
120
|
},
|
|
117
121
|
});
|
|
118
122
|
const seen = new Set();
|
|
@@ -255,11 +259,18 @@ async function main() {
|
|
|
255
259
|
throw new UsageError('Specify exactly one of --project NAME or --no-project');
|
|
256
260
|
return mutate({ kind: 'move', command: moveSchema.parse({ artifactId, requestId: values['request-id'] ?? randomUUID(), project: values.project ?? null }) });
|
|
257
261
|
}
|
|
262
|
+
case 'share': return mutate({ kind: 'share', command: shareSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), email: required('email'), ...(values.notify === undefined ? {} : { notify: values.notify }) }) });
|
|
263
|
+
case 'shares': return client.listShares(listSharesSchema.parse({ artifactId: argument() }));
|
|
264
|
+
case 'unshare': {
|
|
265
|
+
if (arguments_.length !== 2)
|
|
266
|
+
throw new UsageError('unshare requires two positional arguments');
|
|
267
|
+
return mutate({ kind: 'unshare', command: unshareSchema.parse({ artifactId: arguments_[0], shareId: arguments_[1], requestId: values['request-id'] ?? randomUUID() }) });
|
|
268
|
+
}
|
|
258
269
|
case 'retry': {
|
|
259
270
|
const requestId = argument();
|
|
260
271
|
const owner = await client.identity();
|
|
261
272
|
const operation = await loadOperation(stateDir, requestId, resource, owner.userId);
|
|
262
|
-
process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}.
|
|
273
|
+
process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}. Replay confirms the original operation and does not establish current state.\n`);
|
|
263
274
|
return submitOperation(client, operation);
|
|
264
275
|
}
|
|
265
276
|
}
|
package/src/operations.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
2
|
+
import { createSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
3
3
|
import type { ArtifactHttpClient } from '@yargote/artifacts-client';
|
|
4
4
|
export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
5
5
|
kind: z.ZodLiteral<"create">;
|
|
@@ -16,6 +16,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
16
16
|
text: "text";
|
|
17
17
|
html: "html";
|
|
18
18
|
markdown: "markdown";
|
|
19
|
+
design: "design";
|
|
19
20
|
}>;
|
|
20
21
|
text: z.ZodString;
|
|
21
22
|
}, z.core.$strict>;
|
|
@@ -55,6 +56,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
55
56
|
text: "text";
|
|
56
57
|
html: "html";
|
|
57
58
|
markdown: "markdown";
|
|
59
|
+
design: "design";
|
|
58
60
|
}>;
|
|
59
61
|
text: z.ZodString;
|
|
60
62
|
}, z.core.$strict>;
|
|
@@ -97,6 +99,28 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
97
99
|
version: z.ZodLiteral<1>;
|
|
98
100
|
resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
99
101
|
ownerId: z.ZodString;
|
|
102
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
103
|
+
kind: z.ZodLiteral<"share">;
|
|
104
|
+
command: z.ZodObject<{
|
|
105
|
+
requestId: z.ZodString;
|
|
106
|
+
artifactId: z.ZodString;
|
|
107
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
108
|
+
notify: z.ZodOptional<z.ZodBoolean>;
|
|
109
|
+
requireNew: z.ZodOptional<z.ZodLiteral<true>>;
|
|
110
|
+
}, z.core.$strict>;
|
|
111
|
+
version: z.ZodLiteral<1>;
|
|
112
|
+
resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
113
|
+
ownerId: z.ZodString;
|
|
114
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
115
|
+
kind: z.ZodLiteral<"unshare">;
|
|
116
|
+
command: z.ZodObject<{
|
|
117
|
+
requestId: z.ZodString;
|
|
118
|
+
artifactId: z.ZodString;
|
|
119
|
+
shareId: z.ZodString;
|
|
120
|
+
}, z.core.$strict>;
|
|
121
|
+
version: z.ZodLiteral<1>;
|
|
122
|
+
resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
123
|
+
ownerId: z.ZodString;
|
|
100
124
|
}, z.core.$strict>], "kind">;
|
|
101
125
|
export type PreparedOperation = z.infer<typeof operationSchema>;
|
|
102
126
|
export type Mutation = {
|
|
@@ -111,11 +135,33 @@ export type Mutation = {
|
|
|
111
135
|
} | {
|
|
112
136
|
kind: 'move';
|
|
113
137
|
command: z.input<typeof moveSchema>;
|
|
138
|
+
} | {
|
|
139
|
+
kind: 'share';
|
|
140
|
+
command: z.input<typeof shareSchema>;
|
|
141
|
+
} | {
|
|
142
|
+
kind: 'unshare';
|
|
143
|
+
command: z.input<typeof unshareSchema>;
|
|
114
144
|
};
|
|
115
145
|
export declare function operationPath(stateDir: string, resource: string, requestId: string): string;
|
|
116
146
|
export declare function retainOperation(stateDir: string, resource: string, ownerId: string, mutation: Mutation): Promise<PreparedOperation>;
|
|
117
147
|
export declare function loadOperation(stateDir: string, requestId: string, resource: string, ownerId: string): Promise<PreparedOperation>;
|
|
118
|
-
export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<{
|
|
148
|
+
export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<void> | Promise<{
|
|
149
|
+
shareId: string;
|
|
150
|
+
artifactId: string;
|
|
151
|
+
kind: "email";
|
|
152
|
+
value: string;
|
|
153
|
+
createdAt: string;
|
|
154
|
+
notification?: {
|
|
155
|
+
status: "requested";
|
|
156
|
+
} | {
|
|
157
|
+
status: "skipped";
|
|
158
|
+
reason: "disabled";
|
|
159
|
+
} | {
|
|
160
|
+
status: "skipped";
|
|
161
|
+
reason: "rate_limited";
|
|
162
|
+
retryAt: string;
|
|
163
|
+
} | undefined;
|
|
164
|
+
}> | Promise<{
|
|
119
165
|
artifactId: string;
|
|
120
166
|
project: string | null;
|
|
121
167
|
}> | Promise<{
|
package/src/operations.js
CHANGED
|
@@ -3,7 +3,7 @@ import { lstat, mkdir, open } from 'node:fs/promises';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
6
|
+
import { createSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
|
|
7
7
|
import { canonicalResource } from '@yargote/artifacts-contracts/http';
|
|
8
8
|
const binding = {
|
|
9
9
|
version: z.literal(1),
|
|
@@ -15,6 +15,8 @@ export const operationSchema = z.discriminatedUnion('kind', [
|
|
|
15
15
|
z.strictObject({ ...binding, kind: z.literal('update'), command: updateSchema }),
|
|
16
16
|
z.strictObject({ ...binding, kind: z.literal('publish'), command: publishSchema }),
|
|
17
17
|
z.strictObject({ ...binding, kind: z.literal('move'), command: moveSchema }),
|
|
18
|
+
z.strictObject({ ...binding, kind: z.literal('share'), command: shareSchema }),
|
|
19
|
+
z.strictObject({ ...binding, kind: z.literal('unshare'), command: unshareSchema }),
|
|
18
20
|
]);
|
|
19
21
|
export function operationPath(stateDir, resource, requestId) {
|
|
20
22
|
createSchema.shape.requestId.parse(requestId);
|
|
@@ -88,5 +90,9 @@ export function submitOperation(client, operation) {
|
|
|
88
90
|
case 'update': return client.update(operation.command);
|
|
89
91
|
case 'publish': return client.publish(operation.command);
|
|
90
92
|
case 'move': return client.move(operation.command);
|
|
93
|
+
case 'share': return client.share(operation.command);
|
|
94
|
+
case 'unshare': return client.unshare(operation.command);
|
|
91
95
|
}
|
|
96
|
+
const exhaustive = operation;
|
|
97
|
+
throw new Error(`Unsupported retained operation: ${exhaustive}`);
|
|
92
98
|
}
|