mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 02:26:31 +03:00
Add Svelte-native live mode adapter (#179)
* Fix live preview state for framework components * Complete stateful live preview coverage * Record Svelte manual validation * Fix Svelte live mode adapter * Fix live Steer apply flow * Fix Svelte live variant refresh recovery * Fix live exit bar teardown * Consolidate Svelte live DeepSeek sweep * Reconcile Svelte live browser after main rebase * Fix live accept review regressions * Fix carbonize column-zero indentation * Fix live poll lease expiry flake * Fix Svelte shader preview capture
This commit is contained in:
+537
-52
@@ -32,16 +32,21 @@ import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
|
||||
import {
|
||||
assertApplyDockVisible,
|
||||
assertApplyDockLoading,
|
||||
assertAnnotationUploadEvent,
|
||||
assertSourceApplied,
|
||||
clickExitLiveMode,
|
||||
clickAccept,
|
||||
clickApplyEdits,
|
||||
clickEditCopy,
|
||||
clickSaveEdit,
|
||||
clickGo,
|
||||
clickNext,
|
||||
clickPrev,
|
||||
editTextLeaf,
|
||||
drawAnnotationPinAndStroke,
|
||||
getVisibleVariant,
|
||||
pickElement,
|
||||
runLiveChromeBottomBarSmoke,
|
||||
waitForApplyDockHidden,
|
||||
waitForBarHidden,
|
||||
waitForCycling,
|
||||
@@ -80,6 +85,8 @@ const fixtures = onlyName
|
||||
|
||||
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';
|
||||
|
||||
if (fixtures.length === 0) {
|
||||
describe('live-e2e (no runtime fixtures registered)', () => {
|
||||
@@ -154,7 +161,7 @@ for (const { name, fixture } of fixtures) {
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: agentMode === 'llm' ? wrapTargetFromPickedElement : undefined,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
|
||||
@@ -163,15 +170,34 @@ for (const { name, fixture } of fixtures) {
|
||||
const isInsert = fixture.runtime.mode === 'insert';
|
||||
const insertCfg = fixture.runtime.insert || {};
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
const insertDomSelector = agentMode === 'llm' && insertCfg.expectSelectorLlm
|
||||
? insertCfg.expectSelectorLlm
|
||||
: (insertCfg.expectSelector || '.inserted-strip');
|
||||
const domSelector = isInsert
|
||||
? (insertCfg.expectSelector || '.inserted-strip')
|
||||
? insertDomSelector
|
||||
: pickSelector;
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture);
|
||||
const variantContentSelector = isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
|
||||
: usesSvelteComponentPreview
|
||||
? pickSelector
|
||||
: '[data-impeccable-variant="2"] > :first-child';
|
||||
let stateProbeBaseline = null;
|
||||
|
||||
try {
|
||||
// 1. Handshake
|
||||
t.diagnostic('Waiting for live handshake');
|
||||
await waitForHandshake(page);
|
||||
|
||||
if (fixture.runtime.liveChrome?.bottomBar) {
|
||||
t.diagnostic('Running live chrome bottom-bar smoke');
|
||||
await runLiveChromeBottomBarSmoke(page, {
|
||||
expectDetectMinCount: fixture.runtime.liveChrome.detect?.expectMinCount || 1,
|
||||
designTitle: fixture.runtime.liveChrome.design?.title || '',
|
||||
designRawText: fixture.runtime.liveChrome.design?.rawText || '',
|
||||
});
|
||||
}
|
||||
|
||||
// 1b. Steer smoke — page-level chat before the heavier generate cycle.
|
||||
if (fixture.runtime.steer !== false) {
|
||||
const steerTimeouts = agentMode === 'llm'
|
||||
@@ -185,6 +211,9 @@ for (const { name, fixture } of fixtures) {
|
||||
if (fixture.runtime.preActions) {
|
||||
t.diagnostic(`Running ${fixture.runtime.preActions.length} preAction(s)`);
|
||||
await runPreActions(page, fixture.runtime.preActions);
|
||||
if (fixture.runtime.stateProbe) {
|
||||
stateProbeBaseline = await assertStateProbe(page, fixture.runtime.stateProbe, 'after preActions');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Start generate — replace picks an element; insert places a placeholder.
|
||||
@@ -231,19 +260,52 @@ for (const { name, fixture } of fixtures) {
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
if (fixture.runtime.stateProbe) {
|
||||
await assertStateProbe(page, fixture.runtime.stateProbe, 'after variants', { baseline: stateProbeBaseline });
|
||||
}
|
||||
|
||||
// 5. Source-side check: wrapper + style + variants are present
|
||||
const sourceFile = await locateSessionFile(tmp);
|
||||
const after = readFileSync(sourceFile, 'utf-8');
|
||||
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
|
||||
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
|
||||
if (svelteComponentSession) {
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte');
|
||||
const variantBody = readFileSync(variantFile, 'utf-8');
|
||||
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted');
|
||||
if (isInsert) {
|
||||
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
|
||||
if (agentMode === 'fake') {
|
||||
assert.match(variantBody, /inserted-strip/, 'Svelte insert variant component contains inserted content');
|
||||
} else if (insertCfg.expectSourcePattern) {
|
||||
assert.match(variantBody, new RegExp(insertCfg.expectSourcePattern, 'i'), 'Svelte insert variant component contains prompt-matching content');
|
||||
} else {
|
||||
assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element');
|
||||
}
|
||||
} else {
|
||||
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element');
|
||||
}
|
||||
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation');
|
||||
} else {
|
||||
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
|
||||
}
|
||||
if (isInsert) {
|
||||
assert.match(after, /data-impeccable-mode="insert"/, 'insert mode wrapper');
|
||||
assert.doesNotMatch(after, /data-impeccable-variant="original"/, 'insert has no original variant');
|
||||
if (svelteComponentSession) {
|
||||
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert uses component preview mode');
|
||||
} else {
|
||||
assert.match(after, /data-impeccable-mode="insert"/, 'insert mode wrapper');
|
||||
assert.doesNotMatch(after, /data-impeccable-variant="original"/, 'insert has no original variant');
|
||||
}
|
||||
if (insertCfg.assertAnchorContains) {
|
||||
assert.match(after, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched');
|
||||
const anchorSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8')
|
||||
: after;
|
||||
assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched');
|
||||
}
|
||||
}
|
||||
if (sourceFile.endsWith('.astro')) {
|
||||
if (svelteComponentSession) {
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /<style>/, 'Svelte component variant has scoped style block');
|
||||
} else if (sourceFile.endsWith('.astro')) {
|
||||
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
|
||||
assert.match(
|
||||
after,
|
||||
@@ -262,60 +324,163 @@ for (const { name, fixture } of fixtures) {
|
||||
// three kinds; the LLM agent is non-deterministic and may legitimately
|
||||
// emit no params per the live.md spec ("variants are fixed points").
|
||||
if (agentMode === 'fake') {
|
||||
assert.match(after, /data-impeccable-params=/, 'data-impeccable-params manifest emitted');
|
||||
const paramsSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'params.json'), 'utf-8')
|
||||
: after;
|
||||
assert.match(paramsSource, svelteComponentSession ? /"1"\s*:/ : /data-impeccable-params=/, 'params manifest emitted');
|
||||
for (const kind of ['range', 'steps', 'toggle']) {
|
||||
assert.match(after, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
|
||||
assert.match(paramsSource, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Cycle to variant 2 (the bold one in the fake agent)
|
||||
t.diagnostic('Cycling to variant 2');
|
||||
await clickNext(page);
|
||||
const visible = await getVisibleVariant(page);
|
||||
assert.equal(visible, 2, 'variant 2 visible after one Next');
|
||||
if (agentMode === 'fake') {
|
||||
const variantSel = isInsert
|
||||
? '[data-impeccable-variant="2"] .inserted-copy'
|
||||
: '[data-impeccable-variant="2"] > h1';
|
||||
await page.waitForFunction((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el && getComputedStyle(el).fontWeight === '900';
|
||||
}, variantSel, { timeout: 5_000 }).catch(() => {});
|
||||
const variantWeight = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el ? getComputedStyle(el).fontWeight : null;
|
||||
}, variantSel);
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
'900',
|
||||
'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape',
|
||||
);
|
||||
// 6. Cycle variants. Most fixtures stop at variant 2; Svelte Insert
|
||||
// also exercises right/right/left/right and accepts variant 3.
|
||||
const cycleSequence = Array.isArray(fixture.runtime.variantSequence) && fixture.runtime.variantSequence.length > 0
|
||||
? fixture.runtime.variantSequence
|
||||
: [2];
|
||||
let visible = await getVisibleVariant(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);
|
||||
}
|
||||
assert.equal(visible, targetVariant, `variant ${targetVariant} visible`);
|
||||
if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) {
|
||||
await page.waitForFunction((sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
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);
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
'900',
|
||||
'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape',
|
||||
);
|
||||
checkedVariantTwoStyle = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Accept variant 2
|
||||
t.diagnostic('Accepting variant 2');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
if (reloadVariants && usesSvelteComponentPreview) {
|
||||
const visibleBeforeReload = await getVisibleVariant(page);
|
||||
t.diagnostic(`Reload recovery probe at variant ${visibleBeforeReload}/${expectedCount}`);
|
||||
const savedBeforeReload = await readLiveSessionStorage(page);
|
||||
assert.ok(savedBeforeReload, 'local session exists before reload');
|
||||
assert.equal(savedBeforeReload.visible, visibleBeforeReload, 'local session stores visible variant before reload');
|
||||
assert.equal(savedBeforeReload.previewMode, 'svelte-component', 'local session stores Svelte preview mode before reload');
|
||||
assert.ok(savedBeforeReload.previewFile, 'local session stores Svelte preview manifest before reload');
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
|
||||
const savedAfterReload = await readLiveSessionStorage(page);
|
||||
assert.ok(savedAfterReload, 'local session exists after reload');
|
||||
assert.equal(savedAfterReload.id, savedBeforeReload.id, 'same live session id survives refresh');
|
||||
assert.equal(savedAfterReload.visible, visibleBeforeReload, 'fresh local visible variant wins after refresh');
|
||||
assert.equal(savedAfterReload.previewFile, savedBeforeReload.previewFile, 'preview manifest survives refresh');
|
||||
|
||||
if (fixture.runtime.preActions?.length) {
|
||||
await waitForRecoverableVariantSession(page, visibleBeforeReload, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
await runPreActions(page, fixture.runtime.preActions);
|
||||
}
|
||||
|
||||
await waitForCyclingRobust(page, expectedCount, {
|
||||
agentMode,
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
await waitForVariantCounter(page, visibleBeforeReload, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(await getVisibleVariant(page), visibleBeforeReload, 'same visible variant is restored after refresh');
|
||||
|
||||
if (visibleBeforeReload < expectedCount) {
|
||||
await clickNext(page);
|
||||
await waitForVariantCounter(page, visibleBeforeReload + 1, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(await getVisibleVariant(page), visibleBeforeReload + 1, 'next arrow still works after refresh restore');
|
||||
await clickPrev(page);
|
||||
await waitForVariantCounter(page, visibleBeforeReload, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(await getVisibleVariant(page), visibleBeforeReload, 'prev arrow still works after refresh restore');
|
||||
} else {
|
||||
await clickPrev(page);
|
||||
await waitForVariantCounter(page, visibleBeforeReload - 1, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(await getVisibleVariant(page), visibleBeforeReload - 1, 'prev arrow still works after refresh restore');
|
||||
await clickNext(page);
|
||||
await waitForVariantCounter(page, visibleBeforeReload, expectedCount, {
|
||||
timeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(await getVisibleVariant(page), visibleBeforeReload, 'next arrow still works after refresh restore');
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Accept the final visible variant
|
||||
const acceptVariant = cycleSequence[cycleSequence.length - 1] || 2;
|
||||
t.diagnostic(`Accepting variant ${acceptVariant}`);
|
||||
await clickAccept(page, { expectedVariant: acceptVariant });
|
||||
await waitForBarHidden(page);
|
||||
const sourceShadow = !!sourceShadowTargetFor(sourceFile);
|
||||
const svelteComponentTarget = svelteComponentSession || svelteComponentTargetFor(sourceFile);
|
||||
const svelteComponent = !!svelteComponentTarget;
|
||||
if (fixture.runtime.stateProbe && !svelteComponent) {
|
||||
await assertStateProbe(page, fixture.runtime.stateProbe, 'after accept', { baseline: stateProbeBaseline });
|
||||
}
|
||||
if (sourceShadow && typeof session.stopLiveServer === 'function') {
|
||||
t.diagnostic('Stopping live-server to flush deferred accept');
|
||||
session.stopLiveServer();
|
||||
}
|
||||
|
||||
// 8. Wait for live-accept + the agent's carbonize cleanup to land.
|
||||
// File-side: wrapper, all variants, and carbonize markers gone;
|
||||
// only the accepted inner element survives.
|
||||
t.diagnostic('Waiting for accept + carbonize cleanup to land');
|
||||
const final = await waitForSourceClean(sourceFile, 20_000);
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
if (svelteComponentTarget) {
|
||||
assert.equal(existsSync(svelteComponentTarget.manifestPath), false, 'Svelte temp preview session removed after accept');
|
||||
const snapshotPath = join(tmp, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`);
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
assert.equal(snapshot.phase, 'completed');
|
||||
assert.equal(snapshot.sourceFile, svelteComponentTarget.manifest.sourceFile);
|
||||
assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live/);
|
||||
}
|
||||
assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed');
|
||||
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-variant="/, 'no leftover variant scaffolding');
|
||||
if (isInsert) {
|
||||
assert.match(final, /inserted-strip/, 'accepted insert content survives');
|
||||
if (agentMode === 'fake') {
|
||||
assert.match(final, /inserted-strip/, 'accepted insert content survives');
|
||||
} else if (insertCfg.expectSourcePattern) {
|
||||
assert.match(final, new RegExp(insertCfg.expectSourcePattern, 'i'), 'accepted insert content survives');
|
||||
}
|
||||
if (insertCfg.assertAnchorContains) {
|
||||
assert.match(final, new RegExp(insertCfg.assertAnchorContains), 'anchor section still in source');
|
||||
}
|
||||
} else {
|
||||
const acceptedSourcePattern = fixture.runtime.acceptedSourcePattern
|
||||
|| '<h1[^>]*(class|className)="[^"]*\\bhero-title\\b[^"]*"';
|
||||
assert.match(
|
||||
final,
|
||||
/<h1[^>]*(class|className)="[^"]*\bhero-title\b[^"]*"/,
|
||||
'accepted h1 survives with hero-title class',
|
||||
new RegExp(acceptedSourcePattern),
|
||||
'accepted source element survives',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,18 +498,19 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
|
||||
// 9. DOM-side: at least one matching element, none inside any wrapper.
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const all = document.querySelectorAll(sel);
|
||||
if (all.length < 1) return false;
|
||||
for (const el of all) {
|
||||
if (el.closest('[data-impeccable-variants],[data-impeccable-variant]')) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
domSelector,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
if (svelteComponent && fixture.runtime.preActions) {
|
||||
await runPreActions(page, fixture.runtime.preActions);
|
||||
}
|
||||
try {
|
||||
await waitForAcceptedDom(page, domSelector, { allowVariantRoot: sourceShadow, timeout: 20_000 });
|
||||
} catch (err) {
|
||||
if (!svelteComponent || !fixture.runtime.preActions) throw err;
|
||||
t.diagnostic('Accepted Svelte DOM was not visible after HMR; reloading and re-running preActions');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
await runPreActions(page, fixture.runtime.preActions);
|
||||
await waitForAcceptedDom(page, domSelector, { allowVariantRoot: sourceShadow, timeout: 20_000 });
|
||||
}
|
||||
|
||||
// 9b. reloadProbe — fixtures with conditional render assert that the
|
||||
// accepted variant survives a full page reload. The picked element
|
||||
@@ -430,6 +596,98 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fixture.runtime.liveChrome?.annotations) {
|
||||
it('uploads annotations with generate and still accepts the variant', async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
const agentMode = process.env.IMPECCABLE_E2E_AGENT || 'fake';
|
||||
const recordedGenerateEvents = [];
|
||||
let baseAgent;
|
||||
if (agentMode === 'llm') {
|
||||
const llmConfig = resolveLlmAgentConfig({
|
||||
model: process.env.IMPECCABLE_E2E_LLM_MODEL,
|
||||
});
|
||||
baseAgent = await createLlmAgent({
|
||||
config: llmConfig,
|
||||
log: (m) => t.diagnostic('[llm] ' + m),
|
||||
});
|
||||
if (!baseAgent) {
|
||||
t.skip(`IMPECCABLE_E2E_AGENT=llm with provider=${llmConfig.provider} requires ${llmConfig.requiredEnv}`);
|
||||
return;
|
||||
}
|
||||
t.diagnostic(`Using LLM agent (provider=${llmConfig.provider} model=${llmConfig.model})`);
|
||||
} else {
|
||||
baseAgent = createFakeAgent();
|
||||
}
|
||||
const agent = recordGenerateEvents(baseAgent, recordedGenerateEvents);
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, teardown } = session;
|
||||
const annotation = fixture.runtime.liveChrome.annotations;
|
||||
const pickSelector = annotation.selector || fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||
await pickElement(page, pickSelector, { resetPickMode: true });
|
||||
await drawAnnotationPinAndStroke(page, {
|
||||
comment: annotation.comment || 'Make this selected element easier to scan',
|
||||
});
|
||||
await clickGo(page);
|
||||
await waitForCyclingRobust(page, 3, {
|
||||
agentMode,
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
|
||||
const generateEvent = recordedGenerateEvents.at(-1);
|
||||
await assertAnnotationUploadEvent(generateEvent);
|
||||
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
||||
assert.match(generateEvent.screenshotPath, /\.impeccable\/live\/annotations\//, 'annotation screenshot is stored under live annotations');
|
||||
|
||||
const sourceFile = await locateSessionFile(session.tmp);
|
||||
const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
await waitForBarHidden(page);
|
||||
await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (fixture.runtime.liveChrome?.bottomBar) {
|
||||
it('Exit removes live chrome cleanly', async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
try {
|
||||
await waitForHandshake(session.page);
|
||||
await clickExitLiveMode(session.page);
|
||||
} finally {
|
||||
await session.teardown();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,6 +695,16 @@ for (const { name, fixture } of fixtures) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function recordGenerateEvents(agent, events) {
|
||||
return {
|
||||
...agent,
|
||||
async generateVariants(event, context) {
|
||||
events.push(event);
|
||||
return agent.generateVariants(event, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createManualScenarioAgent(t, scenario = {}) {
|
||||
const requested = (process.env.IMPECCABLE_E2E_MANUAL_AGENT || process.env.IMPECCABLE_E2E_AGENT || 'auto')
|
||||
.trim()
|
||||
@@ -528,7 +796,9 @@ function wrapTargetFromPickedElement(event) {
|
||||
const tag = typeof element.tagName === 'string'
|
||||
? element.tagName.trim().toLowerCase()
|
||||
: '';
|
||||
const classes = typeof element.className === 'string'
|
||||
const classes = Array.isArray(element.classes)
|
||||
? element.classes.filter(Boolean).join(' ')
|
||||
: typeof element.className === 'string'
|
||||
? element.className.trim().split(/\s+/).filter(Boolean).join(' ')
|
||||
: extractClassAttr(element.outerHTML);
|
||||
const elementId = typeof element.id === 'string' ? element.id.trim() : '';
|
||||
@@ -537,6 +807,7 @@ function wrapTargetFromPickedElement(event) {
|
||||
tag: tag || 'h1',
|
||||
...(classes ? { classes } : {}),
|
||||
...(elementId ? { elementId } : {}),
|
||||
...(element.textContent ? { text: String(element.textContent).trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -747,6 +1018,81 @@ async function waitForAcceptedSelectionReady(page, selector, { timeout }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function readLiveSessionStorage(page) {
|
||||
return page.evaluate(() => {
|
||||
const raw = localStorage.getItem('impeccable-live-session');
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVariantCounter(page, variant, count, { timeout = 15_000 } = {}) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ variant, count }) => {
|
||||
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
|
||||
const bar = query('#impeccable-live-bar');
|
||||
const text = bar?.textContent || '';
|
||||
return text.includes(`${variant}/${count}`);
|
||||
},
|
||||
{ variant, count },
|
||||
{ timeout },
|
||||
);
|
||||
} catch (err) {
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
|
||||
const bar = query('#impeccable-live-bar');
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
return {
|
||||
barText: bar?.textContent || null,
|
||||
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
|
||||
storage: localStorage.getItem('impeccable-live-session'),
|
||||
wrapper: wrapper ? { preview: wrapper.dataset.impeccablePreview, count: wrapper.dataset.impeccableVariantCount, html: wrapper.outerHTML.slice(0, 500) } : null,
|
||||
};
|
||||
}).catch((snapErr) => ({ error: snapErr.message }));
|
||||
console.error('--- waitForVariantCounter snapshot ---\n' + JSON.stringify(snapshot, null, 2));
|
||||
err.message += '\nVariant counter snapshot: ' + JSON.stringify(snapshot, null, 2);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRecoverableVariantSession(page, variant, count, { timeout = 15_000 } = {}) {
|
||||
await page.waitForFunction(
|
||||
({ variant, count }) => {
|
||||
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
|
||||
const bar = query('#impeccable-live-bar');
|
||||
const text = bar?.textContent || '';
|
||||
const raw = localStorage.getItem('impeccable-live-session');
|
||||
let saved = null;
|
||||
try { saved = raw ? JSON.parse(raw) : null; } catch {}
|
||||
return Boolean(
|
||||
saved
|
||||
&& saved.visible === variant
|
||||
&& saved.expected === count
|
||||
&& saved.previewMode === 'svelte-component'
|
||||
&& /Reveal the selected element to resume/i.test(text)
|
||||
);
|
||||
},
|
||||
{ variant, count },
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAcceptedDom(page, selector, { allowVariantRoot = false, timeout = 20_000 } = {}) {
|
||||
await page.waitForFunction(
|
||||
({ sel, allowVariantRoot }) => {
|
||||
const all = document.querySelectorAll(sel);
|
||||
if (all.length < 1) return false;
|
||||
for (const el of all) {
|
||||
if (el.closest('[data-impeccable-variants]')) return false;
|
||||
if (!allowVariantRoot && el.closest('[data-impeccable-variant]')) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ sel: selector, allowVariantRoot },
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
function assertSourceMissing(tmp, file, text) {
|
||||
const full = join(tmp, file);
|
||||
const body = readFileSync(full, 'utf-8');
|
||||
@@ -858,12 +1204,32 @@ async function clickPickToggle(page, selector) {
|
||||
* Poll the file until carbonize cleanup has landed: no variants wrapper, no
|
||||
* carbonize markers, no leftover variant divs. Returns the final contents.
|
||||
*/
|
||||
async function waitForSourceClean(filePath, timeoutMs) {
|
||||
async function waitForSourceClean(filePath, timeoutMs, { svelteComponentTarget: knownSvelteTarget = null } = {}) {
|
||||
const start = Date.now();
|
||||
let last = '';
|
||||
const shadowTarget = sourceShadowTargetFor(filePath);
|
||||
const svelteTarget = knownSvelteTarget || svelteComponentTargetFor(filePath);
|
||||
if (shadowTarget) {
|
||||
let handled = false;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
last = readFileSync(filePath, 'utf-8');
|
||||
if (last.includes('source-shadow preview handled')) {
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!handled) {
|
||||
throw new Error(`source-shadow preview not handled after ${timeoutMs}ms — last contents:\n${last}`);
|
||||
}
|
||||
filePath = shadowTarget;
|
||||
} else if (svelteTarget) {
|
||||
filePath = svelteTarget.sourceFile;
|
||||
}
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
last = readFileSync(filePath, 'utf-8');
|
||||
const dirty =
|
||||
(svelteTarget && existsSync(svelteTarget.manifestPath)) ||
|
||||
last.includes('data-impeccable-variants=') ||
|
||||
last.includes('impeccable-variants-start') ||
|
||||
last.includes('impeccable-carbonize-start') ||
|
||||
@@ -874,6 +1240,99 @@ async function waitForSourceClean(filePath, timeoutMs) {
|
||||
throw new Error(`source not clean after ${timeoutMs}ms — last contents:\n${last}`);
|
||||
}
|
||||
|
||||
function sourceShadowTargetFor(filePath) {
|
||||
let body;
|
||||
try { body = readFileSync(filePath, 'utf-8'); } catch { return null; }
|
||||
if (!body.includes('data-impeccable-preview="source-shadow"')) return null;
|
||||
const match = body.match(/\bdata-impeccable-source-file=(["'])(.*?)\1/);
|
||||
if (!match) return null;
|
||||
const root = filePath.includes('/.impeccable/')
|
||||
? filePath.slice(0, filePath.indexOf('/.impeccable/'))
|
||||
: dirname(filePath);
|
||||
return join(root, decodeHtmlAttr(match[2]));
|
||||
}
|
||||
|
||||
function svelteComponentTargetFor(filePath) {
|
||||
if (!filePath.endsWith('/manifest.json') && !filePath.endsWith('\\manifest.json')) return null;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
|
||||
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
const sep = pathSepFor(filePath);
|
||||
const markers = [
|
||||
`${sep}node_modules${sep}.impeccable-live${sep}`,
|
||||
`${sep}src${sep}lib${sep}impeccable${sep}`,
|
||||
];
|
||||
const marker = markers.find((candidate) => filePath.includes(candidate));
|
||||
const idx = marker ? filePath.indexOf(marker) : -1;
|
||||
const root = idx === -1 ? dirname(dirname(dirname(dirname(dirname(filePath))))) : filePath.slice(0, idx);
|
||||
return {
|
||||
manifest,
|
||||
manifestPath: filePath,
|
||||
sourceFile: join(root, manifest.sourceFile),
|
||||
expectedTag: expectedTagFromOriginalMarkup(manifest.originalMarkup),
|
||||
};
|
||||
}
|
||||
|
||||
function pathSepFor(filePath) {
|
||||
return filePath.includes('\\') ? '\\' : '/';
|
||||
}
|
||||
|
||||
function expectedTagFromOriginalMarkup(markup) {
|
||||
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)\b/);
|
||||
return match ? match[1] : '[A-Za-z][\\w:-]*';
|
||||
}
|
||||
|
||||
function fixtureUsesSvelteKitAdapter(fixture) {
|
||||
return Array.isArray(fixture?.config?.files)
|
||||
&& fixture.config.files.includes('src/app.html')
|
||||
&& Array.isArray(fixture?.sourceFiles)
|
||||
&& fixture.sourceFiles.some((file) => file.endsWith('.svelte'));
|
||||
}
|
||||
|
||||
function decodeHtmlAttr(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
async function assertStateProbe(page, probe, label, { baseline = null } = {}) {
|
||||
const snapshot = {};
|
||||
if (probe.textSelector) {
|
||||
const actual = await page.locator(probe.textSelector).first().textContent({ timeout: 5_000 });
|
||||
snapshot.text = normalizeText(actual);
|
||||
assert.equal(
|
||||
snapshot.text,
|
||||
normalizeText(probe.expectedText),
|
||||
`stateProbe text ${label}`,
|
||||
);
|
||||
}
|
||||
if (probe.windowProperty) {
|
||||
const actual = await page.evaluate((prop) => window[prop], probe.windowProperty);
|
||||
snapshot.windowValue = actual;
|
||||
if (Object.hasOwn(probe, 'expectedWindowValue')) {
|
||||
assert.equal(
|
||||
actual,
|
||||
probe.expectedWindowValue,
|
||||
`stateProbe ${probe.windowProperty} ${label}`,
|
||||
);
|
||||
}
|
||||
if (probe.expectWindowUnchanged && baseline) {
|
||||
assert.equal(
|
||||
actual,
|
||||
baseline.windowValue,
|
||||
`stateProbe ${probe.windowProperty} unchanged ${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the source file that received the wrapper. We look for any tracked
|
||||
* file containing the variants marker — the agent always writes to exactly
|
||||
@@ -891,9 +1350,35 @@ async function locateSessionFile(tmp) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
for (const f of walkSvelteComponentManifests(tmp)) {
|
||||
const body = readFileSync(f, 'utf-8');
|
||||
if (body.includes('"previewMode": "svelte-component"')) return f;
|
||||
}
|
||||
throw new Error('Could not locate session source file under ' + tmp);
|
||||
}
|
||||
|
||||
function walkSvelteComponentManifests(root) {
|
||||
const results = [];
|
||||
const stack = [
|
||||
join(root, 'node_modules/.impeccable-live'),
|
||||
join(root, 'src/lib/impeccable'),
|
||||
];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (e.name === 'manifest.json') {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function walkSources(root) {
|
||||
const results = [];
|
||||
const stack = [root];
|
||||
|
||||
Reference in New Issue
Block a user