Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions e2e/finish-deploy-e2e.spec.js
Original file line number Diff line number Diff line change
@@ -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,
);
Comment on lines +78 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Export already contains the target dependency

The export contains http: ^1.2.0 before the finish action runs. The request assertion proves the dependency was sent, but not that finishing added it to the project.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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,
});
Comment on lines +134 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Manual terminal setup leaves deploy wiring untested

The test builds the finish context and opens the modal itself. A broken production handoff through mergeFinishDeployContext or renderCommitTerminal can leave this test passing.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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);
});
Loading