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