mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
Ship signed skill-bundle verification, fix annotated-session checkpoint ordering, and pin all published engine platform packages. Validated with Rust, Node, browser, and provider-backed end-to-end tests. AI assistance: prepared and validated with Codex under Paul Bakaus direction.
871 lines
34 KiB
JavaScript
871 lines
34 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import {
|
||
cpSync,
|
||
existsSync,
|
||
mkdirSync,
|
||
readdirSync,
|
||
readFileSync,
|
||
rmSync,
|
||
writeFileSync,
|
||
} from 'node:fs';
|
||
import { dirname, join, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs';
|
||
import { bootFixtureSession, FIXTURES_DIR, REPO_ROOT } from './live-e2e/session.mjs';
|
||
import { runSteerSmoke } from './live-e2e/steer.mjs';
|
||
import { runPreActions } from './live-e2e/preactions.mjs';
|
||
import {
|
||
assertAnnotationUploadEvent,
|
||
assertApplyDockVisible,
|
||
clickAccept,
|
||
clickApplyEdits,
|
||
clickDiscard,
|
||
clickEditCopy,
|
||
clickExitLiveMode,
|
||
clickGo,
|
||
cycleToVariant,
|
||
clickSaveEdit,
|
||
drawAnnotationPinAndStroke,
|
||
editTextLeaf,
|
||
getVisibleVariant,
|
||
installLiveQueryHelpers,
|
||
pickElement,
|
||
runInsertFlow,
|
||
runLiveChromeBottomBarSmoke,
|
||
waitForApplyDockHidden,
|
||
waitForBarHidden,
|
||
waitForCycling,
|
||
waitForHandshake,
|
||
waitForVariantSettled,
|
||
} from './live-e2e/ui.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const FIXTURE_NAME = 'vite8-sveltekit-stateful';
|
||
const ROUTE_FILE = 'src/routes/+page.svelte';
|
||
const LAYOUT_FILE = 'src/routes/+layout.svelte';
|
||
const APP_HTML = 'src/app.html';
|
||
const EXPECTED_INSERT_PROMPT =
|
||
'Insert a concise muted footnote below the empty expenses card explaining that shared expenses sync automatically.';
|
||
|
||
const artifactRoot = createArtifactRoot();
|
||
let playwright;
|
||
let browser;
|
||
|
||
if (!process.env.DEEPSEEK_API_KEY) {
|
||
throw new Error('DEEPSEEK_API_KEY is required. This Svelte adapter sweep must never fall back to fake/mock AI.');
|
||
}
|
||
|
||
describe('Svelte live adapter DeepSeek browser sweep', () => {
|
||
before(async () => {
|
||
playwright = await import('playwright');
|
||
browser = await playwright.chromium.launch({ headless: true });
|
||
});
|
||
|
||
after(async () => {
|
||
if (browser) await browser.close();
|
||
});
|
||
|
||
it('covers live chrome, replace, edit, annotations, insert, refresh recovery, accept/discard, and exit', async (t) => {
|
||
const fixture = loadConsolidatedFixture();
|
||
const llmConfig = resolveLlmAgentConfig({ provider: 'deepseek' });
|
||
assert.equal(llmConfig.provider, 'deepseek');
|
||
const agent = await createLlmAgent({
|
||
config: llmConfig,
|
||
log: (m) => t.diagnostic('[deepseek] ' + m),
|
||
});
|
||
assert.ok(agent, 'DeepSeek agent must be available');
|
||
|
||
const session = await bootFixtureSession({
|
||
name: FIXTURE_NAME,
|
||
fixture,
|
||
browser,
|
||
agent,
|
||
wrapTarget: wrapTargetFromPickedElement,
|
||
log: (m) => t.diagnostic(m),
|
||
});
|
||
|
||
const { page, tmp, live, consoleErrors, teardown } = session;
|
||
const evidence = createEvidenceWriter({ page, tmp, live, consoleErrors });
|
||
|
||
try {
|
||
await evidence.capture('00-boot');
|
||
await assertSvelteAdapterInjection(tmp);
|
||
await waitForHandshake(page);
|
||
await assertShadowChrome(page);
|
||
await assertHostileCssIsolation(page);
|
||
await evidence.capture('01-handshake');
|
||
|
||
await runLiveChromeBottomBarSmoke(page, {
|
||
expectDetectMinCount: 1,
|
||
designRawText: 'Design System: Stateful Expense System',
|
||
});
|
||
await evidence.capture('02-bottom-bar');
|
||
|
||
await runSteerSmoke(page, tmp, fixture, (m) => t.diagnostic(m), {
|
||
unlockTimeoutMs: 120_000,
|
||
selectorTimeoutMs: 60_000,
|
||
runPreActions,
|
||
});
|
||
await evidence.capture('03-steer');
|
||
|
||
await runPreActions(page, fixture.runtime.preActions);
|
||
await assertOpenCount(page);
|
||
|
||
const discardSession = await runReplaceDiscardRecoveryFlow({ page, tmp, evidence });
|
||
await evidence.capture('04-replace-discard-recovered');
|
||
await assertDiscardedSession(tmp, discardSession.id);
|
||
|
||
await runEditCopyFlow({ page, tmp, live, evidence });
|
||
await evidence.capture('05-edit-copy-applied');
|
||
|
||
await runAnnotationGenerateFlow({ page, tmp, evidence });
|
||
await evidence.capture('06-annotation-discarded');
|
||
|
||
const acceptedReplace = await runAcceptReplaceFlow({ page, tmp, evidence });
|
||
await assertCompletedRealSource(tmp, acceptedReplace.id);
|
||
await evidence.capture('07-replace-accepted');
|
||
|
||
const acceptedInsert = await runInsertFlowWithRecovery({ page, tmp, evidence });
|
||
await assertCompletedRealSource(tmp, acceptedInsert.id);
|
||
await assertFinalSourceClean(tmp);
|
||
await evidence.capture('08-insert-accepted');
|
||
|
||
const beforeExit = await globalBarSnapshot(page);
|
||
await clickExitLiveMode(page);
|
||
const afterExit = await page.evaluate(() => ({
|
||
liveInit: window.__IMPECCABLE_LIVE_INIT__,
|
||
hasGlobalBar: Boolean(window.__impeccableLiveQuery?.('#impeccable-live-global-bar')),
|
||
}));
|
||
assert.ok(beforeExit.hasBar, 'global bottom bar exists before Exit');
|
||
assert.equal(afterExit.liveInit, false, 'live init flag clears after Exit');
|
||
assert.equal(afterExit.hasGlobalBar, false, 'global bottom bar is removed immediately after Exit');
|
||
await evidence.capture('09-exit');
|
||
|
||
assertNoConsoleErrors(consoleErrors);
|
||
} catch (err) {
|
||
await evidence.capture('failure');
|
||
throw err;
|
||
} finally {
|
||
await teardown();
|
||
}
|
||
});
|
||
});
|
||
|
||
function loadConsolidatedFixture() {
|
||
const fixturePath = join(FIXTURES_DIR, FIXTURE_NAME, 'fixture.json');
|
||
const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'));
|
||
fixture.runtime = {
|
||
styling: 'plain-css',
|
||
install: ['npm', 'install', '--no-audit', '--no-fund', '--loglevel=error'],
|
||
devCommand: ['npx', 'vite', 'dev', '--host', '127.0.0.1'],
|
||
readyPattern: 'Local:\\s+https?://[^:]+:(\\d+)',
|
||
readyTimeoutMs: 120000,
|
||
pickSelector: "[data-testid='expense-row']",
|
||
acceptedSourcePattern: '<article[^>]*class="[^\"]*\\bexpense-row\\b',
|
||
preActions: [
|
||
{ type: 'click', selector: "[data-testid='add-expense']" },
|
||
{ type: 'wait', selector: "[data-testid='expense-row']" },
|
||
],
|
||
stateProbe: {
|
||
textSelector: "[data-testid='open-count']",
|
||
expectedText: '1 offen',
|
||
windowProperty: '__impeccableStatefulMounts',
|
||
expectWindowUnchanged: true,
|
||
},
|
||
steer: {
|
||
message: 'make the title more direct for the expense dashboard',
|
||
sourceFile: ROUTE_FILE,
|
||
target: { classes: 'hero-title', tag: 'h1' },
|
||
expectSelector: 'h1.hero-title[data-impeccable-steer="e2e"]',
|
||
expectSourceContains: 'data-impeccable-steer="e2e"',
|
||
},
|
||
probe: {
|
||
expectLiveInit: true,
|
||
expectConsoleClean: true,
|
||
},
|
||
};
|
||
return fixture;
|
||
}
|
||
|
||
async function runReplaceDiscardRecoveryFlow({ page, tmp, evidence }) {
|
||
const stateBeforeCycling = await readStateProbe(page);
|
||
await pickElement(page, "[data-testid='expense-row']", { resetPickMode: true });
|
||
await assertSelectedElementChrome(page);
|
||
await selectAction(page, 'Polish');
|
||
|
||
const sourceBefore = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
await clickGo(page);
|
||
await waitForVisibleCycling(page, 3, { timeout: 240_000 });
|
||
const session = await currentSveltePreviewSession(page, tmp);
|
||
assert.equal(session.previewMode, 'svelte-component');
|
||
assert.match(session.previewFile, /^node_modules\/\.impeccable-live\/[^/]+\/manifest\.json$/);
|
||
assert.ok(existsSync(join(tmp, dirname(session.previewFile), 'v1.svelte')));
|
||
assert.ok(existsSync(join(tmp, dirname(session.previewFile), 'v2.svelte')));
|
||
assert.ok(existsSync(join(tmp, dirname(session.previewFile), 'v3.svelte')));
|
||
assert.equal(readFileSync(join(tmp, ROUTE_FILE), 'utf-8'), sourceBefore, 'real Svelte source is not mutated while cycling');
|
||
await assertStateProbeUnchanged(page, stateBeforeCycling, 'variant cycling should not remount Svelte app state');
|
||
|
||
await cycleTo(page, 2);
|
||
await cycleTo(page, 3);
|
||
await cycleTo(page, 2);
|
||
await assertVariantCounter(page, 2, 3);
|
||
await evidence.capture('replace-cycle-variant-2');
|
||
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await waitForHandshake(page);
|
||
await assertRecoverableMessage(page, 2, 3);
|
||
await revealExpenseRow(page);
|
||
await waitForVisibleCycling(page, 3, { timeout: 60_000 });
|
||
await assertVariantCounter(page, 2, 3);
|
||
|
||
await clickDiscard(page);
|
||
await waitForBarHidden(page);
|
||
await page.waitForSelector("[data-testid='expense-row']", { state: 'visible', timeout: 20_000 });
|
||
await assertNoCyclingZero(page);
|
||
await assertNoRawSvelteExpressions(page);
|
||
return session;
|
||
}
|
||
|
||
async function runEditCopyFlow({ page, tmp, live, evidence }) {
|
||
await runPreActions(page, [{ type: 'click', selector: "[data-testid='add-expense']" }, { type: 'wait', selector: "[data-testid='expense-row']" }]);
|
||
await pickElement(page, "[data-testid='expense-row']", { resetPickMode: true });
|
||
const sourceBeforeCancel = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
|
||
await clickEditCopy(page);
|
||
await editTextLeaf(page, '.expense-row strong', 'Design snack cancelled');
|
||
await clickEditBadgeAction(page, 'Cancel');
|
||
await page.waitForFunction(
|
||
() => !window.__impeccableLiveQuery('[data-impeccable-editable="true"]'),
|
||
null,
|
||
{ timeout: 5_000 },
|
||
);
|
||
assert.equal(readFileSync(join(tmp, ROUTE_FILE), 'utf-8'), sourceBeforeCancel, 'Cancel does not mutate source');
|
||
await assertPendingDockCount(live, 0);
|
||
|
||
await clickEditCopy(page);
|
||
await editTextLeaf(page, '.expense-row strong', 'Design snack staged');
|
||
await clickSaveEdit(page);
|
||
await assertApplyDockVisible(page, 1);
|
||
await assertPendingDockCount(live, 1);
|
||
await clickPendingTrash(page);
|
||
await waitForApplyDockHidden(page);
|
||
await assertPendingDockCount(live, 0);
|
||
await page.waitForFunction(
|
||
() => document.querySelector('.expense-row strong')?.textContent?.includes('Design snack'),
|
||
null,
|
||
{ timeout: 10_000 },
|
||
);
|
||
|
||
await clickEditCopy(page);
|
||
await editTextLeaf(page, '.expense-row strong', 'Design snack approved');
|
||
await clickSaveEdit(page);
|
||
await assertApplyDockVisible(page, 1);
|
||
await evidence.capture('edit-pending-dock');
|
||
await clickApplyEdits(page);
|
||
await waitForPendingDockCleared(live, { timeout: 240_000 });
|
||
await waitForApplyDockHidden(page, { timeout: 60_000 });
|
||
const sourceAfter = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
assert.match(sourceAfter, /Design snack approved/, 'DeepSeek Apply writes edited copy to source');
|
||
assert.doesNotMatch(sourceAfter, /contenteditable|data-impeccable-editable|data-impeccable-original-text|impeccable-variants/, 'manual Apply does not leak live scaffolding');
|
||
}
|
||
|
||
async function runAnnotationGenerateFlow({ page, tmp, evidence }) {
|
||
await pickElement(page, '.hero-title', { resetPickMode: true });
|
||
await drawAnnotationPinAndStroke(page, { comment: 'Make the page title easier to scan' });
|
||
await selectAction(page, 'Polish');
|
||
await clickGo(page);
|
||
await waitForCycling(page, 3, { timeout: 180_000 });
|
||
const generateEvent = latestJournalEvent(tmp, (event) => event.type === 'generate' && event.screenshotPath);
|
||
await assertAnnotationUploadEvent(generateEvent);
|
||
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
||
await cycleTo(page, 2);
|
||
await assertVariantCounter(page, 2, 3);
|
||
await evidence.capture('annotation-cycle');
|
||
await clickDiscard(page);
|
||
await waitForBarHidden(page);
|
||
}
|
||
|
||
async function runAcceptReplaceFlow({ page, tmp, evidence }) {
|
||
await runPreActions(page, [{ type: 'click', selector: "[data-testid='add-expense']" }, { type: 'wait', selector: "[data-testid='expense-row']" }]);
|
||
await pickElement(page, "[data-testid='expense-row']", { resetPickMode: true });
|
||
await selectAction(page, 'Polish');
|
||
await clickGo(page);
|
||
await waitForVisibleCycling(page, 3, { timeout: 240_000 });
|
||
await cycleTo(page, 3);
|
||
const session = await currentSveltePreviewSession(page, tmp);
|
||
await evidence.capture('replace-before-accept');
|
||
await clickAccept(page, { expectedVariant: 3 });
|
||
await waitForSvelteAcceptComplete(tmp, session, { timeout: 120_000 });
|
||
await waitForBarHidden(page, { timeout: 20_000 }).catch(() => {});
|
||
const source = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
assert.match(source, /expense-row/, 'accepted replace stays in real Svelte source');
|
||
assert.doesNotMatch(source, /data-impeccable-variants|impeccable-variants-start|data-impeccable-variant=/, 'accepted replace source is clean');
|
||
assert.equal(existsSync(join(tmp, dirname(session.previewFile))), false, 'temp Svelte preview folder deleted after accept');
|
||
return session;
|
||
}
|
||
|
||
async function runInsertFlowWithRecovery({ page, tmp, evidence }) {
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await waitForHandshake(page);
|
||
await page.waitForSelector('.empty-card', { state: 'visible', timeout: 20_000 });
|
||
await runInsertFlow(page, {
|
||
anchorSelector: '.empty-card',
|
||
position: 'after',
|
||
prompt: EXPECTED_INSERT_PROMPT,
|
||
});
|
||
const insertEvent = await waitForLatestJournalEvent(tmp, (event) => event.type === 'generate' && event.mode === 'insert');
|
||
assert.equal(insertEvent.mode, 'insert');
|
||
assert.equal(insertEvent.count, 3);
|
||
assert.equal(insertEvent.insert?.position, 'after');
|
||
assert.match(insertEvent.insert?.anchor?.outerHTML || '', /empty-card/);
|
||
|
||
await waitForCycling(page, 3, { timeout: 240_000 });
|
||
await cycleTo(page, 2);
|
||
await cycleTo(page, 3);
|
||
await cycleTo(page, 2);
|
||
await cycleTo(page, 3);
|
||
await assertVariantCounter(page, 3, 3);
|
||
const session = await currentSveltePreviewSession(page, tmp);
|
||
assert.equal(JSON.parse(readFileSync(join(tmp, session.previewFile), 'utf-8')).mode, 'insert');
|
||
await evidence.capture('insert-cycle-variant-3');
|
||
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await waitForHandshake(page);
|
||
await waitForCycling(page, 3, { timeout: 60_000 });
|
||
await assertVariantCounter(page, 3, 3);
|
||
await page.waitForFunction(
|
||
() => Boolean(document.querySelector('.empty-card + *')),
|
||
null,
|
||
{ timeout: 20_000 },
|
||
);
|
||
|
||
await clickAccept(page, { expectedVariant: 3 });
|
||
await waitForSvelteAcceptComplete(tmp, session, { timeout: 120_000 });
|
||
await waitForBarHidden(page, { timeout: 20_000 }).catch(() => {});
|
||
const source = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
assert.match(source, /automatisch|synchron|sync|abgeglichen|shared expenses/i, 'accepted insert content lands in real source');
|
||
assert.doesNotMatch(source, /data-impeccable-variants|impeccable-variants-start|data-impeccable-variant=/, 'accepted insert source is clean');
|
||
assert.equal(existsSync(join(tmp, dirname(session.previewFile))), false, 'insert temp Svelte preview folder deleted after accept');
|
||
return session;
|
||
}
|
||
|
||
async function assertSvelteAdapterInjection(tmp) {
|
||
const appHtml = readFileSync(join(tmp, APP_HTML), 'utf-8');
|
||
const layout = readFileSync(join(tmp, LAYOUT_FILE), 'utf-8');
|
||
assert.doesNotMatch(appHtml, /live\.js|impeccable-live-start/, 'Svelte adapter leaves src/app.html untouched');
|
||
assert.match(layout, /ImpeccableLiveRoot|impeccable-live-svelte-start/, 'Svelte adapter patches layout with live root');
|
||
}
|
||
|
||
async function assertShadowChrome(page) {
|
||
const result = await page.evaluate(() => {
|
||
const host = document.getElementById('impeccable-live-root');
|
||
const root = host?.shadowRoot;
|
||
return {
|
||
hasHost: Boolean(host),
|
||
hasShadowRoot: Boolean(root),
|
||
hasGlobalBar: Boolean(root?.getElementById('impeccable-live-global-bar')),
|
||
documentHasGlobalBar: Boolean(document.querySelector('#impeccable-live-global-bar')),
|
||
};
|
||
});
|
||
assert.equal(result.hasHost, true);
|
||
assert.equal(result.hasShadowRoot, true);
|
||
assert.equal(result.hasGlobalBar, true);
|
||
assert.equal(result.documentHasGlobalBar, false, 'live chrome is not mounted in app DOM');
|
||
}
|
||
|
||
async function assertHostileCssIsolation(page) {
|
||
await waitForGlobalBarStable(page);
|
||
const before = await globalBarSnapshot(page);
|
||
const hostileStyle = await page.addStyleTag({
|
||
content: `
|
||
button, div, input, svg, #impeccable-live-root, impeccable-live-root * {
|
||
display: block !important;
|
||
color: rgb(255, 0, 255) !important;
|
||
background: rgb(0, 255, 255) !important;
|
||
font-size: 44px !important;
|
||
opacity: 0.2 !important;
|
||
z-index: 1 !important;
|
||
pointer-events: none !important;
|
||
}
|
||
`,
|
||
});
|
||
try {
|
||
const after = await globalBarSnapshot(page);
|
||
assert.equal(after.display, before.display, 'hostile CSS cannot change display');
|
||
assert.equal(after.pointerEvents, before.pointerEvents, 'hostile CSS cannot disable pointer events');
|
||
assert.ok(
|
||
Math.abs(Number.parseFloat(after.opacity) - Number.parseFloat(before.opacity)) < 0.01,
|
||
'hostile CSS cannot change opacity',
|
||
);
|
||
assert.equal(after.fontSize, before.fontSize, 'hostile CSS cannot change font size');
|
||
assert.equal(after.color, before.color, 'hostile CSS cannot change color');
|
||
assert.equal(after.backgroundColor, before.backgroundColor, 'hostile CSS cannot change background color');
|
||
assert.ok(Math.abs(after.rect.width - before.rect.width) < 1, 'hostile CSS cannot change bar width');
|
||
assert.ok(Math.abs(after.rect.height - before.rect.height) < 1, 'hostile CSS cannot change bar height');
|
||
} finally {
|
||
await hostileStyle.evaluate((el) => el.remove()).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function waitForGlobalBarStable(page) {
|
||
await installLiveQueryHelpers(page);
|
||
await page.waitForFunction(
|
||
() => {
|
||
const bar = window.__impeccableLiveQuery?.('#impeccable-live-global-bar');
|
||
if (!bar) return false;
|
||
const opacity = Number.parseFloat(getComputedStyle(bar).opacity || '0');
|
||
return opacity >= 0.999;
|
||
},
|
||
null,
|
||
{ timeout: 5_000 },
|
||
);
|
||
}
|
||
|
||
async function assertSelectedElementChrome(page) {
|
||
await installLiveQueryHelpers(page);
|
||
const result = await page.evaluate(() => {
|
||
const q = window.__impeccableLiveQuery;
|
||
const bar = q('#impeccable-live-bar');
|
||
const picker = q('#impeccable-live-picker');
|
||
const badge = q('#impeccable-live-edit-badge');
|
||
const highlight = q('#impeccable-live-highlight');
|
||
const input = q('#impeccable-live-input');
|
||
return {
|
||
hasBar: Boolean(bar),
|
||
barText: bar?.textContent || '',
|
||
hasPicker: Boolean(picker),
|
||
hasBadge: Boolean(badge),
|
||
badgeText: badge?.textContent || '',
|
||
badgeLabel: badge?.querySelector('button')?.getAttribute('aria-label') || badge?.title || '',
|
||
hasHighlight: Boolean(highlight),
|
||
highlightVisible: highlight?.style.display !== 'none',
|
||
hasInput: Boolean(input),
|
||
hasCount: /×\d+/.test(bar?.textContent || ''),
|
||
hasGenerateButton: [...(bar?.querySelectorAll('button') || [])].some((button) => button.getAttribute('aria-label') === 'Generate variants'),
|
||
};
|
||
});
|
||
assert.equal(result.hasBar, true);
|
||
assert.equal(result.hasPicker, true);
|
||
assert.equal(result.hasBadge, true);
|
||
assert.match(result.badgeLabel, /Edit copy/);
|
||
assert.equal(result.hasHighlight, true);
|
||
assert.equal(result.highlightVisible, true);
|
||
assert.equal(result.hasInput, true);
|
||
assert.equal(result.hasCount, true);
|
||
assert.equal(result.hasGenerateButton, true);
|
||
}
|
||
|
||
async function selectAction(page, label) {
|
||
await installLiveQueryHelpers(page);
|
||
const opened = await page.evaluate(() => {
|
||
const bar = window.__impeccableLiveQuery('#impeccable-live-bar');
|
||
const btn = [...(bar?.querySelectorAll('button') || [])].find((candidate) => /▾|▼/.test(candidate.textContent || ''));
|
||
if (!btn) return false;
|
||
btn.click();
|
||
return true;
|
||
});
|
||
assert.equal(opened, true, 'action picker button opens');
|
||
await page.waitForFunction(
|
||
(expected) => {
|
||
const picker = window.__impeccableLiveQuery('#impeccable-live-picker');
|
||
return picker?.style.display !== 'none'
|
||
&& [...picker.querySelectorAll('button')].some((btn) => (btn.textContent || '').includes(expected));
|
||
},
|
||
label,
|
||
{ timeout: 5_000 },
|
||
);
|
||
const clicked = await page.evaluate((expected) => {
|
||
const picker = window.__impeccableLiveQuery('#impeccable-live-picker');
|
||
const btn = [...(picker?.querySelectorAll('button') || [])].find((candidate) => (candidate.textContent || '').includes(expected));
|
||
if (!btn) return false;
|
||
btn.click();
|
||
return true;
|
||
}, label);
|
||
assert.equal(clicked, true, `${label} action clicked`);
|
||
}
|
||
|
||
async function clickEditBadgeAction(page, label) {
|
||
const clicked = await page.evaluate((expected) => {
|
||
const badge = window.__impeccableLiveQuery('#impeccable-live-edit-badge');
|
||
const btn = [...(badge?.querySelectorAll('button') || [])].find((candidate) => (candidate.textContent || '').includes(expected));
|
||
if (!btn) return false;
|
||
btn.click();
|
||
return true;
|
||
}, label);
|
||
assert.equal(clicked, true, `${label} edit action clicked`);
|
||
}
|
||
|
||
async function clickPendingTrash(page) {
|
||
const dialog = page.waitForEvent('dialog', { timeout: 5_000 })
|
||
.then((d) => d.accept())
|
||
.catch(() => {});
|
||
const clicked = await page.evaluate(() => {
|
||
const dock = window.__impeccableLiveQuery('#impeccable-live-pending-dock');
|
||
const btn = [...(dock?.querySelectorAll('button') || [])].find((candidate) => /Discard copy edits/i.test(candidate.getAttribute('aria-label') || candidate.title || ''));
|
||
if (!btn) return false;
|
||
btn.click();
|
||
return true;
|
||
});
|
||
await dialog;
|
||
assert.equal(clicked, true, 'pending dock discard clicked');
|
||
}
|
||
|
||
async function cycleTo(page, target) {
|
||
// Component imports finish after the counter changes. Do not send the next
|
||
// click (or reload) while the previous variant is still mounting.
|
||
await cycleToVariant(page, target, 3);
|
||
await waitForVariantSettled(page, target, 3);
|
||
}
|
||
|
||
async function waitForVisibleCycling(page, count, { timeout }) {
|
||
await waitForCycling(page, count, { timeout });
|
||
await page.waitForFunction(
|
||
(expectedCount) => {
|
||
const bar = window.__impeccableLiveQuery?.('#impeccable-live-bar');
|
||
if (!bar) return false;
|
||
const style = getComputedStyle(bar);
|
||
return style.display !== 'none'
|
||
&& style.visibility !== 'hidden'
|
||
&& Number.parseFloat(style.opacity || '0') > 0.5
|
||
&& (bar.textContent || '').includes(`/${expectedCount}`);
|
||
},
|
||
count,
|
||
{ timeout: 10_000 },
|
||
);
|
||
}
|
||
|
||
async function assertVariantCounter(page, variant, count) {
|
||
await page.waitForFunction(
|
||
({ variant, count }) => {
|
||
const bar = window.__impeccableLiveQuery('#impeccable-live-bar');
|
||
if (!bar || !(bar.textContent || '').includes(`${variant}/${count}`)) return false;
|
||
const style = getComputedStyle(bar);
|
||
return style.display !== 'none'
|
||
&& style.visibility !== 'hidden'
|
||
&& Number.parseFloat(style.opacity || '0') > 0.5;
|
||
},
|
||
{ variant, count },
|
||
{ timeout: 10_000 },
|
||
);
|
||
assert.equal(await getVisibleVariant(page), variant);
|
||
}
|
||
|
||
async function revealExpenseRow(page) {
|
||
const row = page.locator("[data-testid='expense-row']").first();
|
||
if (await row.isVisible().catch(() => false)) return;
|
||
await page.locator("[data-testid='add-expense']").click();
|
||
await row.waitFor({ state: 'visible', timeout: 10_000 });
|
||
}
|
||
|
||
async function assertRecoverableMessage(page, variant, count) {
|
||
await page.waitForFunction(
|
||
({ variant, count }) => {
|
||
const bar = window.__impeccableLiveQuery('#impeccable-live-bar');
|
||
const raw = localStorage.getItem('impeccable-live-session');
|
||
let saved = null;
|
||
try { saved = raw ? JSON.parse(raw) : null; } catch {}
|
||
return Boolean(
|
||
bar
|
||
&& /Variants ready\. Reveal the selected element to resume\./.test(bar.textContent || '')
|
||
&& saved?.visible === variant
|
||
&& saved?.expected === count
|
||
&& saved?.previewMode === 'svelte-component'
|
||
);
|
||
},
|
||
{ variant, count },
|
||
{ timeout: 30_000 },
|
||
);
|
||
}
|
||
|
||
async function currentSveltePreviewSession(page, tmp) {
|
||
const saved = await page.evaluate(() => {
|
||
const raw = localStorage.getItem('impeccable-live-session');
|
||
return raw ? JSON.parse(raw) : null;
|
||
});
|
||
assert.ok(saved?.id, 'live session is stored locally');
|
||
assert.equal(saved.previewMode, 'svelte-component');
|
||
assert.ok(saved.previewFile, 'Svelte preview manifest path is stored');
|
||
const manifest = JSON.parse(readFileSync(join(tmp, saved.previewFile), 'utf-8'));
|
||
return { ...saved, manifest };
|
||
}
|
||
|
||
async function waitForSvelteAcceptComplete(tmp, session, { timeout }) {
|
||
const manifestPath = join(tmp, session.previewFile);
|
||
const sourcePath = join(tmp, ROUTE_FILE);
|
||
const deadline = Date.now() + timeout;
|
||
while (Date.now() < deadline) {
|
||
const snapshot = readSessionSnapshot(tmp, session.id);
|
||
const source = readFileSync(sourcePath, 'utf-8');
|
||
if (
|
||
snapshot?.phase === 'completed'
|
||
&& snapshot.sourceFile === ROUTE_FILE
|
||
&& !existsSync(manifestPath)
|
||
&& !/data-impeccable-variants|impeccable-variants-start|data-impeccable-variant=/.test(source)
|
||
) {
|
||
return;
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
}
|
||
throw new Error(`Svelte accept did not complete for ${session.id}; snapshot=${JSON.stringify(readSessionSnapshot(tmp, session.id))}`);
|
||
}
|
||
|
||
function assertCompletedRealSource(tmp, id) {
|
||
const snapshot = readSessionSnapshot(tmp, id);
|
||
assert.equal(snapshot?.phase, 'completed');
|
||
assert.equal(snapshot.sourceFile, ROUTE_FILE);
|
||
assert.doesNotMatch(snapshot.sourceFile || '', /node_modules\/\.impeccable-live/);
|
||
assert.match(snapshot.previewFile || '', /node_modules\/\.impeccable-live\/[^/]+\/manifest\.json/);
|
||
}
|
||
|
||
function assertDiscardedSession(tmp, id) {
|
||
const snapshot = readSessionSnapshot(tmp, id);
|
||
assert.equal(snapshot?.phase, 'discarded');
|
||
}
|
||
|
||
function readSessionSnapshot(tmp, id) {
|
||
const file = join(tmp, '.impeccable/live/sessions', `${id}.snapshot.json`);
|
||
if (!existsSync(file)) return null;
|
||
return JSON.parse(readFileSync(file, 'utf-8'));
|
||
}
|
||
|
||
async function assertPendingDockCount(live, expected) {
|
||
const deadline = Date.now() + 20_000;
|
||
let last = null;
|
||
while (Date.now() < deadline) {
|
||
last = await getPendingDockCount(live);
|
||
if (last === expected) return;
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
}
|
||
assert.equal(last, expected, 'pending copy edit count');
|
||
}
|
||
|
||
async function waitForPendingDockCleared(live, { timeout }) {
|
||
const deadline = Date.now() + timeout;
|
||
let last = null;
|
||
while (Date.now() < deadline) {
|
||
last = await getPendingDockCount(live);
|
||
if (last === 0) return;
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
}
|
||
assert.equal(last, 0, 'pending copy edits clear after Apply');
|
||
}
|
||
|
||
async function getPendingDockCount(live) {
|
||
const res = await fetch(`http://127.0.0.1:${live.port}/manual-edit-stash?token=${encodeURIComponent(live.token)}&pageUrl=%2F`);
|
||
if (!res.ok) throw new Error(`manual-edit-stash failed: ${res.status}`);
|
||
const json = await res.json();
|
||
return json.count || 0;
|
||
}
|
||
|
||
async function assertNoCyclingZero(page) {
|
||
const text = await page.evaluate(() => window.__impeccableLiveQuery('#impeccable-live-bar')?.textContent || '');
|
||
assert.doesNotMatch(text, /0\s*\/\s*0/);
|
||
}
|
||
|
||
async function assertNoRawSvelteExpressions(page) {
|
||
const body = await page.evaluate(() => document.body.textContent || '');
|
||
assert.doesNotMatch(body, /\{expenses\[0\]\.(name|amount)\}/);
|
||
}
|
||
|
||
async function readStateProbe(page) {
|
||
return page.evaluate(() => ({
|
||
openCount: document.querySelector('[data-testid="open-count"]')?.textContent?.trim(),
|
||
mounts: window.__impeccableStatefulMounts,
|
||
}));
|
||
}
|
||
|
||
async function assertOpenCount(page) {
|
||
const state = await readStateProbe(page);
|
||
assert.equal(state.openCount, '1 offen');
|
||
}
|
||
|
||
async function assertStateProbeUnchanged(page, expected, message) {
|
||
const state = await readStateProbe(page);
|
||
assert.equal(state.openCount, expected.openCount);
|
||
assert.equal(state.mounts, expected.mounts, message);
|
||
}
|
||
|
||
function assertFinalSourceClean(tmp) {
|
||
const source = readFileSync(join(tmp, ROUTE_FILE), 'utf-8');
|
||
assert.doesNotMatch(source, /node_modules\/\.impeccable-live|data-impeccable-variants|impeccable-variants-start|data-impeccable-variant=/);
|
||
}
|
||
|
||
function latestJournalEvent(tmp, predicate) {
|
||
const events = readJournalEvents(tmp);
|
||
for (let i = events.length - 1; i >= 0; i--) {
|
||
if (predicate(events[i])) return events[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function waitForLatestJournalEvent(tmp, predicate, { timeout = 20_000 } = {}) {
|
||
const deadline = Date.now() + timeout;
|
||
while (Date.now() < deadline) {
|
||
const event = latestJournalEvent(tmp, predicate);
|
||
if (event) return event;
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
}
|
||
throw new Error('timed out waiting for journal event');
|
||
}
|
||
|
||
function readJournalEvents(tmp) {
|
||
const dir = join(tmp, '.impeccable/live/sessions');
|
||
if (!existsSync(dir)) return [];
|
||
const events = [];
|
||
for (const file of readdirSync(dir).filter((name) => name.endsWith('.jsonl'))) {
|
||
const body = readFileSync(join(dir, file), 'utf-8');
|
||
for (const line of body.split('\n')) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const parsed = JSON.parse(line);
|
||
events.push(parsed.event || parsed);
|
||
} catch {}
|
||
}
|
||
}
|
||
return events;
|
||
}
|
||
|
||
async function globalBarSnapshot(page) {
|
||
await installLiveQueryHelpers(page);
|
||
return page.evaluate(() => {
|
||
const bar = window.__impeccableLiveQuery('#impeccable-live-global-bar');
|
||
if (!bar) return { hasBar: false };
|
||
const rect = bar.getBoundingClientRect();
|
||
const style = getComputedStyle(bar);
|
||
return {
|
||
hasBar: true,
|
||
display: style.display,
|
||
pointerEvents: style.pointerEvents,
|
||
opacity: style.opacity,
|
||
fontSize: style.fontSize,
|
||
color: style.color,
|
||
backgroundColor: style.backgroundColor,
|
||
transition: style.transition,
|
||
transform: style.transform,
|
||
rect: { width: rect.width, height: rect.height, left: rect.left, top: rect.top },
|
||
};
|
||
});
|
||
}
|
||
|
||
function wrapTargetFromPickedElement(event) {
|
||
const element = event.element || {};
|
||
const tag = typeof element.tagName === 'string'
|
||
? element.tagName.trim().toLowerCase()
|
||
: '';
|
||
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() : '';
|
||
return {
|
||
tag: tag || 'h1',
|
||
...(classes ? { classes } : {}),
|
||
...(elementId ? { elementId } : {}),
|
||
...(element.textContent ? { text: String(element.textContent).trim() } : {}),
|
||
};
|
||
}
|
||
|
||
function extractClassAttr(outerHTML) {
|
||
if (typeof outerHTML !== 'string') return '';
|
||
const match = outerHTML.match(/\sclass=(["'])(.*?)\1/);
|
||
return match ? match[2].trim().split(/\s+/).filter(Boolean).join(' ') : '';
|
||
}
|
||
|
||
function createEvidenceWriter({ page, tmp, live, consoleErrors }) {
|
||
mkdirSync(artifactRoot, { recursive: true });
|
||
writeFileSync(join(artifactRoot, 'fixture-tmp.txt'), tmp + '\n');
|
||
return {
|
||
async capture(name) {
|
||
const safe = name.replace(/[^a-z0-9_.-]+/gi, '-');
|
||
const dir = join(artifactRoot, safe);
|
||
mkdirSync(dir, { recursive: true });
|
||
try { await page.screenshot({ path: join(dir, 'page.png'), fullPage: true }); } catch {}
|
||
try {
|
||
const dom = await page.evaluate(() => {
|
||
const host = document.getElementById('impeccable-live-root');
|
||
const shadow = host?.shadowRoot;
|
||
return {
|
||
url: location.href,
|
||
bodyText: document.body.textContent?.replace(/\s+/g, ' ').trim().slice(0, 4000),
|
||
appHtml: document.body.innerHTML.slice(0, 12000),
|
||
shadowHtml: shadow?.innerHTML.slice(0, 12000) || null,
|
||
liveState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
|
||
localSession: localStorage.getItem('impeccable-live-session'),
|
||
};
|
||
});
|
||
writeFileSync(join(dir, 'dom.json'), JSON.stringify(dom, null, 2) + '\n');
|
||
} catch (err) {
|
||
writeFileSync(join(dir, 'dom-error.txt'), err.stack || err.message || String(err));
|
||
}
|
||
writeFileSync(join(dir, 'console-errors.json'), JSON.stringify(consoleErrors, null, 2) + '\n');
|
||
writeFileSync(join(dir, 'source.diff'), git(tmp, ['diff', '--', APP_HTML, LAYOUT_FILE, ROUTE_FILE]));
|
||
copyIfExists(join(tmp, APP_HTML), join(dir, 'app.html'));
|
||
copyIfExists(join(tmp, LAYOUT_FILE), join(dir, 'layout.svelte'));
|
||
copyIfExists(join(tmp, ROUTE_FILE), join(dir, 'page.svelte'));
|
||
copyDirIfExists(join(tmp, '.impeccable/live/sessions'), join(dir, 'sessions'));
|
||
copyDirIfExists(join(tmp, 'node_modules/.impeccable-live'), join(dir, 'impeccable-live-preview'));
|
||
writeFileSync(join(dir, 'preview-files.json'), JSON.stringify(listPreviewFiles(tmp), null, 2) + '\n');
|
||
try {
|
||
const status = await fetch(`http://127.0.0.1:${live.port}/status?token=${encodeURIComponent(live.token)}`).then((res) => res.json());
|
||
writeFileSync(join(dir, 'status.json'), JSON.stringify(status, null, 2) + '\n');
|
||
} catch {}
|
||
},
|
||
};
|
||
}
|
||
|
||
function listPreviewFiles(tmp) {
|
||
const root = join(tmp, 'node_modules/.impeccable-live');
|
||
if (!existsSync(root)) return [];
|
||
const out = [];
|
||
walk(root, (file) => out.push(relative(tmp, file).split('\\').join('/')));
|
||
return out.sort();
|
||
}
|
||
|
||
function walk(dir, visit) {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const abs = join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(abs, visit);
|
||
else visit(abs);
|
||
}
|
||
}
|
||
|
||
function copyIfExists(from, to) {
|
||
if (existsSync(from)) cpSync(from, to);
|
||
}
|
||
|
||
function copyDirIfExists(from, to) {
|
||
if (!existsSync(from)) return;
|
||
rmSync(to, { recursive: true, force: true });
|
||
cpSync(from, to, { recursive: true });
|
||
}
|
||
|
||
function git(cwd, args) {
|
||
try {
|
||
return execFileSync('git', args, { cwd, encoding: 'utf-8' });
|
||
} catch (err) {
|
||
return err.stdout?.toString?.() || err.message || String(err);
|
||
}
|
||
}
|
||
|
||
function createArtifactRoot() {
|
||
const explicit = process.env.IMPECCABLE_SVELTE_DEEPSEEK_ARTIFACT_DIR;
|
||
if (explicit) return explicit;
|
||
const base = join(REPO_ROOT, 'tmp/svelte-live-adapter-deepseek');
|
||
for (const round of ['round-1', 'round-2']) {
|
||
const candidate = join(base, round);
|
||
if (!existsSync(candidate)) return candidate;
|
||
}
|
||
return join(base, `run-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||
}
|
||
|
||
function assertNoConsoleErrors(consoleErrors) {
|
||
const ignored = consoleErrors.filter((line) =>
|
||
!/favicon|ResizeObserver loop limit exceeded/i.test(line)
|
||
);
|
||
assert.deepEqual(ignored, []);
|
||
}
|