From 82801a4894d2f2010692bf60bdb9290fde04afed Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 8 Jun 2026 10:39:12 -0700 Subject: [PATCH] [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 --- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .github/workflows/ci.yml | 388 +++++++++++++++++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .pi/skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- .../skills/impeccable/scripts/live-browser.js | 77 +++- cli/bin/commands/skills.mjs | 23 +- package.json | 18 +- .../skills/impeccable/scripts/live-browser.js | 77 +++- scripts/ci-test-plan.mjs | 91 ++++ scripts/run-tests.mjs | 95 +++++ scripts/test-suites.mjs | 308 ++++++++++++++ skill/scripts/live-browser.js | 77 +++- tests/ci-test-plan.test.mjs | 124 ++++++ tests/framework-fixtures/README.md | 6 + tests/live-browser-source.test.mjs | 42 +- tests/live-e2e-steer-agent.test.mjs | 14 +- tests/live-e2e.test.mjs | 261 ++++++++++-- tests/live-e2e/agent.mjs | 103 +++-- tests/live-e2e/session.mjs | 66 ++- tests/live-e2e/ui.mjs | 74 ++-- tests/skills-cli.test.js | 117 +++++- tests/test-suites.test.mjs | 39 ++ 30 files changed, 2585 insertions(+), 262 deletions(-) create mode 100644 scripts/ci-test-plan.mjs create mode 100644 scripts/run-tests.mjs create mode 100644 scripts/test-suites.mjs create mode 100644 tests/ci-test-plan.test.mjs create mode 100644 tests/test-suites.test.mjs diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 423938ee9..60f449b9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,49 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - test: + changes: runs-on: ubuntu-latest - + outputs: + core: ${{ steps.plan.outputs.core }} + detector: ${{ steps.plan.outputs.detector }} + live: ${{ steps.plan.outputs.live }} + framework: ${{ steps.plan.outputs.framework }} + cli_remote_e2e: ${{ steps.plan.outputs.cli_remote_e2e }} + live_e2e: ${{ steps.plan.outputs.live_e2e }} + live_e2e_accept_cleanup: ${{ steps.plan.outputs.live_e2e_accept_cleanup }} + skill_behavior: ${{ steps.plan.outputs.skill_behavior }} + live_svelte_adapter_deepseek: ${{ steps.plan.outputs.live_svelte_adapter_deepseek }} steps: - name: Checkout repository uses: actions/checkout@v6 - + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Detect test plan + id: plan + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: node scripts/ci-test-plan.mjs + + test: + runs-on: ubuntu-latest + needs: changes + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup Node uses: actions/setup-node@v6 with: @@ -27,18 +61,358 @@ jobs: - name: Install dependencies run: bun install + - name: Run core tests + run: bun run test:core + - name: Install Puppeteer browser + if: needs.changes.outputs.detector == 'true' run: bunx puppeteer browsers install chrome - - name: Run tests - run: bun run test - + - name: Run detector tests + if: needs.changes.outputs.detector == 'true' + run: bun run test:detector + + - name: Run live unit tests + if: needs.changes.outputs.live == 'true' + run: bun run test:live + + - name: Run framework fixture tests + if: needs.changes.outputs.framework == 'true' + run: bun run test:framework + + - name: Rebuild browser detector + if: needs.changes.outputs.detector == 'true' + run: bun run build:browser + + - name: Rebuild extension detector + if: needs.changes.outputs.detector == 'true' + run: bun run build:extension + - name: Build run: bun run build - + + - name: Verify generated tracked outputs + run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: impeccable-dist path: dist/ retention-days: 7 + + cli-remote-e2e: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.cli_remote_e2e == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run remote CLI E2E smoke + run: bun run test:cli-remote-e2e + + live-e2e-smoke: + name: live-e2e smoke (${{ matrix.group }}) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' + timeout-minutes: 15 + strategy: + fail-fast: true + matrix: + include: + - group: platform + fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + - group: react + fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache fixture npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} + restore-keys: | + ${{ runner.os }}-fixture-npm- + + - name: Cache Playwright Chromium + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install dependencies + run: bun install + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Run live E2E tests + run: bun run test:live-e2e + env: + IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }} + IMPECCABLE_E2E_SCENARIOS: core + IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000 + IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000 + IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000 + IMPECCABLE_E2E_ARTIFACT_DIR: test-results/live-e2e/${{ matrix.group }} + + - name: Upload live E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: live-e2e-smoke-${{ matrix.group }}-artifacts + path: test-results/live-e2e + if-no-files-found: ignore + + live-e2e-full: + name: live-e2e full (${{ matrix.group }}) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch' + timeout-minutes: 25 + strategy: + fail-fast: true + matrix: + include: + - group: platform + fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + - group: react-a + fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion + - group: react-b + fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain + - group: stateful + fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs + - group: styling + fixtures: vite8-react-tailwindv3,vite8-react-tailwindv4,vite8-react-ts,vite8-react-tsx-repeated-aside,vite8-react-unocss,vite8-react-vanilla-extract + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache fixture npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} + restore-keys: | + ${{ runner.os }}-fixture-npm- + + - name: Cache Playwright Chromium + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install dependencies + run: bun install + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Run live E2E tests + run: bun run test:live-e2e + env: + IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }} + IMPECCABLE_E2E_TEST_TIMEOUT_MS: 300000 + IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 180000 + IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 120000 + IMPECCABLE_E2E_ARTIFACT_DIR: test-results/live-e2e/${{ matrix.group }} + + - name: Upload live E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: live-e2e-full-${{ matrix.group }}-artifacts + path: test-results/live-e2e + if-no-files-found: ignore + + live-e2e-accept-cleanup: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.live_e2e_accept_cleanup == 'true' + timeout-minutes: 15 + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + steps: + - name: Skip without provider key + if: ${{ env.ANTHROPIC_API_KEY == '' && env.DEEPSEEK_API_KEY == '' }} + run: echo "Skipping provider-backed accept-cleanup regression because no provider API key is configured." + + - name: Checkout repository + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + uses: actions/checkout@v6 + + - name: Setup Node + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache fixture npm downloads + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} + restore-keys: | + ${{ runner.os }}-fixture-npm- + + - name: Cache Playwright Chromium + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install dependencies + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + run: bun install + + - name: Install Playwright Chromium + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + run: npx playwright install chromium + + - name: Run accept cleanup regression + if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} + run: | + if [ -n "$DEEPSEEK_API_KEY" ]; then + export IMPECCABLE_E2E_LLM_PROVIDER=deepseek + else + export IMPECCABLE_E2E_LLM_PROVIDER=anthropic + fi + bun run test:live-e2e-accept-cleanup + + live-svelte-adapter-deepseek: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.live_svelte_adapter_deepseek == 'true' + timeout-minutes: 25 + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + steps: + - name: Skip without DeepSeek key + if: ${{ env.DEEPSEEK_API_KEY == '' }} + run: echo "Skipping Svelte adapter DeepSeek sweep because DEEPSEEK_API_KEY is not configured." + + - name: Checkout repository + if: ${{ env.DEEPSEEK_API_KEY != '' }} + uses: actions/checkout@v6 + + - name: Setup Node + if: ${{ env.DEEPSEEK_API_KEY != '' }} + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + if: ${{ env.DEEPSEEK_API_KEY != '' }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache fixture npm downloads + if: ${{ env.DEEPSEEK_API_KEY != '' }} + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} + restore-keys: | + ${{ runner.os }}-fixture-npm- + + - name: Cache Playwright Chromium + if: ${{ env.DEEPSEEK_API_KEY != '' }} + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install dependencies + if: ${{ env.DEEPSEEK_API_KEY != '' }} + run: bun install + + - name: Install Playwright Chromium + if: ${{ env.DEEPSEEK_API_KEY != '' }} + run: npx playwright install chromium + + - name: Run Svelte adapter DeepSeek sweep + if: ${{ env.DEEPSEEK_API_KEY != '' }} + run: bun run test:live-svelte-adapter-deepseek + + skill-behavior: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.skill_behavior == 'true' && github.event_name != 'pull_request' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_CLOUD_API_KEY: ${{ secrets.GOOGLE_CLOUD_API_KEY }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run skill behavior tests + run: bun run test:skill-behavior diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 8b727940f..c3c87e5eb 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -9,7 +9,7 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; import { join, resolve, dirname, relative, isAbsolute } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; @@ -134,6 +134,9 @@ function hashSkillsDir(skillsDir) { * Caller is responsible for cleanup. */ async function downloadAndExtractBundle() { + const localBundle = process.env.IMPECCABLE_BUNDLE_PATH; + if (localBundle) return copyOrExtractLocalBundle(localBundle); + const tmpZip = join(tmpdir(), `impeccable-update-${Date.now()}.zip`); const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`); await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip); @@ -143,6 +146,24 @@ async function downloadAndExtractBundle() { return tmpDir; } +async function copyOrExtractLocalBundle(sourceValue) { + const source = resolve(sourceValue); + if (!existsSync(source)) { + throw new Error(`Local bundle not found: ${source}`); + } + + const tmpDir = join(tmpdir(), `impeccable-local-bundle-${process.pid}-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + + if (statSync(source).isDirectory()) { + cpSync(source, tmpDir, { recursive: true }); + return tmpDir; + } + + await extract(source, { dir: tmpDir }); + return tmpDir; +} + /** * Normalize a SKILL.md's content for comparison by stripping * provider-specific paths. Different install methods (npx skills add diff --git a/package.json b/package.json index cdec77868..d3cb4ab8f 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,18 @@ "dev": "bun run scripts/gen-dev-api.mjs && npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js tests/lib/detector-bundle.test.js tests/lib/provider-blocks.test.js tests/lib/transformers/provider-blocks.test.js tests/lib/utils.test.js tests/lib/transformers/factory.test.js tests/lib/transformers/providers.test.js tests/skills-cli.test.js && node --test tests/critique-storage.test.mjs && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-wrap-buffer-aware.test.mjs && node --test tests/live-insert.test.mjs && node --test tests/live-insert-ui.test.mjs && node --test tests/live-event-validation.test.mjs && node --test tests/live-reference.test.mjs && node --test tests/live-e2e-agent-output.test.mjs && node --test tests/live-e2e-llm-agent.test.mjs && node --test tests/live-e2e-cli-options.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-accept-scrub.test.mjs && node --test tests/live-commit-manual-edits.test.mjs && node --test tests/live-discard-manual-edits.test.mjs && node --test tests/live-manual-edits-buffer.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-poll-stream.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-copy-edit-agent.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", - "test:cli-e2e": "IMPECCABLE_CLI_E2E=1 bun test tests/skills-cli.test.js", - "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", - "test:live-e2e-agent": "node --test tests/live-e2e/agent-insert.test.mjs", - "test:skill-behavior": "node --test --test-timeout=300000 tests/skill-behavior/scenarios.test.mjs", - "test:live-svelte-adapter-deepseek": "node --test --test-timeout=1200000 tests/live-svelte-adapter-deepseek.test.mjs", + "test": "node scripts/run-tests.mjs default", + "test:core": "node scripts/run-tests.mjs core", + "test:detector": "node scripts/run-tests.mjs detector", + "test:framework": "node scripts/run-tests.mjs framework", + "test:live": "node scripts/run-tests.mjs live", + "test:cli-e2e": "node scripts/run-tests.mjs cli-e2e", + "test:cli-remote-e2e": "node scripts/run-tests.mjs cli-remote-e2e", + "test:live-e2e": "node scripts/run-tests.mjs live-e2e", + "test:live-e2e-accept-cleanup": "node scripts/run-tests.mjs live-e2e-accept-cleanup", + "test:live-e2e-agent": "node scripts/run-tests.mjs live-e2e-agent", + "test:skill-behavior": "node scripts/run-tests.mjs skill-behavior", + "test:live-svelte-adapter-deepseek": "node scripts/run-tests.mjs live-svelte-adapter-deepseek", "bench:detector": "node scripts/benchmark-detector.mjs", "bench:detector:browser": "node scripts/benchmark-detector.mjs --browser", "audit": "bun audit --audit-level=moderate", diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs new file mode 100644 index 000000000..8888fe144 --- /dev/null +++ b/scripts/ci-test-plan.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { DEFAULT_SUITES, matchesSuiteTriggers } from './test-suites.mjs'; + +const eventName = process.env.GITHUB_EVENT_NAME || ''; +const localNoChanges = !eventName && !process.env.CI_CHANGED_FILES; +const changedFiles = localNoChanges ? [] : getChangedFiles(); +const forceDeterministic = localNoChanges || eventName === 'push' || eventName === 'workflow_dispatch'; +const forceOptIn = eventName === 'workflow_dispatch'; + +const plan = { + core: true, + detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), + live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), + framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), + cli_remote_e2e: forceOptIn, + live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), + live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), + skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), + live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), +}; + +writeGithubOutputs(plan); +printSummary(plan, changedFiles); + +function getChangedFiles() { + if (process.env.CI_CHANGED_FILES) { + return process.env.CI_CHANGED_FILES + .split(/\r?\n/) + .map((file) => file.trim()) + .filter(Boolean); + } + + const event = process.env.GITHUB_EVENT_NAME || ''; + const sha = process.env.GITHUB_SHA || 'HEAD'; + + if (event === 'pull_request' && process.env.GITHUB_BASE_REF) { + const base = `origin/${process.env.GITHUB_BASE_REF}`; + return gitDiffNames(`${base}...${sha}`) || gitDiffNames(`${base}...HEAD`) || allChanged(); + } + + const before = process.env.GITHUB_EVENT_BEFORE; + if (before && !/^0+$/.test(before)) { + return gitDiffNames(`${before}..${sha}`) || allChanged(); + } + + return allChanged(); +} + +function allChanged() { + return git(['ls-files']).split(/\r?\n/).filter(Boolean); +} + +function gitDiffNames(range) { + try { + return git(['diff', '--name-only', range]).split(/\r?\n/).filter(Boolean); + } catch { + return null; + } +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf-8' }); +} + +function writeGithubOutputs(outputs) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) return; + const lines = []; + for (const [key, value] of Object.entries(outputs)) { + lines.push(`${key}=${value ? 'true' : 'false'}`); + } + fs.appendFileSync(outputPath, lines.join('\n') + '\n'); +} + +function printSummary(outputs, files) { + const deterministic = DEFAULT_SUITES.map((name) => `${name}=${outputs[name]}`).join(' '); + console.log(`Event: ${eventName || 'local'}`); + console.log(`Changed files: ${files.length}`); + console.log(`Deterministic suites: ${deterministic}`); + console.log( + [ + `cli_remote_e2e=${outputs.cli_remote_e2e}`, + `live_e2e=${outputs.live_e2e}`, + `live_e2e_accept_cleanup=${outputs.live_e2e_accept_cleanup}`, + `skill_behavior=${outputs.skill_behavior}`, + `deepseek=${outputs.live_svelte_adapter_deepseek}`, + ].join(' '), + ); +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 000000000..0841d3da8 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { DEFAULT_SUITES, OPT_IN_SUITES, SUITES, expandSuites } from './test-suites.mjs'; + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + printHelp(); + process.exit(0); +} + +if (args.includes('--list')) { + printSuites(); + process.exit(0); +} + +const requestedSuites = args.filter((arg) => !arg.startsWith('-')); +let suites; +try { + suites = expandSuites(requestedSuites); +} catch (err) { + console.error(err.message); + process.exit(1); +} + +for (const suiteName of suites) { + const suite = SUITES[suiteName]; + console.log(`\n## test:${suiteName}`); + console.log(suite.description); + for (const command of suite.commands) { + runCommand(command); + } +} + +function runCommand(command) { + const env = { ...process.env, ...(command.env || {}) }; + if (command.runner === 'bun') { + runProcess('bun', ['test', ...command.files], { env }); + return; + } + + if (command.runner === 'node') { + for (const file of command.files) { + const args = ['--test']; + if (command.timeoutMs) args.push(`--test-timeout=${command.timeoutMs}`); + if (command.forceExit) args.push('--test-force-exit'); + args.push(file); + runProcess(process.execPath, args, { env }); + } + return; + } + + throw new Error(`Unsupported test runner "${command.runner}"`); +} + +function runProcess(cmd, args, { env }) { + console.log(`$ ${formatCommand(cmd, args)}`); + const result = spawnSync(cmd, args, { + stdio: 'inherit', + env, + }); + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + if (result.status !== 0) process.exit(result.status || 1); +} + +function formatCommand(cmd, args) { + const bin = cmd === process.execPath ? 'node' : cmd; + return [bin, ...args].join(' '); +} + +function printHelp() { + console.log(`Usage: node scripts/run-tests.mjs [suite...] + +Aliases: + default ${DEFAULT_SUITES.join(', ')} + all-local ${DEFAULT_SUITES.join(', ')} + all ${[...DEFAULT_SUITES, ...OPT_IN_SUITES].join(', ')} + +Run with --list to see suite contents.`); +} + +function printSuites() { + for (const [name, suite] of Object.entries(SUITES)) { + const marker = suite.optIn ? ' (opt-in)' : ''; + console.log(`\n${name}${marker}`); + console.log(` ${suite.description}`); + for (const command of suite.commands) { + console.log(` ${command.runner}:`); + for (const file of command.files) console.log(` ${file}`); + } + } +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs new file mode 100644 index 000000000..134093030 --- /dev/null +++ b/scripts/test-suites.mjs @@ -0,0 +1,308 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const DEFAULT_SUITES = ['core', 'detector', 'live', 'framework']; +export const OPT_IN_SUITES = [ + 'cli-remote-e2e', + 'live-e2e', + 'live-e2e-accept-cleanup', + 'skill-behavior', + 'live-svelte-adapter-deepseek', +]; + +const COMMON_INFRA_PATTERNS = [ + /^package\.json$/, + /^bun\.lock$/, + /^scripts\/run-tests\.mjs$/, + /^scripts\/test-suites\.mjs$/, + /^scripts\/ci-test-plan\.mjs$/, + /^\.github\/workflows\/ci\.yml$/, +]; + +export const SUITES = { + core: { + description: 'Build, provider transforms, CLI helpers, context, and storage unit tests.', + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/, + /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|context|context-signals|critique-storage|design-parser|impeccable-paths|is-generated))/, + /^cli\/bin\//, + /^tests\/(build|cleanup-deprecated|context|context-signals|critique-storage|design-parser|impeccable-paths|skills-cli|test-suites|windows-path-fix)\.test\.(js|mjs)$/, + /^tests\/lib\//, + ], + commands: [ + { + runner: 'bun', + files: [ + 'tests/build.test.js', + 'tests/windows-path-fix.test.js', + 'tests/lib/provider-blocks.test.js', + 'tests/lib/transformers/provider-blocks.test.js', + 'tests/lib/utils.test.js', + 'tests/lib/transformers/factory.test.js', + 'tests/lib/transformers/providers.test.js', + 'tests/skills-cli.test.js', + ], + }, + { + runner: 'node', + files: [ + 'tests/ci-test-plan.test.mjs', + 'tests/cleanup-deprecated.test.mjs', + 'tests/context.test.mjs', + 'tests/context-signals.test.mjs', + 'tests/critique-storage.test.mjs', + 'tests/design-parser.test.mjs', + 'tests/impeccable-paths.test.mjs', + 'tests/test-suites.test.mjs', + ], + }, + ], + }, + detector: { + description: 'Anti-pattern detector tests across text, jsdom fixtures, and Puppeteer browser paths.', + needsPuppeteer: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^cli\/engine\//, + /^extension\/(background|content|detector|devtools|popup|manifest\.json)/, + /^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/, + /^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/, + /^tests\/(detect-antipatterns|fixtures\/antipatterns)/, + ], + commands: [ + { + runner: 'bun', + files: [ + 'tests/detect-antipatterns.test.js', + 'tests/lib/detector-bundle.test.js', + ], + }, + { + runner: 'node', + files: [ + 'tests/detect-antipatterns-fixtures.test.mjs', + 'tests/detect-antipatterns-browser.test.mjs', + ], + }, + ], + }, + live: { + description: 'Fast live-mode unit and local-server integration tests, excluding full browser fixture sweeps.', + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\/(reference\/live\.md|scripts\/(detect-csp|is-generated|live|live-|modern-screenshot|pin|palette))/, + /^tests\/live-/, + /^tests\/live-e2e\/(agent|agents\/llm-agent|cli-options|preactions|session|steer|ui)\.mjs$/, + /^tests\/live-e2e\/agent-insert\.test\.mjs$/, + ], + commands: [ + { + runner: 'node', + files: [ + 'tests/live-accept.test.mjs', + 'tests/live-accept-scrub.test.mjs', + 'tests/live-browser-regression.test.mjs', + 'tests/live-browser-session.test.mjs', + 'tests/live-browser-source.test.mjs', + 'tests/live-commit-manual-edits.test.mjs', + 'tests/live-completion.test.mjs', + 'tests/live-copy-edit-agent.test.mjs', + 'tests/live-discard-manual-edits.test.mjs', + 'tests/live-e2e-agent-output.test.mjs', + 'tests/live-e2e-cli-options.test.mjs', + 'tests/live-e2e-llm-agent.test.mjs', + 'tests/live-e2e-steer-agent.test.mjs', + 'tests/live-e2e/agent-insert.test.mjs', + 'tests/live-event-validation.test.mjs', + 'tests/live-inject.test.mjs', + 'tests/live-insert.test.mjs', + 'tests/live-insert-ui.test.mjs', + 'tests/live-manual-edits-buffer.test.mjs', + 'tests/live-poll.test.mjs', + 'tests/live-poll-stream.test.mjs', + 'tests/live-recovery-commands.test.mjs', + 'tests/live-reference.test.mjs', + 'tests/live-server.test.mjs', + 'tests/live-session-store.test.mjs', + 'tests/live-wrap.test.mjs', + 'tests/live-wrap-buffer-aware.test.mjs', + ], + }, + ], + }, + framework: { + description: 'Framework fixture coverage for live injection, CSP, generated-file detection, and wrapping.', + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^tests\/framework-fixtures/, + /^tests\/framework-fixtures\.test\.mjs$/, + /^skill\/scripts\/(detect-csp|is-generated|live-inject|live-sveltekit-adapter|live-wrap)\.mjs$/, + ], + commands: [ + { + runner: 'node', + files: ['tests/framework-fixtures.test.mjs'], + }, + ], + }, + 'cli-e2e': { + description: 'Deterministic CLI install/update tests against a local universal bundle.', + commands: [ + { + runner: 'bun', + files: ['tests/skills-cli.test.js'], + }, + ], + }, + 'cli-remote-e2e': { + description: 'Remote CLI install/update smoke tests against impeccable.style.', + optIn: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^cli\/bin\/commands\/skills\.mjs$/, + /^tests\/skills-cli\.test\.js$/, + ], + commands: [ + { + runner: 'bun', + env: { IMPECCABLE_CLI_REMOTE_E2E: '1' }, + files: ['tests/skills-cli.test.js'], + }, + ], + }, + 'live-e2e': { + description: 'Full Playwright live-mode click-to-accept sweep across runtime framework fixtures.', + optIn: true, + needsPlaywright: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\/scripts\/live/, + /^tests\/framework-fixtures/, + /^tests\/live-e2e(\.test\.mjs|\/)/, + ], + commands: [ + { + runner: 'node', + timeoutMs: 600000, + forceExit: true, + files: ['tests/live-e2e.test.mjs'], + }, + ], + }, + 'live-e2e-accept-cleanup': { + description: 'Provider-backed post-accept cleanup regression.', + optIn: true, + needsPlaywright: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\/scripts\/(live-accept|live-browser|live-server|live-sveltekit-adapter|live-wrap)\.mjs$/, + /^tests\/live-e2e-accept-cleanup-regression\.test\.mjs$/, + /^tests\/live-e2e\//, + ], + commands: [ + { + runner: 'node', + timeoutMs: 600000, + files: ['tests/live-e2e-accept-cleanup-regression.test.mjs'], + }, + ], + }, + 'live-e2e-agent': { + description: 'Focused insert-mode fake-agent helper tests.', + commands: [ + { + runner: 'node', + files: ['tests/live-e2e/agent-insert.test.mjs'], + }, + ], + }, + 'skill-behavior': { + description: 'LLM-backed skill setup behavior scenarios.', + optIn: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\/SKILL\.src\.md$/, + /^skill\/reference\/(init|document|brand|product|shape|craft|audit|polish|live)\.md$/, + /^skill\/scripts\/(context|context-signals|detect|detect-csp)\.mjs$/, + /^tests\/skill-behavior\//, + ], + commands: [ + { + runner: 'node', + timeoutMs: 300000, + files: ['tests/skill-behavior/scenarios.test.mjs'], + }, + ], + }, + 'live-svelte-adapter-deepseek': { + description: 'DeepSeek-backed Svelte adapter browser sweep.', + optIn: true, + needsPlaywright: true, + triggers: [ + ...COMMON_INFRA_PATTERNS, + /^skill\/scripts\/(live-sveltekit-adapter|live-svelte-component|live-server|live-wrap)\.mjs$/, + /^tests\/framework-fixtures\/vite8-sveltekit-stateful\//, + /^tests\/live-svelte-adapter-deepseek\.test\.mjs$/, + ], + commands: [ + { + runner: 'node', + timeoutMs: 1200000, + files: ['tests/live-svelte-adapter-deepseek.test.mjs'], + }, + ], + }, +}; + +export function expandSuites(requested) { + const names = requested.length === 0 ? ['default'] : requested; + const expanded = []; + for (const name of names) { + if (name === 'default' || name === 'all-local') { + expanded.push(...DEFAULT_SUITES); + } else if (name === 'all') { + expanded.push(...DEFAULT_SUITES, ...OPT_IN_SUITES); + } else if (SUITES[name]) { + expanded.push(name); + } else { + throw new Error(`Unknown test suite "${name}". Run: node scripts/run-tests.mjs --list`); + } + } + return [...new Set(expanded)]; +} + +export function suiteFiles(suiteNames) { + const files = []; + for (const name of suiteNames) { + const suite = SUITES[name]; + if (!suite) throw new Error(`Unknown test suite "${name}"`); + for (const command of suite.commands) { + files.push(...command.files); + } + } + return files; +} + +export function findTestFiles(root = process.cwd()) { + const out = []; + const stack = [path.join(root, 'tests')]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(abs); + } else if (/\.test\.(js|mjs)$/.test(entry.name)) { + out.push(path.relative(root, abs).split(path.sep).join('/')); + } + } + } + return out.sort(); +} + +export function matchesSuiteTriggers(suiteName, changedFiles) { + const suite = SUITES[suiteName]; + if (!suite) throw new Error(`Unknown test suite "${suiteName}"`); + return changedFiles.some((file) => suite.triggers?.some((pattern) => pattern.test(file))); +} diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 7bf6ef31c..302b9ec23 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -4503,15 +4503,17 @@ if (origContent.id) { liveEl = document.getElementById(origContent.id); } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + const candidates = [...document.getElementsByTagName(tag)]; for (const c of candidates) { if (c.className === cls && !own(c)) { liveEl = c; break; } } if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter(Boolean); - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } } } } @@ -4880,7 +4882,7 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(block, 'text/html'); + const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); @@ -4949,6 +4951,44 @@ }); } + function normalizeSourceFallbackBlock(block, filePath) { + if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; + return String(block) + .replace( + /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, + (_match, attrs, css) => '' + css + '', + ) + .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { + const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); + return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; + }) + .replace(/\bclassName\s*=/g, 'class=') + .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { + const css = jsxStyleObjectToCss(body); + return css ? ' style="' + escapeHtml(css) + '"' : ''; + }); + } + + function jsxStyleObjectToCss(body) { + const declarations = []; + const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; + let match; + while ((match = re.exec(String(body || '')))) { + const prop = jsxStylePropToCss(match[1]); + const value = match[2] ?? match[3] ?? match[4] ?? ''; + if (!prop || value === '') continue; + declarations.push(prop + ': ' + value); + } + return declarations.join('; '); + } + + function jsxStylePropToCss(prop) { + let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); + if (!out) return ''; + if (out.startsWith('--')) return out; + return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); + } + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; @@ -5422,7 +5462,11 @@ } // Source fallback when HMR did not land variants in this tab. if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) { - injectVariantsFromSource(msg.file, msg.id); + setTimeout(() => { + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; + if (state !== 'GENERATING' || msg.id !== currentSessionId) return; + injectVariantsFromSource(msg.file, msg.id); + }, 750); break; } // Variants are in source but not in the DOM yet. Common when the @@ -5514,8 +5558,11 @@ function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; + if (opts && opts.throwOnError) { + console.error('[impeccable] Failed to send event:', err); + throw err; + } + console.debug('[impeccable] Dropped optional live event:', err); return null; } return fetch('http://localhost:' + PORT + '/events', { @@ -6843,12 +6890,16 @@ void main() { function ensureAcceptedDomClean(pending) { const sessionId = pending?.id; const variantId = pending?.variant; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findAcceptedRuntimeWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); if (!wrapper) { restoreAcceptedDomFromSnapshot(pending); return; } + if (acceptedDomAlreadyClean(pending)) { + wrapper.remove(); + return; + } if (!accepted) { wrapper.remove(); restoreAcceptedDomFromSnapshot(pending); @@ -6862,6 +6913,12 @@ void main() { wrapper.remove(); } + function findAcceptedRuntimeWrapper(sessionId) { + if (!sessionId) return null; + return document.querySelector('[data-impeccable-variants="' + sessionId + '"]') + || document.querySelector('[data-impeccable-carbonize="' + sessionId + '"]'); + } + function restoreAcceptedDomFromSnapshot(pending) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { diff --git a/tests/ci-test-plan.test.mjs b/tests/ci-test-plan.test.mjs new file mode 100644 index 000000000..8c2d0bbac --- /dev/null +++ b/tests/ci-test-plan.test.mjs @@ -0,0 +1,124 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const SCRIPT = 'scripts/ci-test-plan.mjs'; + +describe('ci-test-plan', () => { + it('keeps docs-only pull requests on the core suite', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'pull_request', + CI_CHANGED_FILES: 'README.md', + }); + + assert.equal(outputs.core, 'true'); + assert.equal(outputs.detector, 'false'); + assert.equal(outputs.live, 'false'); + assert.equal(outputs.framework, 'false'); + assert.equal(outputs.live_e2e, 'false'); + assert.equal(outputs.live_e2e_accept_cleanup, 'false'); + assert.equal(outputs.live_svelte_adapter_deepseek, 'false'); + }); + + it('routes detector changes to detector tests only', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'pull_request', + CI_CHANGED_FILES: 'cli/engine/detect-antipatterns.mjs', + }); + + assert.equal(outputs.detector, 'true'); + assert.equal(outputs.live, 'false'); + assert.equal(outputs.framework, 'false'); + }); + + it('routes live server changes to live unit and full live E2E lanes', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'pull_request', + CI_CHANGED_FILES: 'skill/scripts/live-server.mjs', + }); + + assert.equal(outputs.live, 'true'); + assert.equal(outputs.live_e2e, 'true'); + assert.equal(outputs.live_e2e_accept_cleanup, 'true'); + assert.equal(outputs.live_svelte_adapter_deepseek, 'true'); + assert.equal(outputs.detector, 'false'); + }); + + it('routes skill setup changes to the skill behavior lane', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'pull_request', + CI_CHANGED_FILES: 'skill/SKILL.src.md', + }); + + assert.equal(outputs.skill_behavior, 'true'); + assert.equal(outputs.detector, 'false'); + assert.equal(outputs.live, 'false'); + }); + + it('forces deterministic suites on push without forcing opt-in E2E suites', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'push', + CI_CHANGED_FILES: 'README.md', + }); + + assert.equal(outputs.core, 'true'); + assert.equal(outputs.detector, 'true'); + assert.equal(outputs.live, 'true'); + assert.equal(outputs.framework, 'true'); + assert.equal(outputs.cli_remote_e2e, 'false'); + assert.equal(outputs.live_e2e, 'false'); + assert.equal(outputs.live_e2e_accept_cleanup, 'false'); + assert.equal(outputs.live_svelte_adapter_deepseek, 'false'); + }); + + it('enables remote smoke suites on manual dispatch', () => { + const outputs = runPlan({ + GITHUB_EVENT_NAME: 'workflow_dispatch', + CI_CHANGED_FILES: 'README.md', + }); + + assert.equal(outputs.cli_remote_e2e, 'true'); + assert.equal(outputs.live_e2e, 'true'); + assert.equal(outputs.live_e2e_accept_cleanup, 'true'); + assert.equal(outputs.skill_behavior, 'true'); + assert.equal(outputs.live_svelte_adapter_deepseek, 'true'); + }); + + it('exposes planned opt-in suite outputs to workflow jobs', () => { + const workflow = readFileSync('.github/workflows/ci.yml', 'utf-8'); + + assert.match(workflow, /live_e2e_accept_cleanup:\s*\$\{\{\s*steps\.plan\.outputs\.live_e2e_accept_cleanup\s*\}\}/); + assert.match(workflow, /live_svelte_adapter_deepseek:\s*\$\{\{\s*steps\.plan\.outputs\.live_svelte_adapter_deepseek\s*\}\}/); + assert.match(workflow, /live-e2e-accept-cleanup:/); + assert.match(workflow, /live-svelte-adapter-deepseek:/); + }); +}); + +function runPlan(env) { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-ci-plan-')); + const outputPath = join(tmp, 'github-output'); + try { + const result = spawnSync(process.execPath, [SCRIPT], { + cwd: process.cwd(), + encoding: 'utf-8', + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + ...env, + }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return Object.fromEntries( + readFileSync(outputPath, 'utf-8') + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => line.split('=')), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md index df0aa27a8..2b638001e 100644 --- a/tests/framework-fixtures/README.md +++ b/tests/framework-fixtures/README.md @@ -84,6 +84,12 @@ The `runtime` block is optional. Fixtures without it only run the static unit ch 6. Runs a **Steer smoke** step (unless `runtime.steer === false`): submit a message in the global Steer bar, wait for the fake agent to reply `steer_done`, assert the bar unlocks and a `data-impeccable-steer` marker lands in source + DOM. Then continues with pick → Go → cycle → accept. 7. Tears everything down (Playwright close, dev server SIGTERM, live-server stop, tmp rm). +Useful runtime E2E filters: + +- `IMPECCABLE_E2E_ONLY=[,]` scopes the run to selected fixture names. +- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, and exit probes. +- `IMPECCABLE_E2E_TEST_TIMEOUT_MS`, `IMPECCABLE_E2E_INSTALL_TIMEOUT_MS`, and `IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS` tighten CI smoke timeouts without changing fixture metadata. + Optional `runtime.steer` fields: ```json diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index ad1c47ceb..4fa41dcd3 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -267,7 +267,7 @@ describe('live-browser source contracts', () => { it('keeps sendEvent fire-and-forget by default while accept/discard opt into rejection', () => { assert.match( SOURCE, - /function sendEvent\(msg, opts\)[\s\S]*if \(opts && opts\.throwOnError\) throw err;[\s\S]*return null;/, + /function sendEvent\(msg, opts\)[\s\S]*if \(opts && opts\.throwOnError\) \{[\s\S]*console\.error\('\[impeccable\] Failed to send event:', err\);[\s\S]*throw err;[\s\S]*\}[\s\S]*console\.debug\('\[impeccable\] Dropped optional live event:', err\);[\s\S]*return null;/, 'event=live_browser.send_event_contract actor=browser operation=send_event_failure risk=fire_and_forget_callers_get_unhandled_rejections expected=default swallow with opt-in throw actual=missing', ); assert.match(SOURCE, /if \(res\.ok\) return res;[\s\S]*const body = await res\.json\(\)\.catch\(\(\) => \(\{\}\)\);[\s\S]*handleFailure\(new Error\(body\.error \|\| \('HTTP ' \+ res\.status \+ ' ' \+ res\.statusText\)\)\)/); @@ -321,9 +321,14 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function ensureAcceptedDomClean\(pending\)[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);/, + /function ensureAcceptedDomClean\(pending\)[\s\S]*?findAcceptedRuntimeWrapper\(sessionId\)[\s\S]*?acceptedDomAlreadyClean\(pending\)[\s\S]*?wrapper\.remove\(\);[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);/, 'post-cleanup fallback should unwrap the accepted variant instead of preserving live runtime wrappers', ); + assert.match( + SOURCE, + /function findAcceptedRuntimeWrapper\(sessionId\)[\s\S]*?data-impeccable-variants[\s\S]*?data-impeccable-carbonize/, + 'post-cleanup fallback should also remove stale carbonize wrappers left by React HMR after accept', + ); assert.match( SOURCE, /if \(!accepted\) \{[\s\S]{0,120}?wrapper\.remove\(\);[\s\S]{0,120}?restoreAcceptedDomFromSnapshot\(pending\);[\s\S]{0,80}?return;/, @@ -340,4 +345,37 @@ describe('live-browser source contracts', () => { 'missing accepted DOM after clean source should recover by reloading the clean page', ); }); + + it('normalizes generated JSX source before source-fallback DOM parsing', () => { + assert.match( + SOURCE, + /parser\.parseFromString\(normalizeSourceFallbackBlock\(block, filePath\), 'text\/html'\)/, + 'source fallback should normalize JSX wrapper syntax before DOMParser sees it', + ); + assert.match( + SOURCE, + /function normalizeSourceFallbackBlock\(block, filePath\)[\s\S]*?\]\*\)>\\s\*\\\{\\s\*`\(\[\\s\\S\]\*\?\)`\\s\*\\\}\\s\*<\\\/style>/, + 'source fallback should unwrap JSX style template literals', + ); + assert.match( + SOURCE, + /replace\(\/\\bclassName\\s\*=\/g, 'class='\)/, + 'source fallback should translate className back to HTML class attributes', + ); + assert.match( + SOURCE, + /value\.replace\(\/\\\$\\\{\[\^}\]\*\\\}\/g, ' '\)/, + 'source fallback should reduce JSX template className values to literal class tokens', + ); + assert.doesNotMatch( + SOURCE, + /querySelectorAll\(tag \+ '\\.' \+ cls\.split/, + 'source fallback should not construct unsafe selectors from JSX-ish class strings', + ); + assert.match( + SOURCE, + /function jsxStyleObjectToCss\(body\)/, + 'source fallback should translate simple JSX style objects such as display:none', + ); + }); }); diff --git a/tests/live-e2e-steer-agent.test.mjs b/tests/live-e2e-steer-agent.test.mjs index b825c8996..4ea4289e2 100644 --- a/tests/live-e2e-steer-agent.test.mjs +++ b/tests/live-e2e-steer-agent.test.mjs @@ -8,7 +8,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { createFakeAgent, findSteerTargetFile, runAgentLoop, STEER_MARKER_ATTR } from './live-e2e/agent.mjs'; +import { addSteerMarkerToSource, createFakeAgent, findSteerTargetFile, runAgentLoop, STEER_MARKER_ATTR } from './live-e2e/agent.mjs'; import { stageFixture, startLiveServer, stopLiveServer, FIXTURES_DIR } from './live-e2e/session.mjs'; import { SCRIPTS_DIR } from './live-e2e/session.mjs'; @@ -49,6 +49,18 @@ describe('live-e2e steer agent handler', () => { assert.match(body, /hero-title/); }); + it('marks JSX template-expression className attributes', () => { + const source = [ + 'export default function App() {', + ' return

Fixture

;', + '}', + ].join('\n'); + const updated = addSteerMarkerToSource(source); + + assert.match(updated, new RegExp(STEER_MARKER_ATTR + '="e2e"')); + assert.match(updated, /className=\{`hero-title \$\{styles\.heroTitle\}`\}/); + }); + it('agent loop handles steer POST and writes the marker', async () => { const sourceFile = findSteerTargetFile(tmp); const before = readFileSync(sourceFile, 'utf-8'); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index d33f0f75f..0d120bc49 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -22,8 +22,8 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createFakeAgent } from './live-e2e/agent.mjs'; @@ -76,17 +76,24 @@ function listRuntimeFixtures() { const allFixtures = listRuntimeFixtures(); -// During development of the full-cycle test, a single fixture is much faster -// to iterate on. Set IMPECCABLE_E2E_ONLY= to scope the run. -const onlyName = process.env.IMPECCABLE_E2E_ONLY; -const fixtures = onlyName - ? allFixtures.filter((f) => f.name === onlyName) +// During development of the full-cycle test, a fixture subset is much faster +// to iterate on. Set IMPECCABLE_E2E_ONLY=[,...] to scope the run. +const onlyNames = parseFixtureFilter(process.env.IMPECCABLE_E2E_ONLY); +const fixtures = onlyNames.size > 0 + ? allFixtures.filter((f) => onlyNames.has(f.name)) : allFixtures; +const missingOnlyNames = [...onlyNames].filter((name) => !allFixtures.some((f) => f.name === name)); +if (missingOnlyNames.length > 0) { + throw new Error(`Unknown IMPECCABLE_E2E_ONLY fixture(s): ${missingOnlyNames.join(', ')}`); +} const manualOnly = process.env.IMPECCABLE_E2E_MANUAL_ONLY === '1' || process.env.IMPECCABLE_E2E_MANUAL_ONLY === 'true'; const reloadVariants = process.env.IMPECCABLE_E2E_RELOAD_VARIANTS === '1' || process.env.IMPECCABLE_E2E_RELOAD_VARIANTS === 'true'; +const scenarioNames = parseFixtureFilter(process.env.IMPECCABLE_E2E_SCENARIOS); +const liveE2eTestTimeoutMs = readPositiveIntEnv('IMPECCABLE_E2E_TEST_TIMEOUT_MS'); +const liveE2eTestOptions = liveE2eTestTimeoutMs ? { timeout: liveE2eTestTimeoutMs } : {}; if (fixtures.length === 0) { describe('live-e2e (no runtime fixtures registered)', () => { @@ -97,6 +104,26 @@ if (fixtures.length === 0) { let playwright; let browser; +function parseFixtureFilter(value) { + return new Set( + String(value || '') + .split(/[,\s]+/) + .map((name) => name.trim()) + .filter(Boolean), + ); +} + +function readPositiveIntEnv(name) { + const raw = process.env[name]; + if (raw == null || raw === '') return null; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function shouldRunScenario(name) { + return scenarioNames.size === 0 || scenarioNames.has('all') || scenarioNames.has(name); +} + before(async () => { if (fixtures.length === 0) return; try { @@ -107,7 +134,7 @@ before(async () => { ); } try { - browser = await playwright.chromium.launch({ headless: true }); + browser = await launchLiveE2eBrowser(); } catch (err) { throw new Error(`Failed to launch Chromium (${err.message}). Run: npx playwright install chromium`); } @@ -117,9 +144,26 @@ after(async () => { if (browser) await browser.close(); }); +async function launchLiveE2eBrowser() { + return playwright.chromium.launch({ headless: true }); +} + +async function teardownAndResetBrowser(teardown) { + try { + await teardown(); + } finally { + if (browser) await browser.close().catch(() => {}); + browser = await launchLiveE2eBrowser(); + } +} + for (const { name, fixture } of fixtures) { describe(`live-e2e · ${name} (${fixture.runtime.styling || 'unknown-styling'})`, () => { - it('drives the full click → Go → cycle → accept cycle', async (t) => { + it('drives the full click → Go → cycle → accept cycle', liveE2eTestOptions, async (t) => { + if (!shouldRunScenario('core')) { + t.skip('scenario filter excludes core'); + return; + } if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { t.skip('manual scenario filter is active'); return; @@ -183,6 +227,7 @@ for (const { name, fixture } of fixtures) { ? pickSelector : '[data-impeccable-variant="2"] > :first-child'; let stateProbeBaseline = null; + let sourceFile = null; try { // 1. Handshake @@ -265,7 +310,7 @@ for (const { name, fixture } of fixtures) { } // 5. Source-side check: wrapper + style + variants are present - const sourceFile = await locateSessionFile(tmp); + sourceFile = await locateSessionFile(tmp); const after = readFileSync(sourceFile, 'utf-8'); const svelteComponentSession = svelteComponentTargetFor(sourceFile); if (svelteComponentSession) { @@ -338,17 +383,18 @@ for (const { name, fixture } of fixtures) { const cycleSequence = Array.isArray(fixture.runtime.variantSequence) && fixture.runtime.variantSequence.length > 0 ? fixture.runtime.variantSequence : [2]; - let visible = await getVisibleVariant(page); + let visible = await readVisibleVariantForCycle(page); let checkedVariantTwoStyle = false; for (const targetVariant of cycleSequence) { t.diagnostic(`Cycling to variant ${targetVariant}`); - while (visible < targetVariant) { - await clickNext(page); - visible = await getVisibleVariant(page); - } - while (visible > targetVariant) { - await clickPrev(page); - visible = await getVisibleVariant(page); + let cycleAttempts = 0; + while (visible !== targetVariant) { + if (cycleAttempts++ > expectedCount + 6) { + throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`); + } + if (visible == null || visible < targetVariant) await clickNext(page); + else await clickPrev(page); + visible = await readVisibleVariantForCycle(page); } assert.equal(visible, targetVariant, `variant ${targetVariant} visible`); if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) { @@ -357,11 +403,49 @@ for (const { name, fixture } of fixtures) { const el = query(sel) || document.querySelector(sel); return el && getComputedStyle(el).fontWeight === '900'; }, variantContentSelector, { timeout: 5_000 }).catch(() => {}); - const variantWeight = await page.evaluate((sel) => { - const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); - const el = query(sel) || document.querySelector(sel); - return el ? getComputedStyle(el).fontWeight : null; - }, variantContentSelector); + const variantWeight = await evaluatePageWithTimeout( + page, + (sel) => { + const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); + const el = query(sel) || document.querySelector(sel); + return el ? getComputedStyle(el).fontWeight : null; + }, + variantContentSelector, + 5_000, + 'variant font-weight read', + ); + if (variantWeight !== '900') { + const styleSnapshot = await evaluatePageWithTimeout( + page, + (sel) => { + const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); + const el = query(sel) || document.querySelector(sel); + const styleEl = document.querySelector('style[data-impeccable-css]'); + const rules = []; + for (const sheet of [...document.styleSheets]) { + if (sheet.ownerNode !== styleEl) continue; + try { + rules.push(...[...sheet.cssRules].map((rule) => rule.cssText)); + } catch (err) { + rules.push(`cssRules error: ${err.message}`); + } + } + return { + selector: sel, + element: el?.outerHTML || null, + parent: el?.parentElement?.outerHTML?.slice(0, 800) || null, + computedWeight: el ? getComputedStyle(el).fontWeight : null, + styleText: styleEl?.textContent || null, + rules, + }; + }, + variantContentSelector, + 5_000, + 'variant style snapshot', + ).catch((err) => ({ error: err.message })); + t.diagnostic('--- variant style snapshot ---'); + t.diagnostic(JSON.stringify(styleSnapshot, null, 2)); + } assert.equal( variantWeight, '900', @@ -464,6 +548,7 @@ for (const { name, fixture } of fixtures) { assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed'); assert.doesNotMatch(final, /impeccable-carbonize-start/, 'carbonize-start marker removed'); assert.doesNotMatch(final, /impeccable-carbonize-end/, 'carbonize-end marker removed'); + assert.doesNotMatch(final, /data-impeccable-carbonize=/, 'carbonize wrapper removed'); assert.doesNotMatch(final, /data-impeccable-variant="/, 'no leftover variant scaffolding'); if (isInsert) { if (agentMode === 'fake') { @@ -544,6 +629,14 @@ for (const { name, fixture } of fixtures) { ); } } catch (err) { + await captureLiveE2eFailure({ + name, + fixture, + session, + sourceFile, + error: err, + log: (m) => t.diagnostic(m), + }); if (knownLimitation) { t.diagnostic(`KNOWN LIMITATION: ${knownLimitation}`); t.diagnostic(`Failure: ${err.message?.split('\n')[0] || err}`); @@ -552,15 +645,15 @@ for (const { name, fixture } of fixtures) { } throw err; } finally { - await teardown(); + await teardownAndResetBrowser(teardown); } }); - if (Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) { + if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) { const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || ''; for (const scenario of fixture.runtime.manualEditScenarios) { if (manualScenarioFilter && !scenario.name.includes(manualScenarioFilter)) continue; - it(`Edit copy → Save → Apply/commit: ${scenario.name}`, async (t) => { + it(`Edit copy → Save → Apply/commit: ${scenario.name}`, liveE2eTestOptions, async (t) => { const manualAgent = await createManualScenarioAgent(t, scenario); if (!manualAgent) return; const { agent, agentMode, probeState } = manualAgent; @@ -591,14 +684,14 @@ for (const { name, fixture } of fixtures) { assert.equal(probeState?.applyCalls, 1, 'manual_edit_apply event should not be redelivered after the correct ack'); } } finally { - await teardown(); + await teardownAndResetBrowser(teardown); } }); } } - if (fixture.runtime.liveChrome?.annotations) { - it('uploads annotations with generate and still accepts the variant', async (t) => { + if (shouldRunScenario('annotations') && fixture.runtime.liveChrome?.annotations) { + it('uploads annotations with generate and still accepts the variant', liveE2eTestOptions, async (t) => { if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { t.skip('manual scenario filter is active'); return; @@ -661,13 +754,13 @@ for (const { name, fixture } of fixtures) { await waitForBarHidden(page); await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget }); } finally { - await teardown(); + await teardownAndResetBrowser(teardown); } }); } - if (fixture.runtime.liveChrome?.bottomBar) { - it('Exit removes live chrome cleanly', async (t) => { + if (shouldRunScenario('exit') && fixture.runtime.liveChrome?.bottomBar) { + it('Exit removes live chrome cleanly', liveE2eTestOptions, async (t) => { if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { t.skip('manual scenario filter is active'); return; @@ -705,6 +798,89 @@ function recordGenerateEvents(agent, events) { }; } +async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error, log = () => {} }) { + const root = process.env.IMPECCABLE_E2E_ARTIFACT_DIR; + if (!root || !session?.tmp) return; + + try { + const tmp = session.tmp; + const dir = join(root, `${safeArtifactName(name)}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + + writeFileSync(join(dir, 'error.txt'), String(error?.stack || error?.message || error || ''), 'utf-8'); + writeFileSync(join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2), 'utf-8'); + writeFileSync(join(dir, 'console-errors.log'), (session.consoleErrors || []).join('\n'), 'utf-8'); + writeFileSync(join(dir, 'dev-server.log'), session.dev?.log?.() || '', 'utf-8'); + writeCommandOutput(dir, 'git-status.txt', tmp, ['status', '--short']); + writeCommandOutput(dir, 'git-diff.patch', tmp, ['diff', '--', '.']); + + const locatedSource = sourceFile || await locateSessionFile(tmp).catch(() => null); + if (locatedSource && existsSync(locatedSource)) { + writeFileSync(join(dir, 'source-file.txt'), relative(tmp, locatedSource), 'utf-8'); + copyFileFromTmp(tmp, locatedSource, join(dir, 'sources')); + const sourceShadow = sourceShadowTargetFor(locatedSource); + if (sourceShadow && existsSync(sourceShadow)) copyFileFromTmp(tmp, sourceShadow, join(dir, 'sources')); + const svelteTarget = svelteComponentTargetFor(locatedSource); + if (svelteTarget?.sourceFile && existsSync(svelteTarget.sourceFile)) { + copyFileFromTmp(tmp, svelteTarget.sourceFile, join(dir, 'sources')); + } + } + + for (const file of walkSources(tmp)) copyFileFromTmp(tmp, file, join(dir, 'sources')); + copyDirIfExists(join(tmp, '.impeccable', 'live'), join(dir, 'impeccable-live')); + copyDirIfExists(join(tmp, 'node_modules', '.impeccable-live'), join(dir, 'impeccable-live-preview')); + + if (session.page) { + const html = await withCaptureTimeout(session.page.content(), 5_000, 'page content').catch((err) => `capture failed: ${err.message}`); + writeFileSync(join(dir, 'page.html'), html, 'utf-8'); + await withCaptureTimeout( + session.page.screenshot({ path: join(dir, 'page.png'), fullPage: true }), + 5_000, + 'page screenshot', + ).catch((err) => writeFileSync(join(dir, 'screenshot-error.txt'), err.message, 'utf-8')); + } + + log(`Failure artifacts written to ${dir}`); + } catch (captureErr) { + log(`Failure artifact capture failed: ${captureErr.message}`); + } +} + +function writeCommandOutput(dir, fileName, cwd, args) { + try { + const output = execFileSync('git', args, { cwd, encoding: 'utf-8' }); + writeFileSync(join(dir, fileName), output, 'utf-8'); + } catch (err) { + writeFileSync(join(dir, fileName), [err.stdout, err.stderr, err.message].filter(Boolean).join('\n'), 'utf-8'); + } +} + +function copyFileFromTmp(tmp, file, destRoot) { + const rel = relative(tmp, file); + if (!rel || rel.startsWith('..')) return; + const dest = join(destRoot, rel); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(file, dest); +} + +function copyDirIfExists(from, to) { + if (!existsSync(from)) return; + mkdirSync(dirname(to), { recursive: true }); + cpSync(from, to, { recursive: true }); +} + +function safeArtifactName(name) { + return String(name || 'fixture').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'fixture'; +} + +function withCaptureTimeout(promise, timeoutMs, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + async function createManualScenarioAgent(t, scenario = {}) { const requested = (process.env.IMPECCABLE_E2E_MANUAL_AGENT || process.env.IMPECCABLE_E2E_AGENT || 'auto') .trim() @@ -1084,6 +1260,7 @@ async function waitForAcceptedDom(page, selector, { allowVariantRoot = false, ti if (all.length < 1) return false; for (const el of all) { if (el.closest('[data-impeccable-variants]')) return false; + if (el.closest('[data-impeccable-carbonize]')) return false; if (!allowVariantRoot && el.closest('[data-impeccable-variant]')) return false; } return true; @@ -1140,6 +1317,25 @@ async function assertVisibleText(page, selector, text, { timeout = 20_000 } = {} } } +async function readVisibleVariantForCycle(page, { timeout = 5_000 } = {}) { + const start = Date.now(); + let last = null; + while (Date.now() - start < timeout) { + last = await getVisibleVariant(page); + if (Number.isInteger(last) && last > 0) return last; + await page.waitForTimeout(250); + } + return last; +} + +async function evaluatePageWithTimeout(page, fn, arg, timeoutMs, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([page.evaluate(fn, arg), timeout]).finally(() => clearTimeout(timer)); +} + async function getServerManualEditStashCount(live, pageUrl = '/') { const res = await fetch( `http://localhost:${live.port}/manual-edit-stash?token=${encodeURIComponent(live.token)}&pageUrl=${encodeURIComponent(pageUrl)}`, @@ -1233,6 +1429,7 @@ async function waitForSourceClean(filePath, timeoutMs, { svelteComponentTarget: last.includes('data-impeccable-variants=') || last.includes('impeccable-variants-start') || last.includes('impeccable-carbonize-start') || + last.includes('data-impeccable-carbonize=') || last.includes('data-impeccable-variant='); if (!dirty) return last; await new Promise((r) => setTimeout(r, 100)); diff --git a/tests/live-e2e/agent.mjs b/tests/live-e2e/agent.mjs index 32a398bff..2db743c69 100644 --- a/tests/live-e2e/agent.mjs +++ b/tests/live-e2e/agent.mjs @@ -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 `
` 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 `
`, 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]*)]*\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 `
` 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 (!//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 }); +} diff --git a/tests/live-e2e/session.mjs b/tests/live-e2e/session.mjs index 10445c5b1..0ee220378 100644 --- a/tests/live-e2e/session.mjs +++ b/tests/live-e2e/session.mjs @@ -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`; +} diff --git a/tests/live-e2e/ui.mjs b/tests/live-e2e/ui.mjs index 2df64470a..dc533840f 100644 --- a/tests/live-e2e/ui.mjs +++ b/tests/live-e2e/ui.mjs @@ -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; + } } /** diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 0dd55a37f..cc3cf8275 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -3,10 +3,11 @@ * * Creates real temp directories, runs the CLI, and verifies results. * - * Pure blocks (already-installed detection, unprefix migration) run in the - * default `bun run test`. Network blocks that download the universal bundle use - * `describeNet` and run only under `bun run test:cli-e2e` (IMPECCABLE_CLI_E2E=1), - * skipping gracefully when impeccable.style is unreachable. + * Deterministic install/update coverage uses a local universal bundle override + * and runs in the default suite. Remote smoke blocks that download the + * production universal bundle use `describeRemote` and run only under + * `bun run test:cli-remote-e2e` (IMPECCABLE_CLI_REMOTE_E2E=1), skipping + * gracefully when impeccable.style is unreachable. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { execSync } from 'child_process'; @@ -57,6 +58,24 @@ function createFakeLinkSource(root, providers = ['.claude']) { } } +function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cursor']) { + const bundleRoot = join(root, 'universal-bundle'); + for (const provider of providers) { + const skillDir = join(bundleRoot, provider, 'skills', 'impeccable'); + mkdirSync(join(skillDir, 'scripts'), { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), [ + '---', + 'name: impeccable', + 'version: 9.9.9-local', + '---', + '', + `Local deterministic bundle for ${provider}.`, + ].join('\n')); + writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n'); + } + return bundleRoot; +} + /** * Simulate an install from the era when the CLI offered a command prefix: the * skill lives at `impeccable`. Optionally drop in a third-party skill @@ -71,19 +90,19 @@ function createPrefixedInstall(root, { prefix = 'i-', providers = ['.claude'], f // ─── Already-installed detection ───────────────────────────────────────────── -// Network e2e blocks (real bundle downloads from impeccable.style) run only -// under `bun run test:cli-e2e` (IMPECCABLE_CLI_E2E=1). The default `bun run test` -// skips them so it stays fast and works offline; when opted in they still skip -// gracefully if the bundle endpoint is unreachable. -const WANT_CLI_E2E = process.env.IMPECCABLE_CLI_E2E === '1'; +// Remote e2e blocks (real bundle downloads from impeccable.style) run only +// under `bun run test:cli-remote-e2e` (IMPECCABLE_CLI_REMOTE_E2E=1). The default +// suite skips them so it stays offline and stable; when opted in they still +// skip gracefully if the bundle endpoint is unreachable. +const WANT_CLI_REMOTE_E2E = process.env.IMPECCABLE_CLI_REMOTE_E2E === '1'; let bundleReachable = false; -if (WANT_CLI_E2E) { +if (WANT_CLI_REMOTE_E2E) { try { execSync('curl -sfIL --max-time 10 https://impeccable.style/api/download/bundle/universal -o /dev/null', { stdio: 'pipe' }); bundleReachable = true; } catch {} } -const describeNet = (WANT_CLI_E2E && bundleReachable) ? describe : describe.skip; +const describeRemote = (WANT_CLI_REMOTE_E2E && bundleReachable) ? describe : describe.skip; describe('skills install: already-installed detection', () => { test('detects impeccable sentinel and bails', () => { @@ -291,9 +310,77 @@ describe('skills: unprefix migration', () => { }); }); -// ─── Update fallback (direct download) ─────────────────────────────────────── +// ─── Install/update from local universal bundle ────────────────────────────── -describeNet('skills update: refreshes from the universal bundle', () => { +describe('skills install/update: local universal bundle e2e', () => { + test('installs provider-specific skills into a fresh project', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-install-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp); + + const output = run('skills install -y --providers=claude,codex,cursor', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + expect(output).toContain('Done!'); + + for (const provider of ['.claude', '.agents', '.cursor']) { + const skillDir = join(tmp, provider, 'skills', 'impeccable'); + expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(true); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain(`Local deterministic bundle for ${provider}.`); + expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true); + } + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('updates stale copied skills from the local bundle', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-update-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + + const skillDir = join(tmp, '.claude', 'skills', 'impeccable'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n'); + + const output = run('skills update -y', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + expect(output).toContain('Updated'); + + const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf8'); + expect(content).not.toContain('stale: true'); + expect(content).toContain('version: 9.9.9-local'); + expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('--force reinstall over an old prefixed install lands on canonical impeccable', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-force-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + const prefixed = join(tmp, '.claude', 'skills', 'i-impeccable'); + mkdirSync(prefixed, { recursive: true }); + writeFileSync(join(prefixed, 'SKILL.md'), '---\nname: i-impeccable\n---\n'); + + run('skills install -y --force --providers=claude', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + + const skills = readdirSync(join(tmp, '.claude', 'skills')); + expect(skills).toContain('impeccable'); + expect(skills).not.toContain('i-impeccable'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); +}); + +// ─── Update fallback (remote direct download smoke) ────────────────────────── + +describeRemote('skills update: refreshes from the production universal bundle', () => { let tmp; beforeAll(() => { @@ -328,9 +415,9 @@ describeNet('skills update: refreshes from the universal bundle', () => { }); }); -// ─── Full install e2e (downloads the universal bundle) ─────────────────────── +// ─── Full install remote smoke (downloads the production universal bundle) ─── -describeNet('skills install: full e2e (universal bundle download)', () => { +describeRemote('skills install: production universal bundle download', () => { let tmp; beforeAll(() => { diff --git a/tests/test-suites.test.mjs b/tests/test-suites.test.mjs new file mode 100644 index 000000000..72f0436fb --- /dev/null +++ b/tests/test-suites.test.mjs @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_SUITES, + OPT_IN_SUITES, + SUITES, + expandSuites, + findTestFiles, + suiteFiles, +} from '../scripts/test-suites.mjs'; + +describe('test suite registry', () => { + it('assigns every test file to a default or opt-in suite', () => { + const allDiscovered = findTestFiles(); + const allRegistered = new Set(suiteFiles([...DEFAULT_SUITES, ...OPT_IN_SUITES])); + const missing = allDiscovered.filter((file) => !allRegistered.has(file)); + + assert.deepEqual( + missing, + [], + 'new test files must be added to scripts/test-suites.mjs, either in a default suite or an opt-in suite', + ); + }); + + it('keeps default local suites free of duplicate test files', () => { + const files = suiteFiles(DEFAULT_SUITES); + const duplicates = files.filter((file, index) => files.indexOf(file) !== index); + + assert.deepEqual(duplicates, []); + }); + + it('keeps opt-in suites out of the default alias', () => { + const expanded = expandSuites(['default']); + for (const suite of expanded) { + assert.equal(SUITES[suite].optIn, undefined, `${suite} should not be opt-in`); + } + }); +});