mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
[codex] Improve CI test coverage (#212)
* Improve CI test coverage * Stabilize live E2E harness * Shard live E2E CI * Cache live E2E CI dependencies * Stabilize live E2E smoke CI * Update generated live browser bundles * Tighten live E2E smoke runtime * Prevent live E2E smoke hangs * Stabilize live E2E CI coverage * Fix stale accept DOM cleanup * Regenerate live browser outputs
This commit is contained in:
+76
-27
@@ -1747,6 +1747,10 @@ export async function runAgentLoop({
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (completionType === 'agent_done' && acceptResult.handled === true && acceptResult.carbonize === true) {
|
||||
await runLiveComplete({ tmp, scriptsDir, id: event.id });
|
||||
log(`completed carbonize session ${event.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
log('accept failed: ' + err.message);
|
||||
@@ -1876,20 +1880,32 @@ export async function applySteerEdits(tmp, { file, edits }) {
|
||||
async function handleSteerDeterministic(context) {
|
||||
const { targetFileAbs, target } = context;
|
||||
let body = await fs.readFile(targetFileAbs, 'utf-8');
|
||||
const next = addSteerMarkerToSource(body, target);
|
||||
if (next === body) return;
|
||||
if (!next) {
|
||||
const { classes = 'hero-title', tag = 'h1' } = target;
|
||||
const classToken = classes.split(/\s+/)[0];
|
||||
throw new Error(`steer target <${tag}.${classToken}> not found in ${targetFileAbs}`);
|
||||
}
|
||||
body = next;
|
||||
await fs.writeFile(targetFileAbs, body, 'utf-8');
|
||||
}
|
||||
|
||||
export function addSteerMarkerToSource(body, target = { classes: 'hero-title', tag: 'h1' }) {
|
||||
const attr = `${STEER_MARKER_ATTR}="${STEER_MARKER_VALUE}"`;
|
||||
if (body.includes(attr)) return;
|
||||
if (body.includes(attr)) return body;
|
||||
|
||||
const { classes = 'hero-title', tag = 'h1' } = target;
|
||||
const classToken = classes.split(/\s+/)[0];
|
||||
const escapedTag = escapeRegExp(tag);
|
||||
const escapedClass = escapeRegExp(classToken);
|
||||
const classValue = `(?:["'][^"']*\\b${escapedClass}\\b[^"']*["']|\\{[^}]*\\b${escapedClass}\\b[^}]*\\})`;
|
||||
const openTagRe = new RegExp(
|
||||
`(<${tag}\\b(?=[^>]*\\b(?:className|class)=["'][^"']*\\b${classToken}\\b)[^>]*)(>)`,
|
||||
`(<${escapedTag}\\b(?=[^>]*\\b(?:className|class)\\s*=\\s*${classValue})[^>]*)(>)`,
|
||||
'i',
|
||||
);
|
||||
if (!openTagRe.test(body)) {
|
||||
throw new Error(`steer target <${tag}.${classToken}> not found in ${targetFileAbs}`);
|
||||
}
|
||||
body = body.replace(openTagRe, `$1 ${attr}$2`);
|
||||
await fs.writeFile(targetFileAbs, body, 'utf-8');
|
||||
if (!openTagRe.test(body)) return null;
|
||||
return body.replace(openTagRe, `$1 ${attr}$2`);
|
||||
}
|
||||
|
||||
function findSteerTargetFileSync(tmp, target) {
|
||||
@@ -1976,26 +1992,12 @@ async function runCarbonizeCleanup({ tmp, file, sessionId /* , variant */ }) {
|
||||
}
|
||||
|
||||
// 2. Unwrap the temporary `<div data-impeccable-variant="N" ...>` placed
|
||||
// around the accepted content. live-accept emits this wrapper with
|
||||
// `style="display: contents"` so it doesn't affect layout. We strip the
|
||||
// wrapper open/close lines and keep what's between.
|
||||
// Match the opening div (any single line) followed by inner content
|
||||
// followed by `</div>`, where the open carries data-impeccable-variant
|
||||
// and is NOT inside a data-impeccable-variants wrapper (the variants
|
||||
// wrapper has the trailing `s`).
|
||||
body = body.replace(
|
||||
/^([ \t]*)<div\b[^>]*\bdata-impeccable-variant="[^"]+"[^>]*>\n([\s\S]*?)\n[ \t]*<\/div>\n/m,
|
||||
(match, indent, inner) => {
|
||||
// Re-indent inner content to the wrapper's indent level.
|
||||
const innerLines = inner.split('\n');
|
||||
const innerIndent = (innerLines[0].match(/^\s*/) || [''])[0];
|
||||
const dedented = innerLines.map((l) => {
|
||||
if (l.startsWith(innerIndent)) return indent + l.slice(innerIndent.length);
|
||||
return l;
|
||||
}).join('\n');
|
||||
return expandAcceptedVariantMarkup(dedented, indent) + '\n';
|
||||
},
|
||||
);
|
||||
// around the accepted content. For JSX targets, live-accept also adds an
|
||||
// outer `<div data-impeccable-carbonize>` so the carbonize block and accepted
|
||||
// node occupy one child slot; strip that shell after the accepted node is
|
||||
// clean.
|
||||
body = unwrapDivAttributeWrapper(body, 'data-impeccable-variant', { expandSingleLineContainer: true });
|
||||
body = unwrapDivAttributeWrapper(body, 'data-impeccable-carbonize');
|
||||
|
||||
// 3. Strip any `data-impeccable-hoist-id` attributes the normalize step
|
||||
// may have injected when the model emitted inline styles. The hoisted
|
||||
@@ -2007,6 +2009,49 @@ async function runCarbonizeCleanup({ tmp, file, sessionId /* , variant */ }) {
|
||||
await fs.writeFile(filePath, body, 'utf-8');
|
||||
}
|
||||
|
||||
function unwrapDivAttributeWrapper(body, attrName, { expandSingleLineContainer = false } = {}) {
|
||||
const lines = String(body).split('\n');
|
||||
const attrRe = new RegExp(`\\b${escapeRegExp(attrName)}=`);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!/<div\b/.test(lines[i]) || !attrRe.test(lines[i])) continue;
|
||||
|
||||
const indent = (lines[i].match(/^(\s*)/) || [''])[1];
|
||||
let depth = countDivDepthDelta(lines[i]);
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
depth += countDivDepthDelta(lines[j]);
|
||||
if (depth !== 0) continue;
|
||||
|
||||
let replacement = reindentWrapperBody(lines.slice(i + 1, j), indent).join('\n');
|
||||
if (expandSingleLineContainer) {
|
||||
replacement = expandAcceptedVariantMarkup(replacement, indent);
|
||||
}
|
||||
lines.splice(i, j - i + 1, ...replacement.split('\n'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function countDivDepthDelta(line) {
|
||||
return countMatches(line, /<div\b/g) - countMatches(line, /<\/div>/g);
|
||||
}
|
||||
|
||||
function countMatches(value, re) {
|
||||
return [...String(value || '').matchAll(re)].length;
|
||||
}
|
||||
|
||||
function reindentWrapperBody(lines, indent) {
|
||||
const firstContentLine = lines.find((line) => line.trim() !== '');
|
||||
const innerIndent = (firstContentLine?.match(/^(\s*)/) || [''])[1] || '';
|
||||
return lines.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
if (innerIndent && line.startsWith(innerIndent)) return indent + line.slice(innerIndent.length);
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
}
|
||||
|
||||
function expandAcceptedVariantMarkup(source, indent) {
|
||||
const lines = source.split('\n');
|
||||
if (lines.length !== 1) return source;
|
||||
@@ -2083,3 +2128,7 @@ async function runAccept({ tmp, scriptsDir, id, variant, discard, paramValues, p
|
||||
const last = stdout.trim().split('\n').filter(Boolean).pop();
|
||||
return JSON.parse(last);
|
||||
}
|
||||
|
||||
async function runLiveComplete({ tmp, scriptsDir, id }) {
|
||||
await execFileP(process.execPath, [path.join(scriptsDir, 'live-complete.mjs'), '--id', id], { cwd: tmp });
|
||||
}
|
||||
|
||||
@@ -51,9 +51,27 @@ export function stageFixture(name, fixture) {
|
||||
return tmp;
|
||||
}
|
||||
|
||||
export function runInstall(tmp, command) {
|
||||
export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABLE_E2E_INSTALL_TIMEOUT_MS', 180_000) } = {}) {
|
||||
const [cmd, ...args] = command;
|
||||
execFileSync(cmd, args, { cwd: tmp, stdio: 'inherit' });
|
||||
const installArgs = addNpmInstallDefaults(cmd, args);
|
||||
try {
|
||||
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
||||
} catch (err) {
|
||||
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
|
||||
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function addNpmInstallDefaults(cmd, args) {
|
||||
if (cmd !== 'npm') return args;
|
||||
if (!['install', 'ci'].includes(args[0])) return args;
|
||||
const out = [...args];
|
||||
for (const flag of ['--prefer-offline', '--no-progress']) {
|
||||
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,11 +139,15 @@ export function startDevServer(tmp, runtime) {
|
||||
child.stderr.on('data', capture);
|
||||
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
const readyTimeoutMs = readTimeoutEnv(
|
||||
'IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS',
|
||||
runtime.readyTimeoutMs ?? 120_000,
|
||||
);
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(
|
||||
`dev server ready timeout (${runtime.readyTimeoutMs}ms). Tail:\n${bufLog.join('')}`,
|
||||
`dev server ready timeout (${readyTimeoutMs}ms). Tail:\n${bufLog.join('')}`,
|
||||
));
|
||||
}, runtime.readyTimeoutMs ?? 120_000);
|
||||
}, readyTimeoutMs);
|
||||
|
||||
const checkMatch = (buf) => {
|
||||
const m = buf.toString().match(readyRe);
|
||||
@@ -145,13 +167,27 @@ export function startDevServer(tmp, runtime) {
|
||||
return { child, ready, log: () => bufLog.join('') };
|
||||
}
|
||||
|
||||
function readTimeoutEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw === '') return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export async function stopDevServer(child) {
|
||||
if (!child || child.killed) return;
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
if (!child || child.exitCode != null || child.signalCode != null) return;
|
||||
let didExit = false;
|
||||
const exited = new Promise((resolve) => child.once('exit', () => {
|
||||
didExit = true;
|
||||
resolve();
|
||||
}));
|
||||
child.kill('SIGTERM');
|
||||
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
await Promise.race([exited, timeoutPromise]);
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
if (!didExit && child.exitCode == null && child.signalCode == null) {
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1_000))]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,20 +232,27 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
};
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
log(`installing deps`);
|
||||
runInstall(tmp, runtime.install);
|
||||
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
|
||||
|
||||
const liveStartedAt = Date.now();
|
||||
log(`starting live-server`);
|
||||
live = startLiveServer(tmp);
|
||||
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
|
||||
const injectStartedAt = Date.now();
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port);
|
||||
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
|
||||
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
|
||||
|
||||
const devStartedAt = Date.now();
|
||||
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
|
||||
dev = startDevServer(tmp, runtime);
|
||||
const { port: devPort } = await dev.ready;
|
||||
log(`dev server ready on ${devPort}`);
|
||||
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
|
||||
|
||||
// Agent loop runs concurrently — abort on teardown.
|
||||
agentAbort = new AbortController();
|
||||
@@ -239,10 +282,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
if (msg.type() === 'error') consoleErrors.push(`console.error: ${msg.text()}`);
|
||||
});
|
||||
|
||||
const pageStartedAt = Date.now();
|
||||
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
@@ -260,3 +305,8 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1_000) return `${ms}ms`;
|
||||
return `${(ms / 1_000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
+51
-23
@@ -139,9 +139,21 @@ function installLiveQueryHelpersInPage() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function installLiveQueryHelpers(page) {
|
||||
export async function installLiveQueryHelpers(page, { timeout = 5_000 } = {}) {
|
||||
await page.addInitScript(installLiveQueryHelpersInPage).catch(() => {});
|
||||
await page.evaluate(installLiveQueryHelpersInPage);
|
||||
await withTimeout(
|
||||
page.evaluate(installLiveQueryHelpersInPage),
|
||||
timeout,
|
||||
'install live query helpers',
|
||||
);
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeout, label) {
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeout}ms`)), timeout);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function clickLiveControl(page, selector) {
|
||||
@@ -605,14 +617,14 @@ export async function clickPrev(page) {
|
||||
}
|
||||
|
||||
async function clickBarButton(page, label) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const button = page.locator(`${BAR_ID} button`, { hasText: label });
|
||||
const textMatch = label instanceof RegExp
|
||||
? { kind: 'regex', source: label.source, flags: label.flags }
|
||||
: { kind: 'text', value: String(label) };
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await installLiveQueryHelpers(page);
|
||||
const button = page.locator(`${BAR_ID} button`, { hasText: label });
|
||||
await button.click({ timeout: 5_000 });
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -637,11 +649,19 @@ async function clickBarButton(page, label) {
|
||||
}
|
||||
|
||||
async function dispatchBarButton(page, label) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const textMatch = label instanceof RegExp
|
||||
? { kind: 'regex', source: label.source, flags: label.flags }
|
||||
: { kind: 'text', value: String(label) };
|
||||
return page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch });
|
||||
try {
|
||||
await installLiveQueryHelpers(page);
|
||||
const textMatch = label instanceof RegExp
|
||||
? { kind: 'regex', source: label.source, flags: label.flags }
|
||||
: { kind: 'text', value: String(label) };
|
||||
return await withTimeout(
|
||||
page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch }),
|
||||
5_000,
|
||||
'dispatch bar button',
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findAndClickBarButton({ barSel, textMatch }) {
|
||||
@@ -662,20 +682,28 @@ function findAndClickBarButton({ barSel, textMatch }) {
|
||||
* Read the currently visible variant index (the "i" in "i/N").
|
||||
*/
|
||||
export async function getVisibleVariant(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
return page.evaluate((barSel) => {
|
||||
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
|
||||
if (wrapper) {
|
||||
const variants = [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')];
|
||||
const visible = variants.find((variant) => variant.style.display !== 'none');
|
||||
const idx = visible ? parseInt(visible.dataset.impeccableVariant || '0', 10) : 0;
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
const bar = window.__impeccableLiveQuery(barSel);
|
||||
if (!bar) return null;
|
||||
const m = (bar.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}, BAR_ID);
|
||||
try {
|
||||
await installLiveQueryHelpers(page);
|
||||
return await withTimeout(
|
||||
page.evaluate((barSel) => {
|
||||
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
|
||||
if (wrapper) {
|
||||
const variants = [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')];
|
||||
const visible = variants.find((variant) => variant.style.display !== 'none');
|
||||
const idx = visible ? parseInt(visible.dataset.impeccableVariant || '0', 10) : 0;
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
const bar = window.__impeccableLiveQuery(barSel);
|
||||
if (!bar) return null;
|
||||
const m = (bar.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}, BAR_ID),
|
||||
5_000,
|
||||
'read visible variant',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user