diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index c22fe05d6..bb45f842e 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -122,6 +122,7 @@ export const SUITES = { files: [ 'tests/live-reference.test.mjs', 'tests/live-browser-ignores.test.mjs', + 'tests/live-browser-source.test.mjs', 'tests/live-e2e-agent-output.test.mjs', 'tests/live-e2e-cli-options.test.mjs', 'tests/live-e2e-llm-agent.test.mjs', diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index da026e255..c32101b12 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6216,6 +6216,71 @@ return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); } + function sourceHasSessionWrapper(text, sessionId) { + const src = String(text || ''); + return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1 + || src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1 + || src.indexOf('impeccable-variants-start ' + sessionId) !== -1; + } + + /** + * Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a + * wrapper deleted from source look identical in the DOM, and only the second + * is an orphan, so the DOM alone cannot decide. #454 forbids parsing or + * injecting raw JSX; reading the file as plain text and matching the session + * marker honors that, because no DOM is ever built from what comes back. + * Marker present means the component is simply not mounted right now (a + * closed modal, another route) and the variant observer keeps waiting. + * Marker absent after the same retry budget the HTML path uses means the + * file was edited out from under the session, which no reload, HMR push, or + * server restart can repair, so the session self-discards and hands the + * surface back to the picker. + */ + function probeJsxWrapperForOrphan(filePath, sessionId, opts) { + const attempt = opts._orphanAttempt || 0; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING'); + const retryLater = () => { + setTimeout(() => { + if (!stillActive()) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + }; + // Discarding is durable (the session moves to the discarded phase and the + // picker replaces it), so it needs evidence that the wrapper is gone: a + // read that answers without the marker, or a 404 (the file itself was + // renamed or deleted). Either kind retries on the shared budget first. + // A read that fails for any other reason (the server briefly away, a + // transient fetch error) says nothing about the wrapper; after the budget + // the session is kept, the user told, and the next event retries. + const onNoWrapper = (reason) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + discardOrphanedSession(reason); + }; + const onUnreadable = (detail) => { + if (!stillActive()) return; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; } + console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail); + showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500); + }; + fetch(url) + .then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); }) + .then(text => { + if (!stillActive()) return; + if (sourceHasSessionWrapper(text, sessionId)) return; + onNoWrapper('variant wrapper missing from source'); + }) + .catch(err => { + const detail = err && err.message ? err.message : 'fetch failed'; + if (/source read failed: 404$/.test(detail)) { + onNoWrapper('source file missing (404) while checking for the variant wrapper'); + return; + } + onUnreadable(detail); + }); + } + function completeSourceInjection(wrapper, sessionId, opts) { recoveryWaitingForAnchor = false; if (pendingVariantAnchorRetryObserver) { @@ -6326,14 +6391,7 @@ return; } if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { - const attempt = opts._orphanAttempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - setTimeout(() => { - if (sessionId !== currentSessionId) return; - if (state !== 'GENERATING' && state !== 'CYCLING') return; - injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - } + probeJsxWrapperForOrphan(filePath, sessionId, opts); } return; } diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs new file mode 100644 index 000000000..0617806f8 --- /dev/null +++ b/tests/live-browser-source.test.mjs @@ -0,0 +1,698 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8'); +const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || ''; +const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || ''; + +describe('live-browser source contracts', () => { + it('reports foreground poll connectivity without a background worker dependency', () => { + assert.match( + SOURCE, + /syncAgentPollingUi\(!!msg\.agentPolling\)/, + 'the initial SSE state should include foreground poll connectivity', + ); + assert.doesNotMatch(SOURCE, /codexWorker|codex-worker|codex_cli_unavailable/); + }); + + it('dispatches plain generation before screenshot capture without bypassing annotated evidence', () => { + const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await sendEvent(basePayload);'); + const captureIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await captureElementToBlob'); + assert.ok(dispatchIndex >= 0, 'plain generation should dispatch immediately'); + assert.ok(captureIndex > dispatchIndex, 'plain generation dispatch must happen before capture begins'); + assert.match( + CAPTURE_AND_EMIT_SOURCE, + /if \(blob && hasAnnotations\)[\s\S]*?\/annotation\?token=/, + 'annotation screenshots should still upload before annotated generation dispatch', + ); + assert.match( + CAPTURE_AND_EMIT_SOURCE, + /if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/, + 'annotated generation should dispatch exactly after capture and upload resolve', + ); + }); + + it('saves copy edits to the staged buffer with rich AI context', () => { + assert.doesNotMatch( + SOURCE, + /type: 'manual_edit_apply'|beginManualApplySession|createManualApplyOverlay|manualApplySession/, + 'Save should not use the old direct manual_edit_apply loading path', + ); + assert.match( + SOURCE, + /fetch\('http:\/\/localhost:' \+ PORT \+ '\/manual-edit-stash\?token=' \+ encodeURIComponent\(TOKEN\)[\s\S]{0,300}?pageUrl: location\.pathname[\s\S]{0,80}?element: extractContext\(contextElement\)[\s\S]{0,40}?ops,/, + 'Save should stage edits through /manual-edit-stash with element context and ops', + ); + assert.match( + SOURCE, + /fetch\([^)]*\/manual-edit-commit\?token=[\s\S]*?&async=1/, + 'Apply copy edits should start /manual-edit-commit in async mode', + ); + assert.match( + SOURCE, + /fetch\([^)]*\/manual-edit-discard\?token=/, + 'Discard copy edits should call /manual-edit-discard', + ); + assert.match( + SOURCE, + /const result = await res\.json\(\)\.catch\(\(\) => \(\{\}\)\);[\s\S]{0,120}?restoreDiscardedManualEdits\(result\.entries \|\| \[\]\);/, + 'Discard copy edits should restore the visible staged DOM from returned buffer entries', + ); + assert.match( + SOURCE, + /const restoreFailures = restoreDiscardedManualEdits\(result\.entries \|\| \[\]\);[\s\S]{0,260}?refresh to reset/, + 'Discard restore should report unsafe local DOM restores to the caller', + ); + assert.match( + SOURCE, + /function canRestoreManualEditElement\(el, op\)[\s\S]*?el\.children && el\.children\.length > 0[\s\S]*?return false;[\s\S]*?normalizeManualContextText\(el\.textContent\) === normalizeManualContextText\(op\.newText\);/, + 'Discard restore should only write textContent into pure text leaves, never parent containers', + ); + assert.match( + SOURCE, + /if \(!el \|\| typeof op\.originalText !== 'string' \|\| !canRestoreManualEditElement\(el, op\)\)[\s\S]*?failures \+= 1;[\s\S]*?continue;/, + 'Unsafe discard restores should be skipped instead of wiping parent markup', + ); + assert.match( + SOURCE, + /function parseManualEditRefSegment\(segment\)[\s\S]*?function elementMatchesManualRefSegment\(el, segment\)/, + 'Discard restore should parse Impeccable document refs instead of treating them as raw CSS selectors', + ); + const refMatchStart = SOURCE.indexOf('function elementMatchesManualRefSegment'); + const refMatchEnd = SOURCE.indexOf('function cssIdent', refMatchStart); + const refMatchFn = SOURCE.slice(refMatchStart, refMatchEnd); + assert.match( + refMatchFn, + /if \(segment\.id && el\.id !== segment\.id\) return false;[\s\S]*for \(const cls of segment\.classes\)[\s\S]*if \(segment\.nth && indexAmongSameTag\(el\) !== segment\.nth\) return false;/, + 'Discard restore refs should require id/classes and nth-of-type to match the same element', + ); + assert.match( + SOURCE, + /const restoreHint = mixedTextWrapRestoreHint\(row\.el\);[\s\S]{0,80}if \(restoreHint\) op\.restore = restoreHint;/, + 'Staged mixed-content text edits should carry a restore hint for their parent text node', + ); + assert.match( + SOURCE, + /function restoreMixedTextNodeManualEdit\(op\)[\s\S]*?byIndex\.nodeValue = op\.originalText;/, + 'Discard restore should restore unwrapped mixed-content text nodes by hint', + ); + assert.doesNotMatch( + SOURCE, + /document\.querySelector\(ref\)/, + 'Discard restore must not pass saved document refs directly to querySelector', + ); + assert.match( + SOURCE, + /function pendingApplyLabel\(count\)[\s\S]{0,80}return count === 1 \? 'Apply copy edit' : 'Apply copy edits';/, + 'the staged apply pill should use Apply copy edits copy', + ); + assert.match( + SOURCE, + /function setPendingApplyLoading\(loading, count\)[\s\S]*?pendingPillSpinnerEl\.style\.display = pendingApplyInFlight \? 'inline-block' : 'none';[\s\S]*?pendingPillEl\.disabled = pendingApplyInFlight;[\s\S]*?pendingTrashBtn\.disabled = pendingApplyInFlight;[\s\S]*?schedulePendingDockPosition\(\);[\s\S]*?\n \}/, + 'Apply copy edits should show a loading state and prevent double apply/discard while the AI batch runs', + ); + assert.match( + SOURCE, + /function handleGo\(\)\s*\{\s*if \(pendingApplyInFlight\) \{ showManualApplyBusyToast\(\); return; \}[\s\S]*?captureAndEmit\(elForCapture, basePayload, snapshot, captureRect\);/, + 'Go should be blocked while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function buildConfigureRow\(\)[\s\S]{0,80}?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?const go = buildConfigureSubmitButton\(\{\s*controlsLocked,/, + 'Configure controls should render disabled while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function buildConfigureSubmitButton\(\{ controlsLocked[\s\S]{0,700}?btn\.disabled = controlsLocked;/, + 'the configure submit button must disable itself while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function handleMouseMove\(e\) \{[\s\S]{0,80}?if \(pendingApplyInFlight\) return;/, + 'Element hover picking should pause while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function togglePick\(\) \{[\s\S]{0,100}?if \(pendingApplyInFlight\) \{ showManualApplyBusyToast\(\); return; \}/, + 'Pick mode should not toggle while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function updateGlobalBarState\(\)[\s\S]*?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?btn\.disabled = controlsLocked;/, + 'Global live controls should be disabled while manual copy edits are applying', + ); + assert.match( + SOURCE, + /function hidePendingApplyDock\(\)[\s\S]*?pendingDockEl\.style\.display = 'none';[\s\S]*?pendingPillEl\.style\.display = 'none';[\s\S]*?pendingTrashBtn\.style\.display = 'none';/, + 'Zero pending copy edits should fully hide the Apply dock controls', + ); + assert.match( + SOURCE, + /case 'manual_edit_commit_done':[\s\S]{0,120}?handleManualEditActivity\(msg\);/, + 'Apply completion SSE should update the pending dock even if HMR interrupts the original fetch handler', + ); + assert.match( + SOURCE, + /case 'manual_edit_apply_reply_received':[\s\S]{0,220}?case 'manual_edit_repair_needs_decision':[\s\S]{0,160}?handleManualEditActivity\(msg\);/, + 'Apply progress and repair SSE should reach the pending dock handler', + ); + assert.match( + SOURCE, + /function remainingManualEditCount\(payload\)[\s\S]*?payload\?\.perPage\?\.\[location\.pathname\][\s\S]*?payload\?\.remainingCount[\s\S]*?payload\?\.totalCount[\s\S]*?if \(totalCount === 0\) return 0;/, + 'Apply completion counts should honor page count first and still hide the dock when only totalCount is zero', + ); + assert.match( + SOURCE, + /if \(msg\.type === 'manual_edit_commit_done'\)[\s\S]*?const remainingCount = remainingManualEditCount\(msg\);[\s\S]*?updatePendingCounter\(remainingCount === null \? 0 : remainingCount\);/, + 'Apply completion SSE should use the shared remaining-count helper', + ); + assert.match( + SOURCE, + /function updateManualApplyProgressFromChunk\(chunk\)[\s\S]*?remainingCount[\s\S]*?phase: remainingCount > 0 \? 'applying' : 'verifying'[\s\S]*?setPendingApplyLoading\(true, remainingCount\);/, + 'Apply progress should count completed chunk ops down and switch to verification after all chunk replies', + ); + assert.match( + SOURCE, + /function manualApplyLoadingText\(fallbackCount\)[\s\S]*?Fixing apply issue, attempt[\s\S]*?Verifying copy edits[\s\S]*?Applying ' \+ remaining \+ ' copy edit/, + 'Apply loading text should cover applying, verifying, and repair states', + ); + assert.match( + SOURCE, + /manual_edit_repair_needs_decision[\s\S]*?showManualApplyDecision\(msg\);/, + 'Repair exhaustion should show the user decision controls instead of hiding the dock', + ); + assert.match( + SOURCE, + /function onPendingKeepFixingClick\(\)[\s\S]*?&repair=1/, + 'Keep fixing should restart the repair loop against the current transaction', + ); + assert.match( + SOURCE, + /function onPendingRollbackClick\(\)[\s\S]*?\/manual-edit-repair-decision[\s\S]*?action: 'rollback'/, + 'Rollback should be an explicit user decision through the repair-decision endpoint', + ); + const applyStart = SOURCE.indexOf('async function onPendingPillClick'); + const applyEnd = SOURCE.indexOf('async function onPendingTrashClick', applyStart); + const applyFn = SOURCE.slice(applyStart, applyEnd); + assert.match(applyFn, /if \(count <= 0 \|\| pendingApplyInFlight\) return;/); + assert.doesNotMatch(applyFn, /page will reload/); + assert.match(applyFn, /setPendingApplyLoading\(true, count\);[\s\S]*?\/manual-edit-commit\?token=/); + assert.match(applyFn, /waitForSseCompletion = true;[\s\S]*?return;/); + assert.match(applyFn, /finally \{[\s\S]*?if \(waitForSseCompletion\) return;/); + assert.match( + SOURCE, + /String\(newText \|\| ''\)\.trim\(\) === ''[\s\S]{0,120}?Save rejected: copy edits cannot be empty\./, + 'manual copy edits should reject empty text instead of staging unverifiable deletes', + ); + assert.match( + SOURCE, + /pendingTrashTooltipEl\.textContent = 'Discard copy edits';/, + 'the discard button should use tooltip copy', + ); + assert.match( + SOURCE, + /const n = Array\.isArray\(result\.applied\) \? result\.applied\.length : \(result\.cleared \|\| 0\);/, + 'Apply success toast should use verified applied/cleared counts only', + ); + assert.doesNotMatch( + SOURCE, + /result\.applied\?\.length \|\| count/, + 'Apply success toast must not fall back to the original staged count', + ); + assert.match( + SOURCE, + /const width = globalBarEl\.offsetWidth;[\s\S]{0,80}?const height = globalBarEl\.offsetHeight;/, + 'pending dock should position from stable bar dimensions', + ); + assert.match( + SOURCE, + /pendingDockEl\.style\.bottom = Math\.round\(14 \+ \(height \/ 2\)\) \+ 'px';/, + 'pending dock should use fixed bottom anchoring', + ); + assert.doesNotMatch( + PENDING_DOCK_POSITION_SOURCE, + /rect\.top \+ rect\.height \/ 2/, + 'pending dock should not use animated bar rect top for vertical positioning', + ); + assert.match( + SOURCE, + /const sourceHint = sourceHintForElement\(row\.el\);[\s\S]{0,80}?op\.sourceHint = sourceHint;/, + 'manual copy edits should preserve framework source hints when available', + ); + assert.match( + SOURCE, + /const contextRef = documentRefForElement\(contextElement\);[\s\S]{0,80}?op\.contextRef = contextRef;/, + 'manual copy edits should preserve the selected/container DOM path', + ); + assert.match( + SOURCE, + /data-astro-source-file[\s\S]{0,120}?data-astro-source-loc/, + 'Astro source metadata should be captured as optional source hints', + ); + assert.match( + SOURCE, + /op\.leaf = copyEditLeafContext\(row\.el, row\.text, newText\);/, + 'manual copy edits should capture the edited leaf details', + ); + assert.match( + SOURCE, + /op\.nearbyEditableTexts = nearbyEditableTextsForManualEdit\(inlineEditRows, row\.el, row\.text, newText\);/, + 'manual copy edits should capture nearby editable sibling text', + ); + assert.match( + SOURCE, + /function sanitizedContextOuterHTML\(el, maxLength\)[\s\S]*?stripManualEditRuntimeState\(clone\);/, + 'manual copy edit prompt context should strip browser-only edit markers before staging HTML', + ); + assert.match( + SOURCE, + /outerHTML: sanitizedContextOuterHTML\(el, 10000\),/, + 'staged element context should not include live edit runtime attributes', + ); + assert.match( + SOURCE, + /function copyEditLeafContext\(el, originalText, newText\)[\s\S]*?outerHTML: sanitizedContextOuterHTML\(el, 3000\) \|\| null,/, + 'staged leaf context should not include live edit runtime attributes', + ); + assert.match( + SOURCE, + /if \(container\) for \(const op of ops\) op\.container = container;/, + 'manual copy edits should attach selected/container context to each op', + ); + assert.match( + SOURCE, + /const acceptPayload = \{[\s\S]{0,160}?pageUrl: location\.pathname,/, + 'accept events should carry pageUrl so post-accept staged-edit cleanup is page-scoped', + ); + const sourceHintStart = SOURCE.indexOf('function sourceHintForElement'); + const sourceHintEnd = SOURCE.indexOf('function parseSourceLoc', sourceHintStart); + const sourceHintFn = SOURCE.slice(sourceHintStart, sourceHintEnd); + assert.doesNotMatch( + sourceHintFn, + /parentElement/, + 'source hints should come from the edited leaf itself, not inherited generated-container ancestors', + ); + }); + + 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\) \{[\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\)\)\)/); + assert.match( + SOURCE, + /\.then\(async res => \{[\s\S]*if \(res\.ok\) return res;[\s\S]*\}\)\.catch\(handleFailure\)/, + 'event=live_browser.http_error_contract actor=browser operation=accept_discard_ack risk=http_500_clears_local_state_without_durable_receipt expected=non-ok response handled before then-success actual=missing', + ); + assert.match(SOURCE, /sendEvent\(acceptPayload, \{ throwOnError: true \}\)/); + assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/); + }); + + it('releases the foreground picker after deterministic accept while carbonize finishes', () => { + assert.match( + SOURCE, + /let pendingAcceptedSession = null;/, + 'accept flow should keep pending completion state after the browser sends the accept intent', + ); + assert.match( + SOURCE, + /case 'complete':\s*case 'accept':[\s\S]{0,400}?if \(maybeCompleteAcceptedSession\(msg\)\) break;/, + 'final accepted DOM cleanup should be driven by explicit complete or harness accept replies', + ); + assert.match( + SOURCE, + /case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?setLiveState\('CYCLING'\);[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/, + 'an SSE error for a queued accept should invalidate pending accept state and keep variants retryable', + ); + assert.equal( + SOURCE.match(/function cssIdent\(value\)/g)?.length || 0, + 1, + 'accepted DOM cleanup should reuse the existing cssIdent helper instead of shadowing it', + ); + const agentDoneStart = SOURCE.indexOf("case 'agent_done':"); + const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart); + const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart); + assert.match(agentDoneSource, /must not hold the foreground picker hostage/); + assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/); + assert.match( + SOURCE, + /function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,400}?awaitingAcceptResult = null;[\s\S]{0,120}?currentSessionId = id8\(\);/, + 'starting a new generation should clear any stale accepted-session sentinel (and the awaited accept-result marker, #384) first', + ); + const handleAcceptStart = SOURCE.indexOf('function handleAccept()'); + const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart); + const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart); + assert.match( + handleAcceptSource, + /sendEvent\(acceptPayload, \{ throwOnError: true \}\)[\s\S]*?markSessionHandled\(\);[\s\S]*?setLiveState\('CONFIRMED'\);[\s\S]*?scheduleAcceptCleanup\(pending\);/, + 'durable accept intent should release the foreground picker before background source cleanup completes', + ); + assert.match( + SOURCE, + /function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted, recoveryRevision\);[\s\S]*?\}, 1200\);/, + 'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred', + ); + assert.match( + SOURCE, + /function ensureAcceptedDomClean\(pending, recoveryRevision\)[\s\S]*?acceptedDomAlreadyClean\(pending\)[\s\S]*?findAcceptedRuntimeWrappers\(sessionId\)[\s\S]*?for \(const wrapper of wrappers\)[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);[\s\S]*?acceptedDomAlreadyClean\(pending\)/, + 'post-cleanup fallback should unwrap the accepted variant instead of preserving live runtime wrappers', + ); + assert.match( + SOURCE, + /function acceptedDomAlreadyClean\(pending\)[\s\S]*?matches\.length > 0[\s\S]*?matches\.every[\s\S]*?data-impeccable-carbonize/, + 'accepted DOM should not be considered clean while any matching root is still inside a carbonize wrapper', + ); + assert.match( + SOURCE, + /function findAcceptedRuntimeWrappers\(sessionId\)[\s\S]*?querySelectorAll\('\[data-impeccable-variants=[\s\S]*?querySelectorAll\('\[data-impeccable-carbonize=/, + 'post-cleanup fallback should remove every stale variants/carbonize wrapper left by React HMR after accept', + ); + assert.match( + SOURCE, + /if \(!accepted\) \{[\s\S]{0,80}?wrapper\.remove\(\);[\s\S]{0,80}?continue;/, + 'post-cleanup fallback should not leave a variants wrapper behind when the accepted variant node is missing', + ); + assert.match( + SOURCE, + /function maybeCompleteAcceptedSession\(msg\)[\s\S]{0,260}?if \(currentSessionId && currentSessionId !== pending\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?return false;/, + 'stale accepted completions should not clean up a newer active browser session', + ); + assert.match( + SOURCE, + /function reloadAfterMissingAcceptedDom\(pending, recoveryRevision\)[\s\S]*?location\.reload\(\);/, + 'missing accepted DOM after clean source should recover by reloading the clean page', + ); + assert.match( + SOURCE, + /restoreAcceptedDomFromSnapshot\(pending, recoveryRevision\)[\s\S]*?function restoreAcceptedDomFromSnapshot\(pending, recoveryRevision\)[\s\S]*?reloadAfterMissingAcceptedDom\(pending, recoveryRevision\)/, + 'snapshot restoration must carry the originating recovery revision into its reload fallback', + ); + assert.match( + SOURCE, + /function ensureAcceptedDomClean\(pending, recoveryRevision\) \{[\s\S]{0,250}?deferredRecoverySuperseded\(pending\?\.id, recoveryRevision\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]{0,180}?deferredRecoverySuperseded\(pending\?\.id, recoveryRevision\)[\s\S]{0,180}?location\.reload\(\);/, + 'accepted-session cleanup and its reload fallback must yield to a newer Live session', + ); + }); + + it('never runs accept or discard structural fallbacks inside framework-owned HMR DOM', () => { + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(wrappers\[0\] \|\| pending\?\.parentElement\)\) \{[\s\S]{0,500}?acceptedDomAlreadyClean\(pending\)[\s\S]{0,80}?location\.reload\(\);[\s\S]{0,80}?return;[\s\S]{0,120}?if \(wrappers\.length === 0\)/, + 'accept cleanup must use a reload grace fallback before any framework-owned structural mutation', + ); + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,900}?location\.reload\(\);[\s\S]{0,100}?return;[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)/, + 'discard cleanup must use a reload grace fallback before replacing a framework-owned wrapper', + ); + assert.match( + SOURCE, + /function releaseDiscardedStaticWrapper\(wrapper, sessionId\)[\s\S]{0,400}?replaceChild\(content, wrapper\)/, + 'only the static-wrapper release helper may structurally restore discarded DOM', + ); + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,700}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,120}?location\.reload\(\);/, + 'discard must keep its original-visibility stylesheet until the HMR grace window ends', + ); + assert.match( + SOURCE, + /const recoverySuperseded = deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\);[\s\S]{0,500}?if \(recoverySuperseded\) \{[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)[\s\S]{0,80}?return;/, + 'discard cleanup and its reload grace callback must yield to a newer Live session', + ); + assert.match( + SOURCE, + /function discardStateStyleId\(sessionId\)[\s\S]{0,100}?DISCARD_STATE_STYLE_ID \+ '-' \+ sessionId[\s\S]{0,400}?getElementById\(discardStateStyleId\(sessionId\)\)/, + 'concurrent discard sessions must retain independent visibility stylesheets', + ); + assert.match( + SOURCE, + /function removeDiscardStateStylesheet\(sessionId\)[\s\S]{0,100}?if \(!sessionId\) return;[\s\S]{0,100}?getElementById\(discardStateStyleId\(sessionId\)\)\?\.remove\(\);/, + 'an older discard callback must remove only its own session stylesheet', + ); + assert.match( + SOURCE, + /setTimeout\(function\(\) \{[\s\S]{0,300}?const staleWrapper = document\.querySelector[\s\S]{0,250}?deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\)[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,100}?return;[\s\S]{0,100}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,100}?location\.reload\(\);/, + 'framework discard recovery may observe safe HMR cleanup but must not reload replacement work', + ); + assert.match( + SOURCE, + /const discardedFrameworkWrapperWatchers = new Map\(\);[\s\S]*?function watchForDiscardedFrameworkWrapperRemoval\(sessionId\)[\s\S]{0,1500}?const replacementActive = !!currentSessionId[\s\S]{0,180}?state !== 'IDLE' && state !== 'PICKING'[\s\S]{0,300}?setTimeout\(resolveStillMounted, 12000\)[\s\S]{0,300}?location\.reload\(\);/, + 'discard recovery must keep observing during replacement work and reload stale framework DOM once Live is idle', + ); + }); + + it('recovers a handled variant or carbonize wrapper after HMR cancels the original cleanup timer', () => { + const start = SOURCE.indexOf('function scheduleHandledRuntimeWrapperReload(wrapper,'); + const end = SOURCE.indexOf('\n function resumeSession(', start); + const recovery = SOURCE.slice(start, end); + assert.match(recovery, /impeccableCarbonize/); + assert.match(recovery, /sessionStorage\.getItem\(handledWrapperReloadKey\(sessionId\)\)/); + assert.match(recovery, /sessionStorage\.setItem\(handledWrapperReloadKey\(sessionId\), String\(reloadAttempts \+ 1\)\)/); + assert.match(recovery, /if \(reloadAttempts >= 2\) return true;/); + assert.match(recovery, /handledRuntimeWrapperReloadSessions\.has\(sessionId\)/); + assert.match(recovery, /handledRuntimeWrapperReloadSessions\.add\(sessionId\)/); + assert.match( + recovery, + /deferredRecoverySuperseded\(sessionId, recoveryRevision\)[\s\S]*?return true;[\s\S]*?setTimeout\(function\(\) \{[\s\S]{0,180}?deferredRecoverySuperseded\(sessionId, recoveryRevision\)[\s\S]{0,220}?handledRuntimeWrapperReloadSessions\.delete\(sessionId\);[\s\S]{0,80}?return;/, + 'handled-wrapper recovery must never reload a newer Live session', + ); + assert.match( + SOURCE, + /const handledRuntimeWrapperReloadSessions = new Set\(\);[\s\S]*?function handledWrapperReloadKey\(sessionId\)[\s\S]{0,100}?HANDLED_WRAPPER_RELOAD_KEY \+ ':' \+ sessionId/, + 'overlapping handled sessions must have independent timers and retry budgets', + ); + assert.match(recovery, /\[data-impeccable-variants=.+\[data-impeccable-carbonize=/s); + assert.match(recovery, /if \(staleWrapper\) location\.reload\(\);/); + assert.match( + SOURCE, + /function resumeSession\(recoveryRevision = liveInteractionRevision\)[\s\S]{0,250}?\[data-impeccable-carbonize\][\s\S]{0,180}?scheduleHandledRuntimeWrapperReload\(runtimeWrapper, recoveryRevision\)/, + 'resume must inspect handled carbonize wrappers before clearing handled state', + ); + assert.match( + SOURCE, + /function restoreSessionSupersedingHandledWrapper\(runtimeWrapper\)[\s\S]{0,900}?saved\.id === handledSessionId[\s\S]{0,300}?restoreSessionWithoutWrapper\('browser_resumed_over_handled_wrapper'\)/, + 'handled-wrapper recovery must recognize a different durable session as newer work', + ); + assert.match( + SOURCE, + /function isUsableInjectionAnchor\(el\)[\s\S]{0,250}?closest\?\.\('\[data-impeccable-variants\],\[data-impeccable-carbonize\]'\)/, + 'a newer restored session must wait for a real page anchor outside stale handled wrappers', + ); + const resumeStart = SOURCE.indexOf('function resumeSession('); + const resumeEnd = SOURCE.indexOf('\n //', resumeStart); + const resume = SOURCE.slice(resumeStart, resumeEnd); + assert.ok( + resume.indexOf('restoreSessionSupersedingHandledWrapper(runtimeWrapper)') + < resume.indexOf('scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)'), + 'a newer durable session must restore before a stale handled wrapper can schedule another reload', + ); + assert.doesNotMatch( + resume, + /clearHandled\(\);/, + 'bounded handled state must survive arbitrarily late wrapper hydration and later reloads', + ); + assert.match( + resume, + /browser_resumed_svelte_orphan_wrapper[\s\S]{0,150}?clearHandled\(sessionId\);/, + 'orphan cleanup must clear only its own handled id', + ); + assert.match( + SOURCE, + /if \(!accepted\?\.isSvelteComponent\) \{[\s\S]{0,100}?watchForHandledRuntimeWrapper\(accepted\?\.id, recoveryRevision\);/, + 'accept cleanup should watch for a carbonize wrapper mounted by a delayed framework refresh', + ); + assert.match( + recovery, + /function watchForHandledRuntimeWrapper\(sessionId, recoveryRevision = liveInteractionRevision\)[\s\S]*?handledRuntimeWrapperWatchers\.get\(sessionId\)[\s\S]*?observer\.observe\(document\.body, \{ childList: true, subtree: true \}\)[\s\S]*?handledRuntimeWrapperWatchers\.set\(sessionId, \{ observer, timer \}\);/, + 'late handled-wrapper recovery should remain bounded while covering slow HMR updates', + ); + assert.match( + SOURCE, + /const handledRuntimeWrapperWatchers = new Map\(\);[\s\S]*?handledRuntimeWrapperWatchers\.delete\(sessionId\);/, + 'overlapping handled-wrapper scouts must retain independent observer state per session', + ); + }); + + it('keeps watching for a framework wrapper when session restore wins the hydration race', () => { + assert.match( + SOURCE, + /const resumed = resumeSession\(\);[\s\S]{0,1200}?if \(!resumed \|\| !document\.querySelector\('\[data-impeccable-variants\],\[data-impeccable-carbonize\]'\)\) \{[\s\S]{0,500}?const scout = new MutationObserver/, + 'restoring durable session state before hydration must still install the deferred-wrapper scout', + ); + assert.match( + SOURCE, + /const deferredResumeRevision = liveInteractionRevision;[\s\S]{0,350}?const scout = new MutationObserver[\s\S]{0,350}?resumeSession\(deferredResumeRevision\)/, + 'the deferred-wrapper scout must retain its originating interaction revision', + ); + }); + + it('invalidates nullable deferred recovery as soon as a replacement edit starts configuring', () => { + assert.match( + SOURCE, + /function beginNewLiveConfiguration\(\) \{[\s\S]{0,100}?liveInteractionRevision \+= 1;[\s\S]{0,80}?setLiveState\('CONFIGURING'\);/, + ); + assert.equal( + SOURCE.match(/beginNewLiveConfiguration\(\);/g)?.length || 0, + 3, + 'mouse replace, mouse insert, and keyboard configuration must all supersede older recovery timers', + ); + assert.match( + SOURCE, + /function deferredRecoverySuperseded\(sessionId, recoveryRevision\) \{[\s\S]{0,160}?liveInteractionRevision !== recoveryRevision[\s\S]{0,100}?currentSessionId !== sessionId/, + 'recovery must be fenced before a replacement configuration has a non-null session id', + ); + assert.match( + SOURCE, + /function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,100}?const recoveryRevision = liveInteractionRevision;[\s\S]*?watchForHandledRuntimeWrapper\(accepted\?\.id, recoveryRevision\)/, + 'accept and handled-wrapper recovery must share the originating interaction revision', + ); + }); + + it('never DOMParser-injects JSX source (#454)', () => { + const isJsxStart = SOURCE.indexOf('function isJsxSourceFile('); + const isJsxEnd = SOURCE.indexOf('function sourceHasSessionWrapper(', isJsxStart); + const isJsxSourceFile = new Function( + SOURCE.slice(isJsxStart, isJsxEnd) + '\nreturn isJsxSourceFile;', + )(); + + assert.equal(isJsxSourceFile('src/App.jsx'), true); + assert.equal(isJsxSourceFile('panel/src/Widget.tsx'), true); + assert.equal(isJsxSourceFile('index.html'), false); + assert.equal(isJsxSourceFile('Card.vue'), false); + + const injectStart = SOURCE.indexOf('function injectVariantsFromSource('); + const injectEnd = SOURCE.indexOf('function buildSvelteExpressionTextMap', injectStart); + const injectFn = SOURCE.slice(injectStart, injectEnd); + const jsxGateIdx = injectFn.indexOf('if (isJsxSourceFile(filePath))'); + const htmlFetchIdx = injectFn.indexOf("const url = 'http://localhost:'"); + assert.ok(jsxGateIdx !== -1 && htmlFetchIdx > jsxGateIdx, 'JSX must return before /source fetch'); + const jsxGate = injectFn.slice(jsxGateIdx, htmlFetchIdx); + assert.doesNotMatch( + jsxGate, + /replaceChild/, + 'the JSX gate must not replaceChild a React tree', + ); + assert.doesNotMatch( + jsxGate, + /discardOrphanedSession/, + 'a missing JSX wrap must not discard from the DOM alone; only the source probe may decide', + ); + assert.match( + jsxGate, + /if \(opts\.orphanDiscard && !liveWrapper && sessionId === currentSessionId\) \{\s*probeJsxWrapperForOrphan\(filePath, sessionId, opts\);/, + 'a resumed CYCLING session with no mounted JSX wrapper must run the source orphan probe (#439)', + ); + assert.match( + jsxGate, + /if \(!liveWrapper\) \{[\s\S]*?showToast\([\s\S]*?return;[\s\S]*?if \(liveWrapper\.dataset\.impeccableMode !== 'insert'\) \{[\s\S]*?recoverEmptyCycling\('source-fallback-empty'\)/, + 'missing wrap waits; empty replace wrap recovers after retries; insert scaffolds stay', + ); + assert.doesNotMatch(SOURCE, /function normalizeSourceFallbackBlock/); + assert.doesNotMatch(SOURCE, /function jsxStyleObjectToCss/); + assert.match( + injectFn, + /parser\.parseFromString\(block, 'text\/html'\)/, + 'HTML fallback should parse the extracted marker block as HTML', + ); + assert.match( + injectFn, + /const startMark = ''/, + 'HTML fallback should still scan HTML comment markers', + ); + assert.doesNotMatch( + SOURCE, + /querySelectorAll\(tag \+ '\\.' \+ cls\.split/, + 'source fallback should not construct unsafe selectors from JSX-ish class strings', + ); + }); + + it('self-discards a JSX session whose wrapper left the source file (#439)', () => { + const probeStart = SOURCE.indexOf('function probeJsxWrapperForOrphan('); + assert.ok(probeStart !== -1, 'the JSX orphan probe must exist'); + const probeEnd = SOURCE.indexOf('function completeSourceInjection', probeStart); + const probe = SOURCE.slice(probeStart, probeEnd); + + // #454 stands: the probe reads the file as text and never builds a DOM + // from it, so raw JSX can never reach the page through this path. + for (const forbidden of ['DOMParser', 'parseFromString', 'replaceChild', 'innerHTML']) { + assert.ok(!probe.includes(forbidden), 'the orphan probe must not ' + forbidden + ' JSX source'); + } + assert.match(probe, /\.then\(r => \{ if \(!r\.ok\) throw new Error\('source read failed: ' \+ r\.status\); return r\.text\(\); \}\)/); + assert.match( + probe, + /if \(sourceHasSessionWrapper\(text, sessionId\)\) return;/, + 'a wrapper still in source is an unmounted component, not an orphan', + ); + assert.match( + probe, + /const onNoWrapper = \(reason\) => \{[\s\S]*?if \(attempt < COMPLETED_SOURCE_FALLBACK_RETRIES\) \{ retryLater\(\); return; \}\s*discardOrphanedSession\(reason\);/, + 'the probe must exhaust the shared retry budget before discarding', + ); + assert.match( + probe, + /onNoWrapper\('variant wrapper missing from source'\)/, + 'a read without the marker retries on the budget, then discards', + ); + // A read that cannot answer must not strand the session (no empty catch), + // and only evidence that the wrapper is gone may discard: a 404 (the file + // renamed or deleted) counts, a transient failure does not. + assert.doesNotMatch(probe, /\.catch\(\(\) => \{\}\)/, 'the probe must not swallow source read failures'); + assert.match( + probe, + /source read failed: 404\$\/\.test\(detail\)\) \{\s*onNoWrapper\('source file missing \(404\)/, + 'a 404 is evidence the file is gone: retry on the budget, then discard', + ); + assert.match( + probe, + /const onUnreadable = \(detail\) => \{[\s\S]*?retryLater\(\); return; \}[\s\S]*?showToast\(/, + 'a transient failure retries on the budget and then keeps the session, telling the user', + ); + assert.doesNotMatch( + probe.slice(probe.indexOf('const onUnreadable')), + /discardOrphanedSession/, + 'a transient failure must never discard a session', + ); + + const matchStart = SOURCE.indexOf('function sourceHasSessionWrapper('); + const sourceHasSessionWrapper = new Function( + SOURCE.slice(matchStart, probeStart) + '\nreturn sourceHasSessionWrapper;', + )(); + assert.equal(sourceHasSessionWrapper('