Skip to content
Merged
Show file tree
Hide file tree
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
411 changes: 389 additions & 22 deletions app.js

Large diffs are not rendered by default.

291 changes: 291 additions & 0 deletions e2e/stu380-deploy.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 36 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3099,7 +3099,7 @@ <h2 id="results-title">Generation Results</h2>
</div>

<div class="results-action-bar" aria-label="Generation actions">
<button id="btn-deploy-to-ff" class="btn-header-action btn-deploy-results hidden" onclick="initiateCommitToFlutterFlow()">
<button id="btn-deploy-to-ff" data-deploy-start class="btn-header-action btn-deploy-results hidden" onclick="initiateCommitToFlutterFlow()">
<img class="flutterflow-deploy-icon" src="https://flutterflow.io/favicon.ico" alt="" />
Deploy to FlutterFlow
</button>
Expand Down Expand Up @@ -3477,7 +3477,7 @@ <h3 class="font-medium text-gray-700 mb-2 text-sm">Target Project</h3>
</div>
<div class="px-6 py-4 border-t border-gray-100 flex gap-3 justify-end">
<button onclick="closeCommitConfirmModal()" class="px-4 py-2 text-sm font-medium text-gray-400 hover:bg-gray-50 rounded-lg transition-colors">Cancel</button>
<button onclick="confirmCommitToFlutterFlow()" class="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors">Confirm Commit</button>
<button onclick="confirmCommitToFlutterFlow()" data-deploy-confirm class="px-4 py-2 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors">Confirm Commit</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -3536,6 +3536,40 @@ <h3 class="font-medium text-gray-700 mb-2 text-sm">Commit Details</h3>
</div>
</div>

<!-- Commit Terminal Modal (partial / unconfirmed outcomes) -->
<div id="commit-terminal-modal" class="modal-overlay" data-modal-close-handler="closeCommitTerminalModal" data-modal-initial-focus="[data-terminal-close]" onclick="closeCommitTerminalModal(event)">
<div class="modal-content" onclick="event.stopPropagation()" style="max-width:520px;">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 class="font-semibold text-base text-amber-600" id="terminal-heading">Deploy outcome</h2>
<button onclick="closeCommitTerminalModal()" class="modal-close"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>
</div>
<div class="p-6 space-y-4">
<div class="text-center">
<div class="w-14 h-14 bg-amber-50 border border-amber-100 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-7 h-7 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.538-1.333-3.308 0L3.732 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<p class="text-sm font-medium text-gray-800" id="terminal-title"></p>
<p class="text-sm text-gray-500 mt-1" id="terminal-message"></p>
</div>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-100 text-sm">
<h3 class="font-medium text-gray-700 mb-2 text-sm">Deploy target</h3>
<div class="space-y-1.5" id="terminal-identity-list"></div>
</div>
<div id="terminal-file-outcomes" class="hidden bg-red-50 border border-red-100 rounded-lg p-3">
<p class="text-xs font-semibold text-red-700 uppercase mb-1">Per-file outcomes</p>
<ul class="space-y-1"></ul>
</div>
<div class="bg-blue-50 border border-blue-100 rounded-lg p-4 text-sm text-blue-700" id="terminal-guidance"></div>
</div>
<div class="px-6 py-4 border-t border-gray-100 flex gap-3 justify-end items-center">
<a id="terminal-open-ff-link" href="https://app.flutterflow.io/" target="_blank" rel="noopener noreferrer" class="px-4 py-2 text-sm font-medium text-orange-500 hover:bg-orange-50 rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Open in FlutterFlow
</a>
<button data-terminal-close onclick="closeCommitTerminalModal()" class="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors">Close</button>
</div>
</div>
</div>

<!-- Sign In Modal -->
<div id="signin-modal" class="modal-overlay" data-modal-close-handler="closeSignInModal" onclick="closeSignInModal(event)">
<div class="modal-content" onclick="event.stopPropagation()" style="max-width:420px;">
Expand Down
1 change: 1 addition & 0 deletions scripts/test-accessible-modals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}...`);
Expand Down
Loading
Loading