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 {lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs';
import {basename, 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 {}
const VALUE_ARGUMENTS = ['--url', '--contract', '--contracts', '--browser', '--out', '--out-dir'];
function normalizeArgs(argv) {
return argv.flatMap((arg) => {
const flag = VALUE_ARGUMENTS.find((candidate) => arg.startsWith(`${candidate}=`));
return flag ? [flag, arg.slice(flag.length + 1)] : [arg];
});
}
function parseArgs(argv) {
const out = {browser: 'chromium', output: null, outputDir: null, allowRemote: false, validateOnly: false};
const errors = [];
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', '--contracts', '--browser', '--out', '--out-dir'].includes(arg)) {
if (!argv[i + 1] || argv[i + 1].startsWith('--')) {
errors.push(`${arg} requires a value`);
continue;
}
const key = arg === '--out' ? 'output' : arg === '--out-dir' ? 'outputDir' : arg.slice(2);
out[key] = argv[++i];
} else {
errors.push(`unknown argument: ${arg}`);
}
}
if (!!out.contract === !!out.contracts) errors.push('provide exactly one of --contract or --contracts');
if (out.contract && !out.contracts) {
if (out.outputDir) errors.push('--out-dir is only valid with --contracts');
out.output ||= 'live-interaction-contracts-result.json';
} else if (out.contracts) {
if (out.output) errors.push('--out is only valid with --contract');
if (!out.outputDir) errors.push('--out-dir is required with --contracts');
}
out.argumentErrors = errors;
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 discoverSuite(path) {
const absolute = resolve(path);
let stat;
try { stat = lstatSync(absolute); } catch { throw new ContractError(`contracts directory does not exist: ${absolute}`); }
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new ContractError(`--contracts must be a non-symlink directory: ${absolute}`);
const names = readdirSync(absolute, {withFileTypes: true})
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name)
.sort();
if (names.length === 0) throw new ContractError(`--contracts contains no first-level *.json files: ${absolute}`);
return {absolute: realpathSync(absolute), names, stat};
}
function prepareSuite(args) {
const discovered = discoverSuite(args.contracts);
let outputDir = resolve(args.outputDir);
let outputExists = false;
let outputStat = null;
try {
outputStat = lstatSync(outputDir);
outputExists = true;
if (outputStat.isSymbolicLink() || !outputStat.isDirectory()) throw new ContractError(`--out-dir must be a non-symlink directory: ${outputDir}`);
outputDir = realpathSync(outputDir);
} catch (error) {
if (error instanceof ContractError) throw error;
if (error?.code !== 'ENOENT') throw error;
}
if (outputExists && outputStat.dev === discovered.stat.dev && outputStat.ino === discovered.stat.ino) {
throw new ContractError('--out-dir must not be the --contracts directory, including filesystem aliases');
}
const entries = discovered.names.map((name) => ({
name,
contractPath: resolve(discovered.absolute, name),
outputPath: resolve(outputDir, `${name.slice(0, -'.json'.length)}.result.json`),
}));
const expectedOutputs = new Set(entries.map(({outputPath}) => outputPath));
if (expectedOutputs.size !== entries.length) throw new ContractError('suite output paths collide');
let unknownResults = [];
if (outputExists) {
unknownResults = readdirSync(outputDir, {withFileTypes: true})
.filter((entry) => entry.name.endsWith('.result.json'))
.map((entry) => resolve(outputDir, entry.name))
.filter((path) => !expectedOutputs.has(path))
.sort();
const invalidationErrors = [];
for (const {outputPath} of entries) {
let stat;
try { stat = lstatSync(outputPath); } catch (error) {
if (error?.code === 'ENOENT') continue;
invalidationErrors.push(`${outputPath}: ${error.message}`);
continue;
}
if (stat.isDirectory()) {
invalidationErrors.push(`mapped suite output must not be a directory: ${outputPath}`);
continue;
}
try { rmSync(outputPath, {force: true}); } catch (error) { invalidationErrors.push(`${outputPath}: ${error.message}`); }
}
if (invalidationErrors.length) throw new ContractError(`could not invalidate all mapped suite outputs:\n - ${invalidationErrors.join('\n - ')}`);
}
if (unknownResults.length) {
throw new ContractError(`--out-dir contains stale result file(s) outside this suite; remove them first: ${unknownResults.map((path) => basename(path)).join(', ')}`);
}
const prepared = [];
const errors = [...args.argumentErrors];
const ids = new Map();
if (!args.url) errors.push('--url is required');
if (!['chromium', 'firefox', 'webkit'].includes(args.browser)) errors.push(`unsupported browser: ${args.browser}`);
for (const entry of entries) {
try {
const {contract} = loadContract(entry.contractPath);
const target = args.url ? targetUrl(args.url, contract.route, args.allowRemote) : null;
if (ids.has(contract.id)) {
errors.push(`${entry.name}: duplicate contract.id ${JSON.stringify(contract.id)} (also in ${ids.get(contract.id)})`);
} else {
ids.set(contract.id, entry.name);
}
prepared.push({...entry, contract, target});
} catch (error) {
errors.push(`${entry.name}: ${error.message}`);
}
}
if (errors.length) throw new ContractError(`suite preflight failed:\n - ${errors.join('\n - ')}`);
return {contractsDir: discovered.absolute, outputDir, entries: prepared};
}
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}`;
try {
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n');
renameSync(temporary, absolute);
} catch (error) {
try { rmSync(temporary, {force: true}); } catch (cleanupError) {
error.message = `${error.message}; temporary cleanup also failed: ${cleanupError.message}`;
}
throw error;
}
rmSync(temporary, {force: true});
}
function removeSuiteEvidence(entries) {
for (const {outputPath} of entries) {
for (const path of [outputPath, `${outputPath}.tmp-${process.pid}`]) {
try {
const stat = lstatSync(path);
if (!stat.isDirectory()) rmSync(path, {force: true});
} catch (error) {
if (error?.code !== 'ENOENT') console.error(`! failed to remove partial evidence ${path}: ${error.message}`);
}
}
}
}
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?.activation?.status && result.phases.activation.status !== 'pass') {
console.log(` phase: activation — ${result.phases.activation.status}`);
}
if (result.phases?.patch_ack?.status && result.phases.patch_ack.status !== 'pass') {
console.log(` phase: patch_ack — ${result.phases.patch_ack.status}`);
}
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 || []) {
const selector = observation.selector || observation.before?.selector;
console.log(` ${observation.pass ? '✓' : '✗'} ${observation.probe}${selector ? ` — ${selector}` : ''}`);
if (observation.pass) console.log(' expectation met');
else for (const mismatch of observation.mismatches) console.log(` ${mismatch}`);
}
for (const diagnostic of result.diagnostics || []) console.log(` ! ${diagnostic.code}: ${diagnostic.message}`);
if (output) console.log(` evidence: ${resolve(output)}`);
}
function printSuiteSummary(results) {
const counts = Object.fromEntries(Object.keys(EXIT).map((verdict) => [verdict, results.filter((result) => result.verdict === verdict).length]));
console.log(`\nSuite summary — ${results.length} contract(s): ${counts.pass} pass, ${counts.fail} fail, ${counts.inconclusive} inconclusive, ${counts.error} error`);
for (const result of results) console.log(` ${result.verdict.toUpperCase().padEnd(12)} ${result.contract.id}`);
}
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 = normalizeArgs(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);
if (args.contract && !args.contracts) {
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});
if (args.argumentErrors.length) throw new ContractError(args.argumentErrors.join('; '));
if (!args.url) throw new ContractError('--url is required');
if (!['chromium', 'firefox', 'webkit'].includes(args.browser)) throw new ContractError(`unsupported browser: ${args.browser}`);
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);
} else if (args.contracts && args.outputDir) {
outputPath = null;
const suite = prepareSuite(args);
if (args.validateOnly) {
for (const entry of suite.entries) console.log(`✓ valid contract v1: ${entry.contract.id}${entry.target.href}`);
console.log(`\nSuite preflight valid — ${suite.entries.length} contract(s) in ${suite.contractsDir}`);
process.exit(0);
}
mkdirSync(suite.outputDir, {recursive: true});
const results = [];
for (const entry of suite.entries) {
const contractResult = await execute(args, entry.contract, entry.target);
results.push(contractResult);
}
try {
for (const [index, entry] of suite.entries.entries()) atomicWrite(entry.outputPath, results[index]);
} catch (error) {
removeSuiteEvidence(suite.entries);
throw new Error(`failed to write complete suite evidence: ${error.message}`);
}
for (const [index, entry] of suite.entries.entries()) printResult(results[index], entry.outputPath);
printSuiteSummary(results);
process.exit(Math.max(...results.map(({verdict}) => EXIT[verdict])));
} else {
throw new ContractError(args.argumentErrors.join('; ') || 'invalid contract runner arguments');
}
} catch (error) {
if (args?.contracts || hinted('--contracts')) {
console.error(`\n! contract suite — ERROR\n ! CONTRACT_ERROR: ${error.message}`);
process.exit(EXIT.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]);