Unverified 提交 abe4861c authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

setup flow e2e test (#2028)

<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Adds a new IPC entrypoint and changes Node.js status resolution logic (even though gated to `E2E_TEST_BUILD`), which could affect runtime behavior if the flag/channel is misused. The rest is E2E-only test additions/refactors. > > **Overview** > Adds Playwright E2E coverage for the initial *Setup Dyad* flow, including Node.js install UX states, provider setup navigation, and verifying the setup banner disappears after configuring an AI provider. > > Introduces a **test-only IPC** (`test:set-node-mock`) to deterministically mock Node.js installed/not-installed status in E2E builds, with a new `PageObject.setNodeMock()` helper and preload allowlisting. Refactors Node.js status handling to centralize Node download URL selection via `getNodeDownloadUrl()`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5dd01b9e95375ff4a39d00b9ec9ecb40685d89a0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds Playwright E2E coverage for the setup flow and a test-only IPC to mock Node.js install status, making the flow fully testable and deterministic. Also refactors Node download URL selection for clarity. - **New Features** - Added setup_flow.spec.ts to verify: - Banner states when Node is installed. - Node install flow with “Continue… I installed Node.js”. - AI provider navigation and banner dismissal after configuration. - Introduced test-only IPC channel test:set-node-mock (allowlisted in preload) gated by E2E_TEST_BUILD=true; added PageObject.setNodeMock() helper. - Refactored node_handlers to use getNodeDownloadUrl() and return mocked versions when enabled. - **Migration** - Run E2E with E2E_TEST_BUILD=true to enable the mock IPC. <sup>Written for commit 5dd01b9e95375ff4a39d00b9ec9ecb40685d89a0. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2028"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 afa381ee
...@@ -1071,6 +1071,16 @@ export class PageObject { ...@@ -1071,6 +1071,16 @@ export class PageObject {
await this.page.getByRole("button", { name: "Add Model" }).click(); await this.page.getByRole("button", { name: "Add Model" }).click();
} }
async setUpTestProviderApiKey() {
// Fill in a test API key for the custom provider
await this.page
.getByPlaceholder(/Enter new.*API Key here/)
.fill("test-api-key-12345");
await this.page.getByRole("button", { name: "Save Key" }).click();
// Wait for the key to be saved
await expect(this.page.getByText("test-api-key-12345")).toBeVisible();
}
async goToSettingsTab() { async goToSettingsTab() {
await this.page.getByRole("link", { name: "Settings" }).click(); await this.page.getByRole("link", { name: "Settings" }).click();
} }
...@@ -1423,6 +1433,22 @@ export class PageObject { ...@@ -1423,6 +1433,22 @@ export class PageObject {
async clickAgentConsentDecline() { async clickAgentConsentDecline() {
await this.page.getByRole("button", { name: "Decline" }).click(); await this.page.getByRole("button", { name: "Decline" }).click();
} }
////////////////////////////////
// Test-only: Node.js Mock Control
////////////////////////////////
/**
* Set the mock state for Node.js installation status.
* @param installed - true = mock as installed, false = mock as not installed, null = use real check
*/
async setNodeMock(installed: boolean | null) {
await this.page.evaluate(async (installed) => {
await (window as any).electron.ipcRenderer.invoke("test:set-node-mock", {
installed,
});
}, installed);
}
} }
interface ElectronConfig { interface ElectronConfig {
......
import { testWithConfig } from "./helpers/test_helper";
import { expect } from "@playwright/test";
const testSetup = testWithConfig({
showSetupScreen: true,
});
testSetup.describe("Setup Flow", () => {
testSetup(
"setup banner shows correct state when node.js is installed",
async ({ po }) => {
// Verify the "Setup Dyad" heading is visible
await expect(
po.page.getByText("Setup Dyad", { exact: true }),
).toBeVisible();
// Verify both accordion sections are visible
await expect(
po.page.getByText("1. Install Node.js (App Runtime)"),
).toBeVisible();
await expect(po.page.getByText("2. Setup AI Access")).toBeVisible();
// Expand Node.js section and verify completed state
await po.page.getByText("1. Install Node.js (App Runtime)").click();
await expect(
po.page.getByText(/Node\.js \(v[\d.]+\) installed/),
).toBeVisible();
// AI provider section should show warning state (needs action)
await expect(
po.page.getByRole("button", { name: /Setup Google Gemini API Key/ }),
).toBeVisible();
await expect(
po.page.getByRole("button", { name: /Setup OpenRouter API Key/ }),
).toBeVisible();
},
);
testSetup("node.js install flow", async ({ po }) => {
// Start with Node.js not installed
await po.setNodeMock(false);
await po.page.reload();
// Verify setup banner and install button are visible
await expect(
po.page.getByText("Setup Dyad", { exact: true }),
).toBeVisible();
await expect(
po.page.getByRole("button", { name: "Install Node.js Runtime" }),
).toBeVisible();
// Manual configuration link should be visible
await expect(
po.page.getByText("Node.js already installed? Configure path manually"),
).toBeVisible();
// Click the install button (opens external URL)
await po.page
.getByRole("button", { name: "Install Node.js Runtime" })
.click();
// After clicking install, the "Continue" button should appear
await expect(
po.page.getByRole("button", { name: /Continue.*I installed Node\.js/ }),
).toBeVisible();
// Simulate user having installed Node.js
await po.setNodeMock(true);
// Click the continue button
await po.page
.getByRole("button", { name: /Continue.*I installed Node\.js/ })
.click();
// Node.js should now show as installed
await expect(
po.page.getByText(/Node\.js \(v[\d.]+\) installed/),
).toBeVisible();
// Reset mock
await po.setNodeMock(null);
});
testSetup("ai provider setup flow", async ({ po }) => {
// Verify setup banner is visible
await expect(
po.page.getByText("Setup Dyad", { exact: true }),
).toBeVisible();
// Dismiss telemetry consent if present
const laterButton = po.page.getByRole("button", { name: "Later" });
if (await laterButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await laterButton.click();
}
// Test Google Gemini navigation
await po.page
.getByRole("heading", { name: "Setup Google Gemini API Key" })
.click({ force: true });
await expect(
po.page.getByRole("heading", { name: "Configure Google" }),
).toBeVisible();
await po.page.getByRole("button", { name: "Go Back" }).click();
// Test OpenRouter navigation
await po.page
.getByRole("heading", { name: "Setup OpenRouter API Key" })
.click();
await expect(
po.page.getByRole("heading", { name: "Configure OpenRouter" }),
).toBeVisible();
await po.page.getByRole("button", { name: "Go Back" }).click();
// Test other providers navigation
await po.page
.getByRole("heading", { name: "Setup other AI providers" })
.click();
await expect(po.page.getByRole("link", { name: "Settings" })).toBeVisible();
// Now configure the test provider
await po.setUpTestProvider();
// Set up API key so provider is considered configured
await po.page.getByRole("heading", { name: "test-provider" }).click();
await po.setUpTestProviderApiKey();
await po.setUpTestModel();
// Go back to apps tab
await po.goToAppsTab();
// After configuring a provider, the setup banner should be gone
await expect(
po.page.getByText("Setup Dyad", { exact: true }),
).not.toBeVisible();
await expect(po.page.getByText("Build a new app")).toBeVisible();
});
});
import { dialog } from "electron"; import { dialog, ipcMain } from "electron";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { platform, arch } from "os"; import { platform, arch } from "os";
import fixPath from "fix-path"; import fixPath from "fix-path";
...@@ -12,7 +12,40 @@ import { systemContracts } from "../types/system"; ...@@ -12,7 +12,40 @@ import { systemContracts } from "../types/system";
const logger = log.scope("node_handlers"); const logger = log.scope("node_handlers");
// Test-only: Mock state for Node.js installation status
// null = use real check, true = mock as installed, false = mock as not installed
let mockNodeInstalled: boolean | null = null;
function getNodeDownloadUrl(): string {
// Default to mac download url.
let nodeDownloadUrl = "https://nodejs.org/dist/v22.14.0/node-v22.14.0.pkg";
if (platform() == "win32") {
if (arch() === "arm64" || arch() === "arm") {
nodeDownloadUrl =
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-arm64.msi";
} else {
// x64 is the most common architecture for Windows so it's the
// default download url.
nodeDownloadUrl =
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi";
}
}
return nodeDownloadUrl;
}
export function registerNodeHandlers() { export function registerNodeHandlers() {
// Test-only handler to control Node.js mock state
// Guarded by E2E_TEST_BUILD environment variable
if (process.env.E2E_TEST_BUILD === "true") {
ipcMain.handle(
"test:set-node-mock",
async (_, { installed }: { installed: boolean | null }) => {
logger.log("test:set-node-mock called with installed:", installed);
mockNodeInstalled = installed;
},
);
}
createTypedHandler(systemContracts.getNodejsStatus, async () => { createTypedHandler(systemContracts.getNodejsStatus, async () => {
logger.log( logger.log(
"handling ipc: nodejs-status for platform:", "handling ipc: nodejs-status for platform:",
...@@ -20,6 +53,22 @@ export function registerNodeHandlers() { ...@@ -20,6 +53,22 @@ export function registerNodeHandlers() {
"and arch:", "and arch:",
arch(), arch(),
); );
const nodeDownloadUrl = getNodeDownloadUrl();
// Test-only: Return mock state if set
if (process.env.E2E_TEST_BUILD === "true" && mockNodeInstalled !== null) {
logger.log("Using mock Node.js status:", mockNodeInstalled);
if (mockNodeInstalled) {
return {
nodeVersion: "v22.14.0",
pnpmVersion: "9.0.0",
nodeDownloadUrl,
};
}
return { nodeVersion: null, pnpmVersion: null, nodeDownloadUrl };
}
// Run checks in parallel // Run checks in parallel
const [nodeVersion, pnpmVersion] = await Promise.all([ const [nodeVersion, pnpmVersion] = await Promise.all([
runShellCommand("node --version"), runShellCommand("node --version"),
...@@ -30,19 +79,6 @@ export function registerNodeHandlers() { ...@@ -30,19 +79,6 @@ export function registerNodeHandlers() {
"pnpm --version || (corepack enable pnpm && pnpm --version) || (npm install -g pnpm@latest-10 && pnpm --version)", "pnpm --version || (corepack enable pnpm && pnpm --version) || (npm install -g pnpm@latest-10 && pnpm --version)",
), ),
]); ]);
// Default to mac download url.
let nodeDownloadUrl = "https://nodejs.org/dist/v22.14.0/node-v22.14.0.pkg";
if (platform() == "win32") {
if (arch() === "arm64" || arch() === "arm") {
nodeDownloadUrl =
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-arm64.msi";
} else {
// x64 is the most common architecture for Windows so it's the
// default download url.
nodeDownloadUrl =
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi";
}
}
return { nodeVersion, pnpmVersion, nodeDownloadUrl }; return { nodeVersion, pnpmVersion, nodeDownloadUrl };
}); });
......
...@@ -47,7 +47,10 @@ const CHAT_STREAM_CHANNELS = getStreamChannels(chatStreamContract); ...@@ -47,7 +47,10 @@ const CHAT_STREAM_CHANNELS = getStreamChannels(chatStreamContract);
const HELP_STREAM_CHANNELS = getStreamChannels(helpStreamContract); const HELP_STREAM_CHANNELS = getStreamChannels(helpStreamContract);
// Test-only channels (handler only registered in E2E test builds, but channel always allowed) // Test-only channels (handler only registered in E2E test builds, but channel always allowed)
const TEST_INVOKE_CHANNELS = ["test:simulateQuotaTimeElapsed"] as const; const TEST_INVOKE_CHANNELS = [
"test:simulateQuotaTimeElapsed",
"test:set-node-mock",
] as const;
/** /**
* All valid invoke channels derived from contracts. * All valid invoke channels derived from contracts.
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论