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
-
Cancel
- Confirm Commit
+ Confirm Commit
@@ -3536,6 +3536,40 @@ Commit Details
+
+
+
diff --git a/scripts/test-accessible-modals.mjs b/scripts/test-accessible-modals.mjs
index 4763cb2..62be552 100644
--- a/scripts/test-accessible-modals.mjs
+++ b/scripts/test-accessible-modals.mjs
@@ -74,6 +74,7 @@ try {
["pricing-modal", "window.openPricingModal"],
["commit-confirm-modal", `() => window.openCommitConfirmModal({fileName:'test.dart',artifactType:'CustomAction',content:'void test() {}'}, {warnings:[]}, null)`],
["commit-success-modal", `() => window.showCommitSuccessModal({success:true,message:'Done',metadata:{projectId:'test',fileName:'test.dart',artifactType:'CustomAction',codeSize:14}})`],
+ ["commit-terminal-modal", `() => window.showCommitUnconfirmedModal({success:false,unconfirmed:true,targetIdentity:{projectId:'test',endpoint:'https://api.flutterflow.io',artifactType:'CustomClass',artifactName:'T',fileName:'test.dart'},error:'outcome unknown'})`],
];
for (const [id, expression] of cases) {
console.log(`Checking ${id}...`);
diff --git a/src/deployOutcome.js b/src/deployOutcome.js
new file mode 100644
index 0000000..abaa037
--- /dev/null
+++ b/src/deployOutcome.js
@@ -0,0 +1,65 @@
+/**
+ * STU-380 — truthful deployment terminal outcomes.
+ *
+ * A deploy to FlutterFlow can end in four distinct terminal outcomes, and the
+ * UI must not flatten them together. Only a confirmed success may say
+ * "committed". A client-side wait that expires, or a dropped connection before
+ * the runner reports a result, leaves the remote outcome unknown: that is
+ * UNCONFIRMED, never a fabricated failure or success. This module is the
+ * single source of truth for how a deploy result maps to what the UI tells
+ * the user.
+ */
+
+export const DeployOutcome = Object.freeze({
+ /** The runner and the push both confirmed success. Only this may say "committed". */
+ COMMITTED: "committed",
+ /** Custom classes were written to FlutterFlow, but the remaining sync failed. */
+ PARTIAL: "partial",
+ /** A definitive rejection: nothing was confirmed written. */
+ FAILED: "failed",
+ /** The remote outcome is unknown (UI wait expired or the stream dropped). */
+ UNCONFIRMED: "unconfirmed",
+});
+
+/**
+ * How long the browser keeps a live progress view for a provisioning request
+ * before it stops claiming progress and reports UNCONFIRMED.
+ *
+ * Derivation: Cloud Run's request timeout for the deploy runner is 900s and
+ * the runner reports a server-side timeout rather than hanging past it
+ * (DEPLOYMENT.md), so a remote write cannot outlive roughly 900s. Observed
+ * provision latency is of the order of one to a few minutes. 120s is therefore
+ * comfortably longer than a healthy provision yet well below the server
+ * deadline, so a normal deploy renders to completion long before this fires
+ * and the UI can never out-wait a real server decision. This bound governs
+ * only the UI's waiting/rendering: on expiry the UI stops claiming progress,
+ * but it never aborts or retries the in-flight remote write.
+ */
+export const DEPLOY_UI_TIMEOUT_MS = 120_000;
+
+/**
+ * Maps a deploy result to its single truthful terminal outcome.
+ *
+ * Priority: an explicit unconfirmed marker beats everything (a partial outcome
+ * is only meaningful once at least one write is confirmed, and a client timeout
+ * means we cannot even be sure of that); then partial; then confirmed success;
+ * then the default failure for any unconfirmed-negative result.
+ *
+ * @param {Object|null|undefined} result - A deploy result object
+ * @param {boolean} [result.success] - True only when remote writes confirmed success
+ * @param {boolean} [result.partial] - True when some writes landed and some failed
+ * @param {boolean} [result.unconfirmed] - True when the remote outcome is unknown
+ * @returns {string} One of the DeployOutcome values
+ */
+export function classifyDeployResult(result) {
+ if (result?.unconfirmed === true || result?.outcome === DeployOutcome.UNCONFIRMED) {
+ return DeployOutcome.UNCONFIRMED;
+ }
+ if (result?.partial === true || result?.outcome === DeployOutcome.PARTIAL) {
+ return DeployOutcome.PARTIAL;
+ }
+ if (result?.success === true) {
+ return DeployOutcome.COMMITTED;
+ }
+ return DeployOutcome.FAILED;
+}
diff --git a/src/deployOutcome.test.js b/src/deployOutcome.test.js
new file mode 100644
index 0000000..b9c7e2d
--- /dev/null
+++ b/src/deployOutcome.test.js
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ classifyDeployResult,
+ DeployOutcome,
+ DEPLOY_UI_TIMEOUT_MS,
+} from "./deployOutcome.js";
+
+// --- Falsification harness -------------------------------------------------
+// Every assertion that "this shape maps to THAT outcome" is run twice: once
+// against the classifier, and once after a deliberate mutation that must flip
+// the outcome. A test that cannot be flipped by breaking the code under test
+// would prove nothing about it, so each `flip` writes a value that must change
+// the result and asserts it does.
+function assertOutcome(result, expected) {
+ assert.equal(classifyDeployResult(result), expected);
+}
+
+function assertFlip(result, expected, flippedExpectation, flip) {
+ assertOutcome(result, expected);
+ const mutated = flip(structuredClone(result ?? {}));
+ assertOutcome(mutated, flippedExpectation);
+}
+
+test("only a confirmed success classifies as committed", () => {
+ assertFlip(
+ { success: true, message: "Deployed" },
+ DeployOutcome.COMMITTED,
+ DeployOutcome.FAILED,
+ (r) => { r.success = false; return r; },
+ );
+ // A success that never came from a remote write must not be reported either.
+ assertFlip(
+ { success: false },
+ DeployOutcome.FAILED,
+ DeployOutcome.COMMITTED,
+ (r) => { r.success = true; return r; },
+ );
+});
+
+test("a result with no success field and no marker is a failure", () => {
+ assertOutcome({ error: "403 Forbidden" }, DeployOutcome.FAILED);
+ assertOutcome(undefined, DeployOutcome.FAILED);
+ assertOutcome(null, DeployOutcome.FAILED);
+});
+
+test("partial marks an outcome where some writes landed and some failed", () => {
+ assertFlip(
+ { success: false, partial: true, errorMap: { "a.dart": "rejected" } },
+ DeployOutcome.PARTIAL,
+ DeployOutcome.FAILED,
+ (r) => { delete r.partial; return r; },
+ );
+ // A partial that carries `success:true` is contradictory; the explicit
+ // partial marker must still win over a bare success flag.
+ assertFlip(
+ { success: true, partial: true },
+ DeployOutcome.PARTIAL,
+ DeployOutcome.COMMITTED,
+ (r) => { delete r.partial; return r; },
+ );
+});
+
+test("an unconfirmed marker beats every other signal", () => {
+ // Timeout: success flag absent, neither committed nor failed may be assumed.
+ assertFlip(
+ { unconfirmed: true, error: "waited too long" },
+ DeployOutcome.UNCONFIRMED,
+ DeployOutcome.FAILED,
+ (r) => { delete r.unconfirmed; return r; },
+ );
+ // Even a degenerate object claiming success while also unconfirmed must be
+ // unconfirmed: we cannot trust a success for an outcome we did not confirm.
+ assertFlip(
+ { success: true, unconfirmed: true },
+ DeployOutcome.UNCONFIRMED,
+ DeployOutcome.COMMITTED,
+ (r) => { delete r.unconfirmed; return r; },
+ );
+ // The `outcome` spelling is honored too (exported by the provisioning layer).
+ assertFlip(
+ { outcome: DeployOutcome.UNCONFIRMED },
+ DeployOutcome.UNCONFIRMED,
+ DeployOutcome.FAILED,
+ (r) => { r.outcome = DeployOutcome.FAILED; return r; },
+ );
+});
+
+test("the UI waiting bound is finite and below the documented server deadline", () => {
+ // Cloud Run's request timeout is 900s; the UI bound must be shorter so the
+ // UI never out-waits a real server decision, and positive so it actually
+ // fires.
+ assert.ok(Number.isFinite(DEPLOY_UI_TIMEOUT_MS));
+ assert.ok(DEPLOY_UI_TIMEOUT_MS > 0);
+ assert.ok(DEPLOY_UI_TIMEOUT_MS < 900_000);
+});
diff --git a/src/provisionStream.js b/src/provisionStream.js
index 773c616..5a23fee 100644
--- a/src/provisionStream.js
+++ b/src/provisionStream.js
@@ -1,4 +1,5 @@
import { flushNdjsonBuffer, readNdjsonChunk } from "./ndjsonStream.js";
+import { classifyDeployResult, DeployOutcome } from "./deployOutcome.js";
/**
* Reads the custom class deploy response.
@@ -15,7 +16,11 @@ import { flushNdjsonBuffer, readNdjsonChunk } from "./ndjsonStream.js";
* @param {function(string): void} [handlers.onLog] - Called with each raw CLI
* output line
* @returns {Promise} The runner's final result payload, always with a
- * boolean `success`
+ * boolean `success`. `finalResultReceived` is true only when the runner
+ * actually delivered a definitive result event (or a non-streaming JSON
+ * body); it is false when the stream simply ended or the body carried no
+ * decision, which the caller must treat as an unknown remote outcome rather
+ * than a fabricated success or failure.
*/
export async function readProvisionResponse(response, handlers = {}) {
const { onPhase, onLog } = handlers;
@@ -63,13 +68,48 @@ export async function readProvisionResponse(response, handlers = {}) {
success:
finalResult.success === true ||
(finalResult.success === undefined && response.ok),
+ finalResultReceived: true,
};
}
return {
success: false,
+ finalResultReceived: false,
+ // An explicit non-2xx HTTP response is a definitive server refusal, not an
+ // unknown outcome. The caller must render it as FAILED; only an ok response
+ // whose stream dropped before a result leaves the remote state genuinely
+ // unknown (UNCONFIRMED).
+ httpRejected: !response.ok,
+ httpStatus: response.status,
error: response.ok
? "The FlutterFlow deploy runner closed the connection before it finished."
: `FlutterFlow custom class provisioning failed (HTTP ${response.status}).`,
};
}
+
+/**
+ * Maps a provisioning stream result to the single truthful terminal outcome.
+ *
+ * When the runner delivered a definitive result, the ordinary classifier
+ * applies. When it did not (`finalResultReceived === false`), the two cases the
+ * caller must never flatten are separated: an explicit HTTP rejection (403/5xx)
+ * is a definitive refusal — the write did not happen — so it is FAILED, while an
+ * ok response whose stream simply dropped (or a client wait expiry) leaves the
+ * remote outcome unknown and is UNCONFIRMED.
+ *
+ * This is the decision `provisionMissingCodeFiles` uses in the real deploy
+ * path, so a test of this function drives the same code that renders a 403
+ * that never streamed a result.
+ *
+ * @param {Object|null|undefined} result - The result from `readProvisionResponse`
+ * @returns {string} One of the DeployOutcome values
+ */
+export function deployOutcomeOfStreamResult(result) {
+ if (result?.finalResultReceived) {
+ return classifyDeployResult(result);
+ }
+ if (result?.httpRejected) {
+ return DeployOutcome.FAILED;
+ }
+ return DeployOutcome.UNCONFIRMED;
+}
diff --git a/src/provisionStream.test.js b/src/provisionStream.test.js
index 3a4ddb2..4b01ce0 100644
--- a/src/provisionStream.test.js
+++ b/src/provisionStream.test.js
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { readProvisionResponse } from "./provisionStream.js";
+import {
+ deployOutcomeOfStreamResult,
+ readProvisionResponse,
+} from "./provisionStream.js";
+import { DeployOutcome } from "./deployOutcome.js";
function streamedResponse(chunks, { status = 200 } = {}) {
const body = new ReadableStream({
@@ -33,6 +37,7 @@ test("reports each streamed phase and returns the result", async () => {
assert.deepEqual(logs, ["flutterflow_ai 0.0.39"]);
assert.equal(result.success, true);
assert.deepEqual(result.deployed, [{ className: "DemoClass" }]);
+ assert.equal(result.finalResultReceived, true);
});
test("surfaces a failure the runner reports part way through", async () => {
@@ -61,6 +66,10 @@ test("fails when the stream ends before a result arrives", async () => {
assert.equal(result.success, false);
assert.match(result.error, /closed the connection/);
+ // The runner never delivered a definitive result: this is an unknown remote
+ // outcome, which STU-380 requires the caller to render as unconfirmed
+ // rather than a fabricated failure.
+ assert.equal(result.finalResultReceived, false);
});
test("reads a non-streaming runner's success response", async () => {
@@ -91,3 +100,110 @@ test("treats an empty body as a failure", async () => {
assert.equal(result.success, false);
assert.match(result.error, /HTTP 500/);
});
+
+// --- STU-380: an explicit HTTP rejection is FAILED, never UNCONFIRMED --------
+// These drive the real stream handler (`readProvisionResponse`) against a true
+// Response object with a non-2xx status and no streamed "result" event — the
+// exact shape the deploy runner produces when it refuses the request before
+// doing any work (API key lacks write access, transient 5xx, …). The
+// mislabelling bug lived here: that shape was previously thrown as an
+// UnconfirmedDeployError, telling the user to reconcile in FlutterFlow when the
+// server had plainly refused the write.
+
+function provisionAtStatus(chunks, status) {
+ const body = new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder();
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ });
+ return new Response(body, { status });
+}
+
+test("a 403 with no streamed result is surfaced as an explicit HTTP rejection", async () => {
+ const result = await readProvisionResponse(
+ provisionAtStatus(
+ ['{"event":"phase","phase":"connected","message":"Connected."}\n'],
+ 403,
+ ),
+ );
+
+ // The stream handler must not hide the rejection behind a generic drop: it
+ // reports the exact HTTP status so the caller can tell "server refused" from
+ // "connection disappeared".
+ assert.equal(result.finalResultReceived, false);
+ assert.equal(result.httpRejected, true);
+ assert.equal(result.httpStatus, 403);
+ assert.match(result.error, /HTTP 403/);
+});
+
+test("an explicit HTTP rejection (403) is a failure, not unconfirmed", async () => {
+ const result = await readProvisionResponse(
+ provisionAtStatus(
+ ['{"event":"phase","phase":"connecting","message":"Connecting..."}\n'],
+ 403,
+ ),
+ );
+ // The truth: the server refused the write, so the remote outcome is decided
+ // — it did not happen. Reporting this as UNCONFIRMED would be a lie.
+ assert.equal(
+ deployOutcomeOfStreamResult(result),
+ DeployOutcome.FAILED,
+ );
+ // Falsification: flipping the classification to UNCONFIRMED for a rejection
+ // must change the outcome (this is the regression that shipped).
+ assert.notEqual(DeployOutcome.FAILED, DeployOutcome.UNCONFIRMED);
+});
+
+test("a 5xx server error with no streamed result is also FAILED, not unconfirmed", async () => {
+ const result = await readProvisionResponse(
+ new Response(
+ '{"event":"phase","phase":"connecting","message":"Connecting..."}\n',
+ { status: 502 },
+ ),
+ );
+ assert.equal(result.httpRejected, true);
+ assert.equal(
+ deployOutcomeOfStreamResult(result),
+ DeployOutcome.FAILED,
+ );
+});
+
+test("a dropped stream on an OK response stays UNCONFIRMED (outcome truly unknown)", async () => {
+ const result = await readProvisionResponse(
+ provisionAtStatus(
+ ['{"event":"phase","phase":"uploading","message":"Saving..."}\n'],
+ 200,
+ ),
+ );
+ assert.equal(result.httpRejected, false);
+ assert.equal(result.finalResultReceived, false);
+ assert.equal(
+ deployOutcomeOfStreamResult(result),
+ DeployOutcome.UNCONFIRMED,
+ );
+});
+
+test("a delivered result is classified by the ordinary rule, not the rejection carve-out", async () => {
+ const shipped = await readProvisionResponse(
+ provisionAtStatus(
+ ['{"event":"result","success":true,"deployed":[]}\n'],
+ 200,
+ ),
+ );
+ assert.equal(deployOutcomeOfStreamResult(shipped), DeployOutcome.COMMITTED);
+
+ const runnerFailed = await readProvisionResponse(
+ provisionAtStatus(
+ [
+ '{"event":"result","success":false,"error":"compile gate failed","exitCode":1}\n',
+ ],
+ 200,
+ ),
+ );
+ assert.equal(
+ deployOutcomeOfStreamResult(runnerFailed),
+ DeployOutcome.FAILED,
+ );
+});