From 862241ea53142acf7463a31833038c2aa7e8f609 Mon Sep 17 00:00:00 2001 From: Stuart Date: Sun, 4 Oct 2026 16:39:52 +1100 Subject: [PATCH] test(e2e): prove an interrupted deploy finishes end-to-end via the real step-3 push The "step-2 timeout ends with classes AND pubspec both present" criterion was only proven at the unit level (isDeployFinishable, buildFinishPushFileMap). This drives the real handlers: provision stalls past a shortened UI bound while the class has landed, reconcile arms the "Finish deploy" terminal, and running that action pushes the merged pubspec (asserted on the intercepted syncCustomCodeChanges request body), issues no second /deployCustomClasses request, and ends the terminal in a finished state. --- e2e/finish-deploy-e2e.spec.js | 210 ++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 e2e/finish-deploy-e2e.spec.js diff --git a/e2e/finish-deploy-e2e.spec.js b/e2e/finish-deploy-e2e.spec.js new file mode 100644 index 0000000..e408a47 --- /dev/null +++ b/e2e/finish-deploy-e2e.spec.js @@ -0,0 +1,210 @@ +import { test, expect } from "@playwright/test"; +import JSZip from "jszip"; +import { + applyDefaultRoutes, + ENDPOINTS, + customCodeSync, +} from "./fixtures/apiFixtures.js"; + +/** + * E2E proof of the "finish deploy" action (hardening/finish-deploy-e2e). + * + * The stated done criterion — "a simulated step-2 timeout ends with classes AND + * pubspec both present" — was previously only exercised through the pure + * decision helpers (isDeployFinishable, buildFinishPushFileMap). Here it is + * proven through the actual handlers that perform the work: + * + * 1. A provisioning request to the runner stalls past a shortened UI bound + * while the class it was writing HAS landed in the project export. + * __CCC_PROVISION_CUSTOM_CLASSES__ drives the real provision transport + * (fetch -> stream read -> UI bound -> reconcile), which is the same real + * code path the terminal combos above already rely on; the real browser + * "confirm deploy" entry point is unreachable without a full generate -> + * review -> confirm journey, so the transport hook is used to start the + * flow, exactly as stu380-deploy.spec.js and deploy-timeout-reconcile.spec.js + * already do. + * 2. The reconcile proves every class landed, so the terminal offers + * "Finish deploy". The error's finishDeploy context is merged exactly as + * app.js does (mergeFinishDeployContext) and shown through + * window.showCommitUnconfirmedModal — the same dispatch the real + * renderCommitTerminal uses for an UNCONFIRMED outcome — which arms the + * real window.finishProvisionedDeploy action behind the on-screen button. + * 3. Running that action performs step 3 only. We assert on the INTERCEPTED + * syncCustomCodeChanges request body (serialized_yaml carries the merged + * dependency), that NO second request reaches /deployCustomClasses (the + * landed class is not re-provisioned), and that the terminal ends in a + * finished state rather than still-unknown. + */ + +const identity = { + projectId: "ff-proj-0007", + endpoint: "https://api.flutterflow.io", + artifactType: "CustomClass", + artifactName: "GaugeWidget", + fileName: "gauge_widget.dart", +}; + +// The single class the runner "writes" before its response is lost. +const attemptedClass = { + "gauge_widget.dart": { + artifactName: "GaugeWidget", + content: + "class GaugeWidget { final int value; GaugeWidget(this.value); }", + type: "C", + path: "lib/custom_code/gauge_widget.dart", + }, +}; + +// The pubspec the deploy merged locally before provisioning — the one the +// finish push must carry to FlutterFlow (step 3). +const mergedPubspec = `name: my_app +environment: + sdk: ">=3.0.0 <4.0.0" +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 +`; + +test("an interrupted deploy finishes via the real step-3 push, pushes the merged pubspec, and does not re-provision the landed class", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page); + + // The project export after the timeout contains the class the runner wrote — + // what reconcile reads back to decide every class landed. + const zip = new JSZip(); + zip.file("pubspec.yaml", mergedPubspec); + zip.file( + "lib/custom_code/gauge_widget.dart", + attemptedClass["gauge_widget.dart"].content, + ); + const projectZip = await zip.generateAsync({ type: "base64" }); + await page.route("**/exportCode", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ value: { project_zip: projectZip } }), + }); + }); + + // The runner accepts the connection but never answers: provisioning times + // out at the (shortened) UI bound while the class may already be written. + // Every hit is counted so we can prove the finish action adds no further one. + let provisionCalls = 0; + await page.route(ENDPOINTS.deployCustomClasses, () => { + provisionCalls += 1; + return new Promise(() => {}); + }); + + // Capture the step-3 push request exactly as it leaves the page. + let pushBody = null; + await page.route("**/syncCustomCodeChanges", async (route) => { + pushBody = JSON.parse(route.request().postData() || "{}"); + await route.fulfill(customCodeSync()); + }); + + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__CCC_PROVISION_CUSTOM_CLASSES__ === "function" && + typeof window.showCommitUnconfirmedModal === "function", + ); + + // Drive the real provision transport to an unconfirmed timeout, then arm the + // real finish-deploy terminal exactly as the production flow does. + const reconcile = await page.evaluate( + async ({ fileMap, pubspecYaml, uiTimeoutMs, identity }) => { + let error; + try { + await window.__CCC_PROVISION_CUSTOM_CLASSES__({ + fileMap, + pubspecYaml, + uiTimeoutMs, + }); + return { settled: "resolved" }; + } catch (e) { + error = e; + } + const fd = error.finishDeploy; + if (!fd) { + return { settled: "rejected", name: error.name, finishable: false }; + } + // Mirror app.js mergeFinishDeployContext: gives the finished terminal the + // identity and the "Deploy finished" message a normal deploy would show. + const merged = { + ...fd, + result: { + targetIdentity: identity, + metadata: { + artifactType: identity.artifactType, + artifactName: identity.artifactName, + fileName: identity.fileName, + projectId: identity.projectId, + }, + message: `Deploy finished: ${identity.artifactName || "custom classes"} and their dependencies are now in FlutterFlow.`, + addedDependencies: [], + unverified: [], + approximate: [], + warnings: [], + elapsedTime: 0, + }, + }; + window.showCommitUnconfirmedModal({ + success: false, + unconfirmed: true, + targetIdentity: identity, + error: error.message, + finishDeploy: merged, + }); + return { + settled: "rejected", + name: error.name, + finishable: true, + mergedYamlHasDep: fd.serializedYaml.includes("http: ^1.2.0"), + landedPaths: fd.landed.map((entry) => entry.path), + }; + }, + { fileMap: attemptedClass, pubspecYaml: mergedPubspec, uiTimeoutMs: 60, identity }, + ); + + // The provision timed out unconfirmed and the reconcile deemed it finishable. + expect(reconcile.settled).toBe("rejected"); + expect(reconcile.name).toBe("UnconfirmedDeployError"); + expect(reconcile.finishable).toBe(true); + expect(reconcile.landedPaths).toEqual(["lib/custom_code/gauge_widget.dart"]); + // The finish context keeps the merged pubspec — this is what step 3 must push. + expect(reconcile.mergedYamlHasDep).toBe(true); + // Only the original stalled provision reached the runner so far. + expect(provisionCalls).toBe(1); + + // The unconfirmed terminal appears with the finish action, and it names the + // landed class rather than leaving the outcome unknown. + const terminal = page.locator("#commit-terminal-modal"); + await expect(terminal).toBeVisible(); + await expect(terminal.locator("#terminal-heading")).toContainText( + "Deploy almost complete", + ); + await expect(terminal).toContainText("GaugeWidget"); + await expect(page.locator("#terminal-finish-deploy")).toBeVisible(); + + // Run the action — the real window.finishProvisionedDeploy behind the button. + await page.click("#terminal-finish-deploy"); + + // The finished terminal replaces the unknown one with a success claim. + const success = page.locator("#commit-success-modal"); + await expect(success).toBeVisible(); + await expect(page.locator("#success-message")).toContainText("Deploy finished"); + + // Assertion 1: the step-3 push really carried the merged pubspec dependency. + expect(pushBody).not.toBeNull(); + expect(pushBody.serialized_yaml).toContain("http: ^1.2.0"); + // The landed class is excluded from the finish-push file map (only the + // pubspec dependency entry remains, and it travels via serialized_yaml, not + // the file_map), so the push carries no entry for the already-landed class. + expect(JSON.stringify(pushBody.file_map)).not.toContain("gauge_widget.dart"); + + // Assertion 2: finishing did not re-provision the landed class. + expect(provisionCalls).toBe(1); +});