Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts priv contract-runner check.mjs
Raw

priv/contract-runner/check.mjs

import {createRequire} from 'node:module';
import {mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs';
import {dirname, isAbsolute, resolve} from 'node:path';
const RESULT_SCHEMA_VERSION = 1;
const PLAYWRIGHT_VERSION = '1.61.1';
const MAX_CONTRACT_BYTES = 256 * 1024;
const DEFAULT_TIMEOUT = 8_000;
const EXIT = {pass: 0, fail: 1, inconclusive: 2, error: 3};
class ContractError extends Error {}
class InconclusiveError extends Error {}
class SelectorMatchError extends Error {}
function parseArgs(argv) {
const out = {browser: 'chromium', output: 'live-interaction-contracts-result.json', allowRemote: false, validateOnly: false};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--allow-remote') out.allowRemote = true;
else if (arg === '--validate-only') out.validateOnly = true;
else if (['--url', '--contract', '--browser', '--out'].includes(arg)) {
if (!argv[i + 1]) throw new ContractError(`${arg} requires a value`);
const key = arg === '--out' ? 'output' : arg.slice(2);
out[key] = argv[++i];
} else {
throw new ContractError(`unknown argument: ${arg}`);
}
}
if (!out.url) throw new ContractError('--url is required');
if (!out.contract) throw new ContractError('--contract is required');
if (!['chromium', 'firefox', 'webkit'].includes(out.browser)) throw new ContractError(`unsupported browser: ${out.browser}`);
return out;
}
function exactKeys(value, allowed, path) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ContractError(`${path} must be an object`);
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length) throw new ContractError(`${path} has unknown field(s): ${unknown.join(', ')}`);
}
function string(value, path) {
if (typeof value !== 'string' || value.trim() === '') throw new ContractError(`${path} must be a non-empty string`);
}
function booleanObject(value, fields, path) {
exactKeys(value, fields, path);
if (Object.keys(value).length === 0) throw new ContractError(`${path} must assert at least one field`);
for (const [key, field] of Object.entries(value)) {
if (typeof field !== 'boolean') throw new ContractError(`${path}.${key} must be boolean`);
}
}
function clickAction(value, path) {
exactKeys(value, ['type', 'selector'], path);
if (value.type !== 'click') throw new ContractError(`${path}.type must be "click"`);
string(value.selector, `${path}.selector`);
}
function patchAction(value, path) {
if (value?.type === 'click') return clickAction(value, path);
exactKeys(value, ['type', 'selector', 'key'], path);
if (value.type !== 'press') throw new ContractError(`${path}.type must be "click" or "press"`);
string(value.selector, `${path}.selector`);
if (!['Enter', 'Space'].includes(value.key)) throw new ContractError(`${path}.key must be "Enter" or "Space"`);
}
function activationAction(value, path) {
exactKeys(value, ['type', 'selector'], path);
if (!['click', 'hover', 'scroll'].includes(value.type)) throw new ContractError(`${path}.type must be "click", "hover", or "scroll"`);
string(value.selector, `${path}.selector`);
}
function attributeValueObject(value, path) {
exactKeys(value, ['value'], path);
if (!Object.hasOwn(value, 'value')) throw new ContractError(`${path} must assert value`);
if (value.value !== null && typeof value.value !== 'string') throw new ContractError(`${path}.value must be a string or null`);
}
function validateContract(contract) {
exactKeys(contract, ['schema_version', 'id', 'route', 'subject', 'activate', 'patch', 'expectations', 'metadata'], 'contract');
if (contract.schema_version !== 1) throw new ContractError('contract.schema_version must be 1');
string(contract.id, 'contract.id');
string(contract.route, 'contract.route');
if (!contract.route.startsWith('/') || contract.route.startsWith('//')) throw new ContractError('contract.route must be a same-origin relative route beginning with one slash');
exactKeys(contract.subject, ['selector'], 'contract.subject');
string(contract.subject.selector, 'contract.subject.selector');
if (!Array.isArray(contract.activate) || contract.activate.length === 0) throw new ContractError('contract.activate must be a non-empty array');
contract.activate.forEach((action, index) => activationAction(action, `contract.activate[${index}]`));
exactKeys(contract.patch, ['trigger', 'ack'], 'contract.patch');
patchAction(contract.patch.trigger, 'contract.patch.trigger');
exactKeys(contract.patch.ack, ['type', 'selector', 'attribute'], 'contract.patch.ack');
if (contract.patch.ack.type !== 'attribute_change') throw new ContractError('contract.patch.ack.type must be "attribute_change"');
string(contract.patch.ack.selector, 'contract.patch.ack.selector');
string(contract.patch.ack.attribute, 'contract.patch.ack.attribute');
if (!Array.isArray(contract.expectations) || contract.expectations.length === 0) throw new ContractError('contract.expectations must be a non-empty array');
for (const [index, expectation] of contract.expectations.entries()) {
const path = `contract.expectations[${index}]`;
if (!expectation || typeof expectation !== 'object' || Array.isArray(expectation)) throw new ContractError(`${path} must be an object`);
if (expectation.probe === 'node_identity') {
exactKeys(expectation, ['probe', 'selector', 'after'], path);
if (expectation.selector !== undefined) string(expectation.selector, `${path}.selector`);
if (expectation.after !== 'same') throw new ContractError(`${path}.after must be "same"`);
} else if (expectation.probe === 'attribute') {
exactKeys(expectation, ['probe', 'selector', 'attribute', 'before', 'after'], path);
string(expectation.selector, `${path}.selector`);
string(expectation.attribute, `${path}.attribute`);
attributeValueObject(expectation.before, `${path}.before`);
attributeValueObject(expectation.after, `${path}.after`);
} else if (expectation.probe === 'popover') {
exactKeys(expectation, ['probe', 'selector', 'before', 'after'], path);
if (expectation.selector !== undefined) string(expectation.selector, `${path}.selector`);
booleanObject(expectation.before, ['open'], `${path}.before`);
booleanObject(expectation.after, ['open'], `${path}.after`);
} else if (expectation.probe === 'dialog') {
exactKeys(expectation, ['probe', 'selector', 'before', 'after'], path);
if (expectation.selector !== undefined) string(expectation.selector, `${path}.selector`);
booleanObject(expectation.before, ['open', 'open_attribute', 'modal'], `${path}.before`);
booleanObject(expectation.after, ['open', 'open_attribute', 'modal'], `${path}.after`);
} else if (expectation.probe === 'focus') {
exactKeys(expectation, ['probe', 'selector', 'before', 'after'], path);
string(expectation.selector, `${path}.selector`);
booleanObject(expectation.before, ['active'], `${path}.before`);
booleanObject(expectation.after, ['active'], `${path}.after`);
} else {
throw new ContractError(`${path}.probe is unsupported: ${expectation.probe}`);
}
}
if (contract.metadata !== undefined) {
exactKeys(contract.metadata, ['live_view', 'app_version', 'commit'], 'contract.metadata');
for (const [key, value] of Object.entries(contract.metadata)) string(value, `contract.metadata.${key}`);
}
return contract;
}
function loadContract(path) {
const absolute = resolve(path);
let stat;
try { stat = statSync(absolute); } catch { throw new ContractError(`contract file does not exist: ${absolute}`); }
if (!stat.isFile()) throw new ContractError(`contract path is not a regular file: ${absolute}`);
if (stat.size > MAX_CONTRACT_BYTES) throw new ContractError(`contract exceeds ${MAX_CONTRACT_BYTES} bytes`);
let parsed;
try { parsed = JSON.parse(readFileSync(absolute, 'utf8')); } catch (error) { throw new ContractError(`invalid contract JSON: ${error.message}`); }
return {absolute, contract: validateContract(parsed)};
}
function targetUrl(base, route, allowRemote) {
let baseUrl;
try { baseUrl = new URL(base); } catch { throw new ContractError('--url must be an absolute http(s) URL'); }
if (!['http:', 'https:'].includes(baseUrl.protocol)) throw new ContractError('--url must use http or https');
if (baseUrl.username || baseUrl.password) throw new ContractError('--url must not contain credentials');
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(baseUrl.hostname);
if (!loopback && !allowRemote) throw new ContractError('remote targets are refused by default; use --allow-remote only for an explicitly safe test environment');
const target = new URL(route, baseUrl);
if (target.origin !== baseUrl.origin) throw new ContractError('contract.route must remain on the --url origin');
return target;
}
function playwright() {
const attempts = [createRequire(resolve(process.cwd(), 'package.json')), createRequire(import.meta.url)];
let versionError = null;
for (const require of attempts) {
try {
const version = require('playwright/package.json').version;
if (version !== PLAYWRIGHT_VERSION) {
versionError = new Error(`Playwright ${version} is installed, but this runner is tested and pinned to ${PLAYWRIGHT_VERSION}.`);
continue;
}
return {api: require('playwright'), version};
} catch {}
}
if (versionError) throw versionError;
throw new Error(`Playwright ${PLAYWRIGHT_VERSION} is not installed. Run \`npm install --save-dev playwright@${PLAYWRIGHT_VERSION}\` and \`npx playwright install chromium\`.`);
}
async function uniqueLocator(page, selector, path) {
let count;
try { count = await page.evaluate((css) => document.querySelectorAll(css).length, selector); }
catch (error) { throw new ContractError(`${path} is not valid CSS: ${error.message}`); }
if (count !== 1) throw new SelectorMatchError(`${path} must match exactly one element; matched ${count}: ${selector}`);
return page.locator(selector);
}
function remainingTimeout(deadline) {
return Math.max(1, deadline - Date.now());
}
async function eventuallyUniqueLocator(page, selector, path, deadline = Date.now() + DEFAULT_TIMEOUT) {
let lastError;
while (Date.now() <= deadline) {
try { return await uniqueLocator(page, selector, path); }
catch (error) {
if (!(error instanceof SelectorMatchError)) throw error;
lastError = error;
}
await page.waitForTimeout(50);
}
throw lastError;
}
async function click(page, action, path) {
const deadline = Date.now() + DEFAULT_TIMEOUT;
const locator = await eventuallyUniqueLocator(page, action.selector, `${path}.selector`, deadline);
await locator.click({timeout: remainingTimeout(deadline)});
}
async function triggerPatch(page, action, path) {
if (action.type === 'click') return click(page, action, path);
const deadline = Date.now() + DEFAULT_TIMEOUT;
const locator = await eventuallyUniqueLocator(page, action.selector, `${path}.selector`, deadline);
const target = await locator.elementHandle({timeout: remainingTimeout(deadline)});
const focused = await page.evaluate((element) => {
element.focus({preventScroll: true});
return document.activeElement === element;
}, target);
if (!focused) throw new InconclusiveError(`patch press target could not receive exact focus: ${action.selector}`);
await page.keyboard.press(action.key, {timeout: remainingTimeout(deadline)});
}
async function activate(page, action, path) {
const deadline = Date.now() + DEFAULT_TIMEOUT;
const locator = await eventuallyUniqueLocator(page, action.selector, `${path}.selector`, deadline);
const timeout = remainingTimeout(deadline);
if (action.type === 'click') await locator.click({timeout});
else if (action.type === 'hover') await locator.hover({timeout});
else await locator.scrollIntoViewIfNeeded({timeout});
}
async function observe(page, expectation, subjectSelector) {
const selector = expectation.selector || subjectSelector;
await uniqueLocator(page, selector, `${expectation.probe} probe selector`);
if (expectation.probe === 'popover') {
return page.evaluate((css) => ({open: document.querySelector(css).matches(':popover-open')}), selector);
}
if (expectation.probe === 'dialog') {
return page.evaluate((css) => {
const dialog = document.querySelector(css);
return {open: dialog.open, open_attribute: dialog.hasAttribute('open'), modal: dialog.matches(':modal')};
}, selector);
}
if (expectation.probe === 'focus') {
return page.evaluate((css) => ({active: document.activeElement === document.querySelector(css)}), selector);
}
if (expectation.probe === 'attribute') {
return page.evaluate(({css, attribute}) => ({value: document.querySelector(css).getAttribute(attribute)}), {css: selector, attribute: expectation.attribute});
}
throw new Error(`cannot observe probe: ${expectation.probe}`);
}
function mismatches(actual, expected, prefix) {
return Object.entries(expected)
.filter(([key, value]) => actual?.[key] !== value)
.map(([key, value]) => `${prefix}.${key}: expected ${JSON.stringify(value)}, observed ${JSON.stringify(actual?.[key])}`);
}
async function observeExpectedPhase(page, expectations, subjectSelector, phase) {
const deadline = Date.now() + DEFAULT_TIMEOUT;
let finalSamples = [];
while (Date.now() <= deadline) {
const samples = [];
for (const expectation of expectations) {
try {
const state = await observe(page, expectation, subjectSelector);
samples.push({expectation, state, matchError: null, mismatches: mismatches(state, expectation[phase], `${expectation.probe}.${phase}`)});
} catch (error) {
if (!(error instanceof SelectorMatchError)) throw error;
samples.push({expectation, state: null, matchError: error, mismatches: [`${expectation.probe}.${phase}: probe target missing or non-unique: ${error.message}`]});
}
}
finalSamples = samples;
if (samples.every((sample) => sample.mismatches.length === 0)) return samples;
await page.waitForTimeout(50);
}
return finalSamples;
}
function atomicWrite(path, value) {
const absolute = isAbsolute(path) ? path : resolve(path);
mkdirSync(dirname(absolute), {recursive: true});
const temporary = `${absolute}.tmp-${process.pid}`;
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n');
renameSync(temporary, absolute);
}
function assertAllowedPage(page, target, stage) {
const current = new URL(page.url());
if (current.origin !== target.origin) throw new Error(`${stage} navigated away from the allowed origin to ${current.origin}`);
}
function assertNoBlockedNavigation(blockedNavigation, stage) {
if (blockedNavigation.current) {
throw new Error(`${stage} attempted navigation away from the allowed origin to ${blockedNavigation.current}`);
}
}
function printResult(result, output) {
const mark = result.verdict === 'pass' ? '✓' : result.verdict === 'fail' ? '✗' : '!';
console.log(`\n${mark} ${result.contract?.id || 'contract'}${result.verdict.toUpperCase()}`);
if (result.phases?.patch_ack) console.log(` patch ack: ${JSON.stringify(result.phases.patch_ack.before)}${JSON.stringify(result.phases.patch_ack.after)}`);
for (const observation of result.observations || []) {
console.log(` ${observation.pass ? '✓' : '✗'} ${observation.probe}: ${observation.pass ? 'expectation met' : observation.mismatches.join('; ')}`);
}
for (const diagnostic of result.diagnostics || []) console.log(` ! ${diagnostic.code}: ${diagnostic.message}`);
if (output) console.log(` evidence: ${resolve(output)}`);
}
async function execute(args, contract, target) {
const result = {
result_schema_version: RESULT_SCHEMA_VERSION,
contract: {id: contract.id, schema_version: contract.schema_version, metadata: contract.metadata || {}},
runtime: {runner_version: process.env.LIC_RUNNER_VERSION || 'dev', playwright_version: null, browser: args.browser, browser_version: null},
target: {origin: target.origin, route: target.pathname, query_present: target.search !== ''},
phases: {activation: {status: 'pending'}, patch_ack: {status: 'pending'}},
observations: [],
diagnostics: [],
verdict: 'error',
};
let browser;
try {
const loaded = playwright();
result.runtime.playwright_version = loaded.version;
const launcher = loaded.api[args.browser];
browser = await launcher.launch();
result.runtime.browser_version = browser.version();
const context = await browser.newContext();
let unexpectedPopup = null;
let mainPage = null;
const blockedNavigation = {current: null};
context.on('page', async (popup) => {
if (!mainPage || popup === mainPage) return;
unexpectedPopup = popup.url() || 'about:blank';
await popup.close().catch(() => {});
});
const page = await context.newPage();
mainPage = page;
await context.route('**/*', async (route) => {
const request = route.request();
if (request.isNavigationRequest()) {
const requested = new URL(request.url());
if (requested.origin !== target.origin) {
blockedNavigation.current = request.url();
return route.abort('blockedbyclient');
}
}
return route.continue();
});
page.setDefaultTimeout(DEFAULT_TIMEOUT);
await page.goto(target.href, {waitUntil: 'domcontentloaded', timeout: DEFAULT_TIMEOUT});
assertAllowedPage(page, target, 'initial navigation');
for (const [index, action] of contract.activate.entries()) {
try {
await activate(page, action, `activate[${index}]`);
} catch (error) {
assertNoBlockedNavigation(blockedNavigation, `activation step ${index}`);
if (error instanceof SelectorMatchError || error.name === 'TimeoutError') {
result.phases.activation.status = 'inconclusive';
throw new InconclusiveError(`activation did not complete: ${error.message}`);
}
result.phases.activation.status = 'error';
throw error;
}
assertNoBlockedNavigation(blockedNavigation, `activation step ${index}`);
assertAllowedPage(page, target, `activation step ${index}`);
if (unexpectedPopup) throw new Error(`activation opened an unexpected page: ${unexpectedPopup}`);
}
const probeExpectations = contract.expectations.filter((expectation) => expectation.probe !== 'node_identity');
const before = await observeExpectedPhase(page, probeExpectations, contract.subject.selector, 'before');
const preconditionMismatches = before.flatMap((sample) => sample.mismatches);
if (preconditionMismatches.length) {
result.phases.activation.status = 'inconclusive';
throw new InconclusiveError(`activation precondition failed: ${preconditionMismatches.join('; ')}`);
}
const identityExpectations = contract.expectations.filter(({probe}) => probe === 'node_identity');
const identityDeadline = Date.now() + DEFAULT_TIMEOUT;
for (const expectation of identityExpectations) {
const selector = expectation.selector || contract.subject.selector;
try {
await eventuallyUniqueLocator(page, selector, 'node_identity probe selector', identityDeadline);
} catch (error) {
if (!(error instanceof SelectorMatchError)) throw error;
result.phases.activation.status = 'inconclusive';
throw new InconclusiveError(`activation produced no unique node_identity probe target: ${error.message}`);
}
}
let subjectLocator;
try {
subjectLocator = await uniqueLocator(page, contract.subject.selector, 'subject.selector');
} catch (error) {
if (!(error instanceof SelectorMatchError)) throw error;
result.phases.activation.status = 'inconclusive';
throw new InconclusiveError(`activation produced no unique subject: ${error.message}`);
}
const subjectBefore = await subjectLocator.elementHandle();
const ack = contract.patch.ack;
const ackLocator = await uniqueLocator(page, ack.selector, 'patch.ack.selector');
const ackInsideSubject = await page.evaluate(([subject, marker]) => subject === marker || subject.contains(marker), [subjectBefore, await ackLocator.elementHandle()]);
if (!ackInsideSubject) throw new ContractError('patch.ack selector must be the subject or its descendant');
const ackBefore = await ackLocator.getAttribute(ack.attribute);
result.phases.patch_ack.before = ackBefore;
const identityBefore = new Map();
for (const expectation of identityExpectations) {
const selector = expectation.selector || contract.subject.selector;
identityBefore.set(expectation, expectation.selector
? await (await uniqueLocator(page, selector, 'node_identity probe selector')).elementHandle()
: subjectBefore);
}
result.phases.activation.status = 'pass';
try {
await triggerPatch(page, contract.patch.trigger, 'patch.trigger');
} catch (error) {
if (error instanceof InconclusiveError) {
result.phases.patch_ack.status = 'inconclusive';
throw new InconclusiveError(`patch press did not complete: ${error.message}`);
}
throw error;
}
assertNoBlockedNavigation(blockedNavigation, 'patch trigger');
assertAllowedPage(page, target, 'patch trigger');
if (unexpectedPopup) throw new Error(`patch trigger opened an unexpected page: ${unexpectedPopup}`);
try {
await page.waitForFunction(
({selector, attribute, before}) => {
const element = document.querySelector(selector);
return !!element && element.getAttribute(attribute) !== before;
},
{selector: ack.selector, attribute: ack.attribute, before: ackBefore},
{timeout: DEFAULT_TIMEOUT},
);
} catch (error) {
if (error.name === 'TimeoutError') {
assertNoBlockedNavigation(blockedNavigation, 'patch trigger');
result.phases.patch_ack.status = 'inconclusive';
throw new InconclusiveError(`patch acknowledgment did not change ${ack.selector}[${ack.attribute}] within ${DEFAULT_TIMEOUT}ms`);
}
result.phases.patch_ack.status = 'error';
throw error;
}
assertNoBlockedNavigation(blockedNavigation, 'patch acknowledgment');
assertAllowedPage(page, target, 'patch acknowledgment');
let ackAfterLocator;
try {
ackAfterLocator = await uniqueLocator(page, ack.selector, 'patch.ack.selector after patch');
} catch (error) {
if (error instanceof SelectorMatchError) {
result.phases.patch_ack.status = 'inconclusive';
throw new InconclusiveError(`patch acknowledgment is not uniquely present after changing: ${error.message}`);
}
throw error;
}
const ackAfterHandle = await ackAfterLocator.elementHandle();
const ackAfter = await ackAfterLocator.getAttribute(ack.attribute);
if (ackAfter === ackBefore) {
result.phases.patch_ack.status = 'inconclusive';
throw new InconclusiveError(`patch acknowledgment reverted to its original value ${JSON.stringify(ackBefore)}`);
}
let subjectAfterLocator;
try {
subjectAfterLocator = await uniqueLocator(page, contract.subject.selector, 'subject.selector after patch');
} catch (error) {
if (error instanceof SelectorMatchError) {
result.phases.patch_ack.status = 'error';
throw new ContractError(`cannot prove post-patch acknowledgment locality: ${error.message}`);
}
throw error;
}
const subjectAfter = await subjectAfterLocator.elementHandle();
const ackInsideSubjectAfter = await page.evaluate(([subject, marker]) => subject === marker || subject.contains(marker), [subjectAfter, ackAfterHandle]);
if (!ackInsideSubjectAfter) throw new ContractError('post-patch acknowledgment selector must be the subject or its descendant');
result.phases.patch_ack.status = 'pass';
result.phases.patch_ack.after = ackAfter;
const identityAfter = new Map();
for (const expectation of identityExpectations) {
const selector = expectation.selector || contract.subject.selector;
try {
const current = await (await uniqueLocator(page, selector, 'node_identity probe selector after patch')).elementHandle();
const same = await page.evaluate(([previous, after]) => previous === after, [identityBefore.get(expectation), current]);
identityAfter.set(expectation, {same, matchError: null});
} catch (error) {
if (!(error instanceof SelectorMatchError)) throw error;
identityAfter.set(expectation, {same: false, matchError: error});
}
}
const after = await observeExpectedPhase(page, probeExpectations, contract.subject.selector, 'after');
for (const expectation of contract.expectations) {
if (expectation.probe === 'node_identity') {
const selector = expectation.selector || contract.subject.selector;
const {same: sameNode, matchError} = identityAfter.get(expectation);
const failures = matchError
? [`node_identity.after: probe target missing or non-unique after acknowledged patch: ${matchError.message}`]
: expectation.after === 'same' && !sameNode ? ['node_identity.after: expected same DOM object, observed replacement'] : [];
result.observations.push({probe: 'node_identity', before: {selector}, after: matchError ? null : {same: sameNode}, pass: failures.length === 0, mismatches: failures});
continue;
}
const beforeState = before.find((entry) => entry.expectation === expectation).state;
const afterSample = after.find((entry) => entry.expectation === expectation);
const afterState = afterSample.state;
const failures = afterSample.matchError
? [`${expectation.probe}.after: probe target missing or non-unique after acknowledged patch: ${afterSample.matchError.message}`]
: afterSample.mismatches;
result.observations.push({probe: expectation.probe, selector: expectation.selector || contract.subject.selector, before: beforeState, after: afterState, pass: failures.length === 0, mismatches: failures});
}
assertNoBlockedNavigation(blockedNavigation, 'completed interaction');
if (unexpectedPopup) throw new Error(`interaction opened an unexpected page: ${unexpectedPopup}`);
result.verdict = result.observations.every((observation) => observation.pass) ? 'pass' : 'fail';
if (result.verdict === 'fail') {
const dialog = result.observations.find((observation) => observation.probe === 'dialog')?.after;
if (dialog && dialog.open === false && dialog.open_attribute === false && dialog.modal === true) {
result.diagnostics.push({code: 'TORN_DIALOG_VECTOR', message: 'The same native dialog reports closed while still matching :modal. Inspect ownership of the reflected open attribute.'});
}
}
await context.close();
} catch (error) {
if (error instanceof InconclusiveError) {
result.verdict = 'inconclusive';
result.diagnostics.push({code: 'INCONCLUSIVE', message: error.message});
} else {
result.verdict = 'error';
if (result.phases.activation.status === 'pending') result.phases.activation.status = 'error';
else if (result.phases.patch_ack.status === 'pending') result.phases.patch_ack.status = 'error';
result.diagnostics.push({code: error instanceof ContractError ? 'CONTRACT_ERROR' : 'RUNNER_ERROR', message: error.message});
}
} finally {
if (browser) await browser.close().catch(() => {});
}
return result;
}
const rawArgv = process.argv.slice(2);
const hinted = (flag, fallback = null) => {
const index = rawArgv.indexOf(flag);
return index >= 0 && rawArgv[index + 1] ? rawArgv[index + 1] : fallback;
};
let args;
let outputPath = hinted('--out', 'live-interaction-contracts-result.json');
let result;
try {
args = parseArgs(rawArgv);
outputPath = args.output;
const contractPath = resolve(args.contract);
const absoluteOutput = resolve(outputPath);
if (contractPath === absoluteOutput) {
outputPath = null;
throw new ContractError('--out must not overwrite --contract');
}
rmSync(absoluteOutput, {force: true});
const {contract} = loadContract(args.contract);
const target = targetUrl(args.url, contract.route, args.allowRemote);
if (args.validateOnly) {
console.log(`✓ valid contract v1: ${contract.id}${target.href}`);
process.exit(0);
}
result = await execute(args, contract, target);
} catch (error) {
result = {
result_schema_version: RESULT_SCHEMA_VERSION,
contract: null,
runtime: {runner_version: process.env.LIC_RUNNER_VERSION || 'dev'},
target: null,
phases: {},
observations: [],
diagnostics: [{code: 'CONTRACT_ERROR', message: error.message}],
verdict: 'error',
};
}
if (outputPath) {
try {
const contractHint = hinted('--contract');
if (!contractHint || resolve(contractHint) !== resolve(outputPath)) atomicWrite(outputPath, result);
else console.error('! evidence not written because --out and --contract resolve to the same path');
} catch (error) {
console.error(`! failed to write evidence: ${error.message}`);
process.exit(3);
}
}
printResult(result, outputPath);
process.exit(EXIT[result.verdict]);