diff --git a/app.js b/app.js index 4d7172c..38c49b2 100644 --- a/app.js +++ b/app.js @@ -46,7 +46,15 @@ import { trimEmail, } from "./src/authMagicLink.js"; import { planCustomCodeVerification } from "./src/customCodeVerification.js"; -import { readProvisionResponse } from "./src/provisionStream.js"; +import { + deployOutcomeOfStreamResult, + readProvisionResponse, +} from "./src/provisionStream.js"; +import { + classifyDeployResult, + DeployOutcome, + DEPLOY_UI_TIMEOUT_MS, +} from "./src/deployOutcome.js"; import { buildFlutterFlowSyncMetadata } from "./src/flutterFlowSyncMetadata.js"; import { initHeroMarkField } from "./src/heroMarkField.js"; import { @@ -2114,6 +2122,54 @@ function invalidateProjectSourceCache(apiClient) { projectSourceCache.delete(projectSourceCacheKey(apiClient)); } +/** + * A carve-out from the ordinary failure path: the client stopped waiting for + * (or lost the connection to) the deployment runner before it reported a + * decision, so the remote outcome is unknown. This is deliberately *not* a + * plain failure — nothing here may be reported as failed or committed when we + * cannot know what the server did. + */ +class UnconfirmedDeployError extends Error { + constructor(message) { + super(message); + this.name = "UnconfirmedDeployError"; + this.outcome = DeployOutcome.UNCONFIRMED; + } +} + +/** + * Bounded waiting for a remote write. The UI shows live progress only while + * this promise settles; once the bound expires the waiter stops claiming + * progress and `onExpire` decides the terminal outcome. The underlying promise + * is deliberately *not* aborted or retried: it is handed back to the pending + * work, and any late settlement is ignored (the bound already resolved the + * waiter). This is a rendering bound, never a transport one. + * @param {Promise} promise - Work whose remote outcome may or may not arrive + * @param {number} ms - How long to wait for a decision + * @param {function} onExpire - Called with (resolve, reject) exactly once on expiry + * @returns {Promise} Settles with the work's value, or via onExpire on timeout + */ +function withUiTimeout(promise, ms, onExpire) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn, value, which) => { + if (settled) return; + settled = true; + if (which === "resolve") resolve(value); + else reject(value); + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + onExpire(resolve, reject); + }, ms); + promise.then( + (value) => { clearTimeout(timer); finish(resolve, value, "resolve"); }, + (error) => { clearTimeout(timer); finish(reject, error, "reject"); }, + ); + }); +} + async function provisionMissingCodeFiles( apiClient, fileMap, @@ -2173,10 +2229,42 @@ async function provisionMissingCodeFiles( }), }); - const result = await readProvisionResponse(response, { - onPhase: (message) => commitProgress.setSubstatus(message), - onLog: (message) => console.log(`[custom class deploy] ${message}`), - }); + const result = await withUiTimeout( + readProvisionResponse(response, { + onPhase: (message) => commitProgress.setSubstatus(message), + onLog: (message) => console.log(`[custom class deploy] ${message}`), + }), + DEPLOY_UI_TIMEOUT_MS, + (resolve, reject) => + reject( + new UnconfirmedDeployError( + "The deploy is still working on the FlutterFlow server, but this browser tab has stopped waiting. " + + "Your custom classes may or may not have been written — the outcome is not yet known. " + + "Open your FlutterFlow project to reconcile before retrying the deploy.", + ), + ), + ); + + // A stream that ended (or a body that carried) no definitive result leaves + // the remote decision ambiguous. There are two distinct cases which must not + // be flattened: a server that explicitly refused the request (a 403/5xx + // `response.ok === false`) has made a definitive decision — the write did not + // happen — so that is a FAILURE, not an unknown outcome; only an ok response + // whose stream dropped before a result (or a client wait expiry) leaves the + // remote state genuinely unknown and must be reported UNCONFIRMED. + if (!result.finalResultReceived) { + if (deployOutcomeOfStreamResult(result) === DeployOutcome.FAILED) { + throw new Error( + result.error || + `FlutterFlow custom class provisioning failed (HTTP ${result.httpStatus}).`, + ); + } + throw new UnconfirmedDeployError( + "The connection to the FlutterFlow deploy runner dropped before it reported a result. " + + "The deploy may still be finishing on the server; open your FlutterFlow project to reconcile " + + "before retrying.", + ); + } if (!result.success) { const details = result.details ? ` ${result.details}` : ""; @@ -2197,6 +2285,10 @@ async function provisionMissingCodeFiles( syncFileMap: excludeProvisionedCodeFiles(fileMap, missingCodeFiles), unverified, approximate, + // Custom classes were actually upserted on the FlutterFlow side by the + // runner. Any failure of the *remaining* sync is therefore a partial + // outcome — classes wrote, the rest did not — never a clean total failure. + provisionSucceeded: true, }; } @@ -2937,6 +3029,18 @@ async function executeCommit(code, options = {}) { console.log(`Starting commit for ${artifactName} (${artifactType})`); + // Written before the try so the catch can decide partial vs failed: once the + // runner has upserted custom classes, a failure of the remaining sync is a + // genuine partial outcome, not a clean total failure. + let provisionClassesWritten = false; + const targetIdentity = { + projectId: null, + endpoint: getFlutterFlowEndpoint(), + artifactType, + artifactName, + fileName, + }; + try { // Step 1: Prepare the code commitState.setState(CommitState.PREPARING); @@ -3011,6 +3115,8 @@ async function executeCommit(code, options = {}) { `Provision ${artifactName} custom class`, serializedYaml, ); + targetIdentity.projectId = projectId; + provisionClassesWritten = provisioning.provisionSucceeded === true; const syncMetadata = await buildApiSyncMetadata( provisioning.syncFileMap, @@ -3061,6 +3167,7 @@ async function executeCommit(code, options = {}) { success: true, message: `Successfully committed ${codeInfo.fileName} to FlutterFlow`, metadata, + targetIdentity, addedDependencies: pubspecMerge.added, unverified: provisioning.unverified, approximate: provisioning.approximate, @@ -3095,10 +3202,39 @@ async function executeCommit(code, options = {}) { } } + // A client-side wait expiry or a dropped stream means the remote outcome + // is unknown: report unconfirmed, never fabricated failed or committed. + if (error instanceof UnconfirmedDeployError) { + return { + success: false, + unconfirmed: true, + error: error.message, + errorMap, + targetIdentity, + state: commitState.currentState, + elapsedTime: commitState.getElapsedTime(), + }; + } + + // Custom classes were already upserted but the remaining sync failed: + // that is a partial outcome, and concealing the written classes would lie. + if (provisionClassesWritten) { + return { + success: false, + partial: true, + error: error.message, + errorMap, + targetIdentity, + state: commitState.currentState, + elapsedTime: commitState.getElapsedTime(), + }; + } + return { success: false, error: error.message, errorMap: errorMap, + targetIdentity, state: commitState.currentState, elapsedTime: commitState.getElapsedTime(), }; @@ -3108,6 +3244,17 @@ async function executeCommit(code, options = {}) { async function executeBundleCommit(bundlePlan, options = {}) { const { pipelineResult } = options; + let provisionClassesWritten = false; + const targetIdentity = { + projectId: null, + endpoint: getFlutterFlowEndpoint(), + artifactType: "Bundle", + artifactName: bundlePlan?.title, + fileName: bundlePlan?.fileEntries + ? `${bundlePlan.fileEntries.length} artifacts` + : "bundle", + }; + try { commitState.setState(CommitState.PREPARING); if (bundlePlan.errors?.length > 0) { @@ -3173,6 +3320,8 @@ async function executeBundleCommit(bundlePlan, options = {}) { `Provision ${bundlePlan.title} custom classes`, serializedYaml, ); + targetIdentity.projectId = projectId; + provisionClassesWritten = provisioning.provisionSucceeded === true; const syncMetadata = await buildApiSyncMetadata( provisioning.syncFileMap, provisioning.remoteFiles, @@ -3226,6 +3375,7 @@ async function executeBundleCommit(bundlePlan, options = {}) { success: true, message: `Successfully committed ${bundlePlan.fileEntries.length} artifacts to FlutterFlow`, metadata, + targetIdentity, addedDependencies: pubspecMerge.added, unverified: provisioning.unverified, approximate: provisioning.approximate, @@ -3241,10 +3391,38 @@ async function executeBundleCommit(bundlePlan, options = {}) { } catch (error) { console.error("Bundle commit execution failed:", error); commitState.setError(error); + + const errorMap = error.errorMap || new Map(); + + if (error instanceof UnconfirmedDeployError) { + return { + success: false, + unconfirmed: true, + error: error.message, + errorMap, + targetIdentity, + state: commitState.currentState, + elapsedTime: commitState.getElapsedTime(), + }; + } + + if (provisionClassesWritten) { + return { + success: false, + partial: true, + error: error.message, + errorMap, + targetIdentity, + state: commitState.currentState, + elapsedTime: commitState.getElapsedTime(), + }; + } + return { success: false, error: error.message, - errorMap: error.errorMap || new Map(), + errorMap, + targetIdentity, state: commitState.currentState, elapsedTime: commitState.getElapsedTime(), }; @@ -5484,6 +5662,128 @@ function showCommitFailureModal(result) { showCommitError(result); } +/** + * Populates the shared terminal modal for the two outcomes that are real but + * are neither a clean success nor a clean failure: PARTIAL (some custom + * classes were written, the remaining sync failed) and UNCONFIRMED (the remote + * outcome is unknown because the client stopped waiting or the stream + * dropped). It always shows the same target identity the user confirmed, the + * per-file outcomes when the runner/push reported them, and reconciliation + * guidance that never hides the manual FlutterFlow step of checking the + * project. + */ +function populateCommitTerminalModal(result, { heading, title, guidance }) { + const identity = result.targetIdentity || {}; + const modal = document.getElementById("commit-terminal-modal"); + if (!modal) return false; + + const set = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = val || ""; + }; + + set("terminal-heading", heading); + set("terminal-title", title); + set("terminal-message", result.error || result.message || ""); + + const idList = document.getElementById("terminal-identity-list"); + if (idList) { + const rows = [ + ["Project", identity.projectId || result.metadata?.projectId], + ["Endpoint", identity.endpoint], + ["File", identity.fileName || result.metadata?.fileName], + ["Artifact", identity.artifactType || result.metadata?.artifactType], + ]; + idList.innerHTML = rows + .map( + ([label, value]) => + `
${escapeHtml( + label, + )}:${escapeHtml( + String(value ?? "—"), + )}
`, + ) + .join(""); + } + + const fileList = document.getElementById("terminal-file-outcomes"); + let errorMap = result.errorMap; + if (errorMap && !(errorMap instanceof Map)) { + errorMap = new Map(Object.entries(errorMap)); + } + if (fileList && errorMap && errorMap.size > 0) { + fileList.innerHTML = [...errorMap.entries()] + .map( + ([file, info]) => + `
  • ${escapeHtml( + file, + )}: ${escapeHtml(formatFlutterFlowFileError(info))}
  • `, + ) + .join(""); + fileList.classList.remove("hidden"); + } else if (fileList) { + fileList.classList.add("hidden"); + } + + const guidanceEl = document.getElementById("terminal-guidance"); + if (guidanceEl) guidanceEl.innerHTML = guidance; + + const ffLink = document.getElementById("terminal-open-ff-link"); + const projectId = identity.projectId || result.metadata?.projectId; + if (ffLink) { + ffLink.href = projectId + ? `https://app.flutterflow.io/project/${projectId}` + : "https://app.flutterflow.io/"; + } + + openModal(modal); + return true; +} + +/** + * Terminal outcome: some custom classes were written to FlutterFlow, but the + * remaining sync was rejected. "Committed" would lie — part of the deploy did + * land, but part failed. + */ +function showCommitPartialModal(result) { + populateCommitTerminalModal(result, { + heading: "Deploy partially applied", + title: + "Some custom classes reached FlutterFlow, but the deploy did not fully complete.", + guidance: + "The custom classes the runner confirmed are already in your project and are not lost. " + + "Review the per-file errors above, fix them in FlutterFlow or regenerate, then deploy again. " + + "Open the project to see exactly what landed.", + }); +} + +/** + * Terminal outcome: the client stopped waiting (bounded UI timeout) or the + * connection dropped before the runner reported a result. The remote outcome + * is unknown — this is never reported as committed or as failed, and the + * in-flight write is never cancelled. + */ +function showCommitUnconfirmedModal(result) { + populateCommitTerminalModal(result, { + heading: "Deploy outcome not yet known", + title: + "This browser stopped waiting before the FlutterFlow server reported a result.", + guidance: + "The deploy may still be finishing on the server — it was not cancelled. " + + "Open your FlutterFlow project and confirm whether the class landed before retrying, so you do not " + + "push a duplicate or build on top of an unknown state.", + }); +} + +/** + * Closes the shared terminal modal (partial / unconfirmed outcomes). + * @param {Event} [event] - Optional click event + */ +function closeCommitTerminalModal(event) { + if (event && event.target !== event.currentTarget) return; + closeModal(document.getElementById("commit-terminal-modal")); +} + /** * Toggles the code preview section. */ @@ -5733,19 +6033,68 @@ function commitNeedsProvisioning(commitData) { /** * Confirms the commit after modal review. */ +// A deploy is in flight. The confirm action and the deploy toggle are disabled +// for its whole run, so a user clicking twice cannot start a second push and a +// second modal cannot be opened over a live one. +let deployInFlight = false; + +function setDeployBusy(busy) { + deployInFlight = busy; + // The modal's confirm button and every deploy trigger are disabled for the + // whole run so a second click cannot start an overlapping push. + const confirm = document.querySelector( + "#commit-confirm-modal button[data-deploy-confirm]", + ); + if (confirm) confirm.disabled = busy; + const triggers = [ + ...document.querySelectorAll("[data-deploy-start]"), + ...document.querySelectorAll("#btn-deploy-to-ff"), + ]; + triggers.forEach((el) => { + el.disabled = busy; + }); +} + +/** + * Dispatches a deploy result to the single truthful terminal presentation. + * Every outcome releases the UI busy state; only COMMITTED reaches the success + * modal. Partial and unconfirmed get their own truthful modals. + * @param {Object} result - A deploy result (see deployOutcome.classifyDeployResult) + */ +function renderCommitTerminal(result) { + hideCommitProgress(); + setDeployBusy(false); + const outcome = classifyDeployResult(result); + if (outcome === DeployOutcome.COMMITTED) { + showCommitSuccessModal(result); + } else if (outcome === DeployOutcome.PARTIAL) { + showCommitPartialModal(result); + } else if (outcome === DeployOutcome.UNCONFIRMED) { + showCommitUnconfirmedModal(result); + } else { + showCommitFailureModal(result); + } +} + async function confirmCommitToFlutterFlow() { if (!pendingCommitData) { console.error("No pending commit data"); return; } + if (deployInFlight) { + console.warn("Deploy already in flight; ignoring duplicate confirm."); + return; + } // Null the pending data before any await: it is only cleared at the end of // this function otherwise, so a second confirm click landing mid-commit - // would read the same data and push twice concurrently. + // would read the same data and push twice concurrently. The deploy-in-flight + // guard above makes this doubly safe. const commitData = pendingCommitData; pendingCommitData = null; commitTargetProjectId = readCommitTargetProjectId(); closeCommitConfirmModal(); + setDeployBusy(true); showCommitProgress({ withProvisioning: commitNeedsProvisioning(commitData) }); if (commitData.bundlePlan) { @@ -5757,14 +6106,7 @@ async function confirmCommitToFlutterFlow() { }); commitTargetProjectId = null; - hideCommitProgress(); - - if (result.success) { - showCommitSuccessModal(result); - } else { - showCommitFailureModal(result); - } - + renderCommitTerminal(result); return; } @@ -5783,13 +6125,7 @@ async function confirmCommitToFlutterFlow() { }); commitTargetProjectId = null; - hideCommitProgress(); - - if (result.success) { - showCommitSuccessModal(result); - } else { - showCommitFailureModal(result); - } + renderCommitTerminal(result); } // Global exports @@ -5838,6 +6174,9 @@ window.updateFlutterFlowCredentialStatus = updateFlutterFlowCredentialStatus; window.openCommitConfirmModal = openCommitConfirmModal; window.closeCommitConfirmModal = closeCommitConfirmModal; window.closeCommitSuccessModal = closeCommitSuccessModal +window.closeCommitTerminalModal = closeCommitTerminalModal +window.showCommitPartialModal = showCommitPartialModal +window.showCommitUnconfirmedModal = showCommitUnconfirmedModal window.showCommitSuccessModal = showCommitSuccessModal window.showCommitFailureModal = showCommitFailureModal; window.toggleCodePreview = toggleCodePreview; @@ -6522,6 +6861,34 @@ if (import.meta.env.DEV) { showResultsView(getSelectedArtifactCode(), renderMarkdownAudit(pipelineState.step3Result)); } window.__CCC_RENDER_RESULTS__ = renderResultsPreview; + + // Deterministic test hook for STU-380: drives the exact same terminal + // presentation the real confirm flow uses, against a fixture result, so the + // browser suite can assert truthful outcomes without a real remote write. + window.__CCC_RENDER_DEPLOY_TERMINAL__ = renderCommitTerminal; + // Lets the browser suite start a live deploy progress overlay so it can + // prove a terminal state releases the busy state. + window.__CCC_START_DEPLOY_PROGRESS__ = () => + commitProgress.start({ withProvisioning: true }); + // Lets the browser suite put the deploy into (and out of) its exactly-real + // busied state — the same `setDeployBusy` the confirm flow calls — so a test + // can prove a terminal outcome releases the busy lock on the deploy controls. + window.__CCC_SET_DEPLOY_BUSY__ = setDeployBusy; + // Lets the browser suite open the real commit-confirm modal (and set real + // `pendingCommitData`) with fixture code, so the duplicate-submit guard in + // `confirmCommitToFlutterFlow` can be driven through the real confirm flow + // without a real remote write. + window.__CCC_OPEN_COMMIT_CONFIRM__ = (codeInfo, checks, deps) => + openCommitConfirmModal( + { + fileName: "gauge_widget.dart", + artifactType: "CustomClass", + content: "class GaugeWidget {}", + ...codeInfo, + }, + { warnings: [], ...checks }, + deps || {}, + ); } function copyResultsCode() { diff --git a/e2e/stu380-deploy.spec.js b/e2e/stu380-deploy.spec.js new file mode 100644 index 0000000..0b15b50 --- /dev/null +++ b/e2e/stu380-deploy.spec.js @@ -0,0 +1,291 @@ +import { test, expect } from "@playwright/test"; +import { applyDefaultRoutes } from "./fixtures/apiFixtures.js"; + +/** + * STU-380 — truthful deployment terminal outcomes. + * + * A deploy ends in one of four states and the UI must never flatten them: + * only a confirmed success may say "committed". 403 / file rejection / compile + * gate are failures; a mixed outcome (classes written, rest rejected) is + * partial; a client wait expiry or a dropped stream is unconfirmed. Every + * terminal state must release the UI busy state and offer safe next actions + * without concealing the manual FlutterFlow reconciliation step. These tests + * drive the same presentation the real confirm flow uses + * (`__CCC_RENDER_DEPLOY_TERMINAL__`), so a fixture map to the DOM without a + * real remote write — the definitive-committed claim still belongs to STU-392 + * integration acceptance. + */ + +// Identity is captured once at confirmation and must survive into every +// terminal state unchanged. +const identity = { + projectId: "ff-proj-0007", + endpoint: "https://api.flutterflow.io", + artifactType: "CustomClass", + artifactName: "GaugeWidget", + fileName: "gauge_widget.dart", +}; + +async function render(page, result) { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__CCC_RENDER_DEPLOY_TERMINAL__ === "function", + ); + // The real flow hides the progress overlay and re-enables the deploy + // controls itself; start a deploy first so we can prove it was released. + await page.evaluate(() => window.__CCC_START_DEPLOY_PROGRESS__()); + await page.evaluate( + ({ result }) => window.__CCC_RENDER_DEPLOY_TERMINAL__(result), + { result }, + ); +} + +test.describe("STU-380 deployment terminal outcomes", () => { + test("only a confirmed success says committed; the success modal carries identity", async ({ page }) => { + await render(page, { + success: true, + message: "Successfully committed gauge_widget.dart to FlutterFlow", + targetIdentity: identity, + metadata: { + projectId: identity.projectId, + fileName: identity.fileName, + artifactType: identity.artifactType, + codeSize: 4096, + }, + addedDependencies: [], + }); + + const success = page.locator("#commit-success-modal"); + await expect(success).toBeVisible(); + // "committed" appears only here. + await expect(success).toContainText("Commit Successful"); + await expect(success).toContainText("Successfully committed"); + // Identity carried from confirmation into the terminal state. + await expect(success).toContainText(identity.projectId); + await expect(success).toContainText(identity.fileName); + }); + + test("a 403 rejection is a failure that never claims committed", async ({ page }) => { + await render(page, { + success: false, + targetIdentity: identity, + error: "HTTP 403 Forbidden — your FlutterFlow API key lacks write access.", + }); + + const output = page.locator("#step3-output"); + await expect(output).toContainText("FlutterFlow Commit Failed"); + await expect(output).toContainText("403"); + // A definitive rejection must not read as committed. + await expect(output).not.toContainText("committed"); + await expect(output).not.toContainText("Committed"); + }); + + test("a per-file rejection surfaces each file's error, not a blanket success", async ({ page }) => { + await render(page, { + success: false, + targetIdentity: identity, + error: "FlutterFlow rejected 1 of 1 files.", + errorMap: { + "gauge_widget.dart": [{ errorMessage: "Duplicate class GaugeWidget", isCritical: true }], + }, + }); + + const output = page.locator("#step3-output"); + await expect(output).toContainText("gauge_widget.dart"); + await expect(output).toContainText("Duplicate class GaugeWidget"); + await expect(output).not.toContainText("committed"); + }); + + test("a compile gate failure shows the analyzer rejection, never committed", async ({ page }) => { + await render(page, { + success: false, + targetIdentity: identity, + error: "The generated custom code does not compile, so nothing was deployed to FlutterFlow.", + details: "named argument 'radius' required by package", + }); + + const output = page.locator("#step3-output"); + await expect(output).toContainText("does not compile"); + await expect(output).not.toContainText("committed"); + }); + + test("a partial outcome opens the terminal modal with per-file outcomes and does not claim committed", async ({ page }) => { + await render(page, { + success: false, + partial: true, + targetIdentity: identity, + error: "Classes were written, but the sync was rejected.", + errorMap: { + "pubspec_merge.dart": [{ errorMessage: "Dependency conflict", isCritical: true }], + }, + }); + + const terminal = page.locator("#commit-terminal-modal"); + await expect(terminal).toBeVisible(); + await expect(terminal).toContainText("Deploy partially applied"); + await expect(terminal).toContainText("not lost"); + // Per-file outcomes rendered. + await expect(terminal).toContainText("pubspec_merge.dart"); + await expect(terminal).toContainText("Dependency conflict"); + // Identity carried from confirmation. + await expect(terminal).toContainText(identity.projectId); + await expect(terminal).toContainText(identity.fileName); + // Safe next action available. + await expect(terminal.locator("#terminal-open-ff-link")).toHaveAttribute( + "href", + `https://app.flutterflow.io/project/${identity.projectId}`, + ); + // Truthful: not committed. + await expect(terminal).not.toContainText("Committed"); + }); + + test("a disconnect leaves the outcome unconfirmed with reconciliation guidance, never committed", async ({ page }) => { + await render(page, { + success: false, + unconfirmed: true, + targetIdentity: identity, + error: "The connection to the FlutterFlow deploy runner dropped before it reported a result.", + }); + + const terminal = page.locator("#commit-terminal-modal"); + await expect(terminal).toBeVisible(); + await expect(terminal).toContainText("Deploy outcome not yet known"); + // Reconciliation guidance names the manual FlutterFlow step. + await expect(terminal).toContainText("Open your FlutterFlow project"); + await expect(terminal).toContainText("not cancelled"); + await expect(terminal).not.toContainText("Committed"); + await expect(terminal).not.toContainText("Commit Failed"); + await expect(terminal.locator("#terminal-open-ff-link")).toHaveAttribute( + "href", + `https://app.flutterflow.io/project/${identity.projectId}`, + ); + }); + + test("a wait-timeout is unconfirmed, not a fabricated failure", async ({ page }) => { + await render(page, { + success: false, + unconfirmed: true, + targetIdentity: identity, + error: "This browser stopped waiting before the server reported a result.", + }); + + const terminal = page.locator("#commit-terminal-modal"); + await expect(terminal).toBeVisible(); + await expect(terminal).toContainText("Deploy outcome not yet known"); + await expect(terminal).toContainText("not cancelled"); + await expect(terminal).not.toContainText("Committed"); + }); + + test("every terminal state releases the UI busy state", async ({ page }) => { + await render(page, { + success: true, + targetIdentity: identity, + metadata: { projectId: identity.projectId, fileName: identity.fileName, artifactType: identity.artifactType, codeSize: 1024 }, + addedDependencies: [], + }); + // Progress overlay was started, then must be gone in the terminal state. + await expect(page.locator("#commit-progress-overlay")).toBeHidden(); + await page.evaluate(() => window.closeCommitSuccessModal()); + + await page.evaluate( + ({ identity }) => { + window.__CCC_RENDER_DEPLOY_TERMINAL__({ + success: false, + partial: true, + targetIdentity: identity, + error: "sync rejected", + }); + }, + { identity }, + ); + await expect(page.locator("#commit-progress-overlay")).toBeHidden(); + }); + + test("a partial/unconfirmed terminal keeps the deploy controls enabled (busy released)", async ({ page }) => { + await render(page, { + success: false, + partial: true, + targetIdentity: identity, + error: "sync rejected", + }); + await expect(page.locator("#commit-terminal-modal")).toBeVisible(); + // The deploy trigger must be re-enabled so a retry is possible. + await expect(page.locator("#btn-deploy-to-ff")).toBeEnabled(); + await expect( + page.locator("#commit-confirm-modal button[data-deploy-confirm]"), + ).toBeEnabled(); + }); + + test("a duplicate confirm while a deploy is in flight is ignored (guard)", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__CCC_OPEN_COMMIT_CONFIRM__ === "function" && + typeof window.__CCC_SET_DEPLOY_BUSY__ === "function", + ); + + // Put the deploy into its in-flight state, exactly as confirmCommitToFlutterFlow + // does at the top of a real run, then open the real commit-confirm modal so + // there is real pending commit data a second (errant) confirm would consume. + await page.evaluate(() => window.__CCC_SET_DEPLOY_BUSY__(true)); + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + + // A second confirm while a deploy is already in flight must be a no-op. + await page.evaluate(() => window.confirmCommitToFlutterFlow()); + + // Guard held: the confirm modal was not closed and no progress overlay was + // started (both would only happen if a second commit ran). + await expect(page.locator("#commit-confirm-modal")).toBeVisible(); + await expect(page.locator("#commit-progress-overlay")).toBeHidden(); + }); + + test("a terminal state releases the busy lock on the deploy controls (guard release)", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__CCC_OPEN_COMMIT_CONFIRM__ === "function" && + typeof window.__CCC_SET_DEPLOY_BUSY__ === "function" && + typeof window.__CCC_RENDER_DEPLOY_TERMINAL__ === "function", + ); + + // Busy the deploy controls by driving the exact same setDeployBusy(true) + // the confirm flow calls at the start of a run. + await page.evaluate(() => window.__CCC_SET_DEPLOY_BUSY__(true)); + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await expect(page.locator("#btn-deploy-to-ff")).toBeDisabled(); + await expect( + page.locator("#commit-confirm-modal button[data-deploy-confirm]"), + ).toBeDisabled(); + + // A terminal outcome must release the busy state through the real terminal + // renderer (renderCommitTerminal -> setDeployBusy(false)). + await page.evaluate( + ({ identity }) => { + window.__CCC_RENDER_DEPLOY_TERMINAL__({ + success: false, + partial: true, + targetIdentity: identity, + error: "sync rejected", + }); + }, + { identity }, + ); + + await expect(page.locator("#btn-deploy-to-ff")).toBeEnabled(); + await expect( + page.locator("#commit-confirm-modal button[data-deploy-confirm]"), + ).toBeEnabled(); + }); +}); diff --git a/index.html b/index.html index 7bc4bef..41b5f97 100644 --- a/index.html +++ b/index.html @@ -3099,7 +3099,7 @@

    Generation Results

    - @@ -3477,7 +3477,7 @@

    Target Project

    - +
    @@ -3536,6 +3536,40 @@

    Commit Details

    + + +