test(app/e2e): cover project-settings error-UX paths (Cluster G3) (#731)
* test(app/e2e): cover 5 error-UX paths + host indicator + script removal for project settings Add helpers/project-settings.ts DSL helpers and extend projects-settings.spec.ts with 5 new tests covering the error paths dropped by PR #725: - stale_project_config callout + disabled save + reload recovery - invalid_project_config read callout + reload after fix - write_failed callout + retry + reload recovery - single-host static indicator vs picker chip - script removal via kebab menu + confirm dialog * refactor(app/e2e): unslop project-settings helpers and spec - Fix removeProjectScript: derive trigger testID from row testID instead of using scoped locator (which was timing out) - Extract inline writeFile call in invalid-config test to restorePaseoConfig helper - Replace raw testID click in write_failed test with clickReloadProjectSettings - Remove writeFile from spec imports; drop defensive ?? "" fallback * test(app/e2e): add read-transport and offline no-target tests for project settings - Add read-transport failure test: WS-level drop during readProjectConfig triggers read-transport-callout; Reload retries until WS reconnects and refetch succeeds. - Add no-target test: WS drop after form load triggers NoEditableTarget via live connectionStatus check (useHostRuntimeSnapshot) on the selected host. - Hoist openProjects/editWorktreeSetup from spec body into project-settings helper. - Fix expectNoProjectSettingsError to accept optional timeout (needed for toPass loop). - Add isHostGone to renderContent: after readQuery errors are checked, offline/error connectionStatus renders NoEditableTarget without unmounting ProjectSettingsBody. * fixup(app/e2e): correct misleading comments on WS close → error-state mapping
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
// action-0 is always "Try again"; action-1 is always "Reload".
|
||||
// The write-failed callout renders these two buttons in a fixed order.
|
||||
await page.getByTestId("write-failed-callout-action-0").click();
|
||||
}
|
||||
|
||||
export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
// Scope to the active error callout so the locator is unambiguous.
|
||||
// At most one error callout renders at a time.
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
|
||||
const errorCalloutTestId: Record<ErrorKind, string> = {
|
||||
stale: "stale-callout",
|
||||
invalid: "invalid-callout",
|
||||
write_failed: "write-failed-callout",
|
||||
transport: "read-transport-callout",
|
||||
read_failed: "read-failed-callout",
|
||||
};
|
||||
|
||||
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectNoProjectSettingsError(
|
||||
page: Page,
|
||||
kind: ErrorKind,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
|
||||
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// --- Script-list assertions and interactions ---
|
||||
|
||||
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
|
||||
// prefix but contain "-menu-").
|
||||
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
|
||||
).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectEmptyScriptList(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No scripts yet.")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
|
||||
const row = page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
|
||||
.filter({ hasText: scriptName })
|
||||
.first();
|
||||
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
|
||||
// from the row's testID to avoid scoped locator unreliability.
|
||||
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
|
||||
await page.getByTestId(`script-row-menu-${id}`).click();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
}
|
||||
|
||||
// --- File manipulation ---
|
||||
|
||||
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
|
||||
}
|
||||
|
||||
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
|
||||
const configPath = path.join(repoPath, "paseo.json");
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
config._bump = Date.now();
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export async function restorePaseoConfig(
|
||||
repoPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o555);
|
||||
}
|
||||
|
||||
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o755);
|
||||
}
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
function buildDaemonPortPattern(): RegExp {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
}
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
|
||||
// in DaemonClient due to describeTransportClose returning a non-empty string.
|
||||
export async function installDaemonConnectionGate(
|
||||
page: Page,
|
||||
): Promise<{ drop: () => Promise<void> }> {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user