mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
* fix(app): hold the loading screen until first-run setup is decided A new user saw the empty home, the sidebar and a logo jump before setup popped in. Startup now keeps the splash while setup may still open over the empty home, for at most 3 s and only while the setup host is reachable. Remembered-workspace restore does not wait. Web dialogs now fade in as they already fade out. (cherry picked from commita0a7620b62) (cherry picked from commit72d7c0bf26) * fix(app): fade the dialog card in and land the dim at once (cherry picked from commit6ef05705a0) (cherry picked from commit71c28b431c) * fix(app): keep the loading screen up while startup redirects (cherry picked from commit0b28d4cbff) (cherry picked from commit13cc485e6f) * test(app): cover a setup host that drops while startup waits (cherry picked from commit2a906ef83e) (cherry picked from commit7b6e21207a) * fix(app): type the web-only dialog enter animation style The CSS animation props are not part of ViewStyle. CI typechecks without the generated expo-env.d.ts, which declares them, so the cast is required there. (cherry picked from commitb887d979d2) * fix(app): keep the setup window mounted when the theme changes The shell's appearance boundary remounts its dialog hosts whenever a theme color changes, so picking a theme in setup, or a system light/dark switch, replaced the open setup window and replayed its fade. Setup draws colors through CSS variables and themed props, which update in place, and changes no font size, so it now sits outside that remount. The dialog enter fade is skipped with reduced motion, and its style is typed for CI, which typechecks without the generated expo-env.d.ts. (cherry picked from commitb24c6eacf8) (cherry picked from commitcb0e9907f5) * docs(changelog): prepare 0.15.2 (cherry picked from commit5af1a8ff66) * chore(release): cut 0.15.2 (cherry picked from commite896c05b09)
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { mkdir, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, metroTest as test } from "../support/fixtures";
|
|
import {
|
|
addProjectFromSetup,
|
|
continueSetup,
|
|
expectSetupStep,
|
|
finishSetup,
|
|
goToSetupStep,
|
|
hostGivesAgentsPaseoTools,
|
|
openAppAsNewUser,
|
|
openRelayPairingFromSetup,
|
|
closeRelayPairing,
|
|
emptyHome,
|
|
loadingScreen,
|
|
openTailscaleDocsFromSetup,
|
|
paseoToolsSwitch,
|
|
pickTheme,
|
|
projectSuggestion,
|
|
projectSuggestionRow,
|
|
readFirstLaunch,
|
|
recordFirstLaunch,
|
|
setupModal,
|
|
setupPrimaryButton,
|
|
showSetupAgainFromSettings,
|
|
skipSetup,
|
|
startSetupHost,
|
|
} from "../support/helpers/onboarding";
|
|
import {
|
|
chooseCustomSkills,
|
|
openSkillSelection,
|
|
saveSkillSelection,
|
|
} from "../support/helpers/agent-skills";
|
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
|
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { openSettingsHostSection } from "../support/helpers/settings";
|
|
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
|
|
|
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
|
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
|
|
|
|
test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
|
const host = await startSetupHost("fresh");
|
|
const repo = await createTempGitRepo("onboarding-folder-");
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
|
|
await test.step("setup opens on Appearance and a theme applies at once", async () => {
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
await pickTheme(page, "Midnight");
|
|
await expect(
|
|
setupModal(page).getByText("Let's start by picking a look", { exact: true }),
|
|
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
|
|
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
|
|
});
|
|
|
|
await test.step("Providers shows the same provider list as Settings", async () => {
|
|
await continueSetup(page);
|
|
await expectSetupStep(page, "Your agents");
|
|
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
|
});
|
|
|
|
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
|
await continueSetup(page);
|
|
await expectSetupStep(page, "Add your projects");
|
|
await expect(
|
|
setupModal(page).getByText("No recent projects found", { exact: true }),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await addProjectFromSetup(page, repo.path);
|
|
await expectSetupStep(page, "Add your projects");
|
|
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
|
|
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
|
|
});
|
|
|
|
await test.step("Orchestration starts with its switches on", async () => {
|
|
await continueSetup(page);
|
|
await expectSetupStep(page, "Let agents use Paseo");
|
|
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
|
await expect(
|
|
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
|
|
).toBeChecked();
|
|
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
|
|
});
|
|
|
|
await test.step("Skip setup closes it for good", async () => {
|
|
await skipSetup(page);
|
|
await page.reload();
|
|
await expect(
|
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({
|
|
hasText: path.basename(repo.path),
|
|
}),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await expect(setupModal(page)).toHaveCount(0);
|
|
});
|
|
} finally {
|
|
await host.close();
|
|
await repo.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
|
const host = await startSetupHost("first-launch");
|
|
|
|
try {
|
|
await recordFirstLaunch(page);
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
|
|
const launch = await readFirstLaunch(page);
|
|
expect(launch.appBeforeSetup).toBe(false);
|
|
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
|
|
await expect(setupModal(page)).toHaveCSS("opacity", "1");
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("with reduced motion, setup appears without fading in", async ({ page }) => {
|
|
const host = await startSetupHost("reduced-motion");
|
|
|
|
try {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await recordFirstLaunch(page);
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
|
|
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
|
|
const host = await startSetupHost("theme-change");
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
const setupWindow = await setupModal(page).elementHandle();
|
|
|
|
for (const title of [
|
|
"Welcome to Paseo",
|
|
"Your agents",
|
|
"Add your projects",
|
|
"Let agents use Paseo",
|
|
"Take your agents with you",
|
|
]) {
|
|
if (title !== "Welcome to Paseo") await continueSetup(page);
|
|
await expectSetupStep(page, title);
|
|
const heading = setupModal(page).getByRole("heading", { name: title });
|
|
const headingNode = await heading.elementHandle();
|
|
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
|
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
await expect(heading).not.toHaveCSS("color", lightColor);
|
|
await page.emulateMedia({ colorScheme: "light" });
|
|
await expect(heading).toHaveCSS("color", lightColor);
|
|
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
|
|
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
|
|
}
|
|
|
|
await goToSetupStep(page, "Appearance");
|
|
await pickTheme(page, "Midnight");
|
|
await pickTheme(page, "Light");
|
|
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
|
|
const host = await startSetupHost("slow-workspaces");
|
|
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
|
|
|
try {
|
|
await recordFirstLaunch(page);
|
|
gate.holdNextClientRequest("fetch_workspaces_request");
|
|
await openAppAsNewUser(page, host);
|
|
await gate.waitForHeldClientRequest();
|
|
await expect(loadingScreen(page)).toBeVisible();
|
|
await expect(emptyHome(page)).toHaveCount(0);
|
|
|
|
gate.releaseHeldClientRequest();
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
|
|
const host = await startSetupHost("stuck-workspaces");
|
|
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
|
|
|
try {
|
|
gate.holdNextClientRequest("fetch_workspaces_request");
|
|
await openAppAsNewUser(page, host);
|
|
await gate.waitForHeldClientRequest();
|
|
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
|
await expect(setupModal(page)).toHaveCount(0);
|
|
|
|
// Setup still opens once the host answers.
|
|
gate.releaseHeldClientRequest();
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
|
|
const host = await startSetupHost("dropped-workspaces");
|
|
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
|
|
|
try {
|
|
gate.holdNextClientRequest("fetch_workspaces_request");
|
|
await openAppAsNewUser(page, host);
|
|
await gate.waitForHeldClientRequest();
|
|
await gate.drop();
|
|
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
|
await expect(setupModal(page)).toHaveCount(0);
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
|
|
const host = await startSetupHost("unreachable");
|
|
await host.close();
|
|
|
|
await openAppAsNewUser(page, host);
|
|
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
|
await expect(setupModal(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
|
const repo = await createTempGitRepo("onboarding-history-");
|
|
const repoName = path.basename(repo.path);
|
|
const subdirectory = path.join(repo.path, "packages", "web");
|
|
await mkdir(subdirectory, { recursive: true });
|
|
const worktree = `${repo.path}-fix`;
|
|
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
|
|
const scratch = await createTempDirectory("onboarding-scratch-");
|
|
const scratchName = path.basename(scratch.path);
|
|
const host = await startSetupHost("history", {
|
|
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
|
|
withBrokenProvider: true,
|
|
});
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
await goToSetupStep(page, "Projects");
|
|
await expectSetupStep(page, "Add your projects");
|
|
|
|
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
|
|
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
|
|
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
|
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
|
});
|
|
|
|
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
|
|
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
|
|
timeout: 60_000,
|
|
});
|
|
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
|
|
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
|
});
|
|
|
|
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
|
|
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
|
|
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
|
|
await expect(
|
|
setupModal(page).getByRole("button", { name: "Add project", exact: true }),
|
|
).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
|
|
await projectSuggestion(page, repoName).click();
|
|
await projectSuggestion(page, scratchName).click();
|
|
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
|
|
await projectSuggestion(page, repoName).click();
|
|
await projectSuggestion(page, scratchName).click();
|
|
});
|
|
|
|
await test.step("a folder that cannot be added keeps the user on the step", async () => {
|
|
await scratch.cleanup();
|
|
await setupPrimaryButton(page, "Add 2 projects").click();
|
|
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
|
|
await expectSetupStep(page, "Add your projects");
|
|
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
|
await expect(projectSuggestionRow(page, scratchName)).toContainText("Directory not found");
|
|
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
|
|
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
|
|
});
|
|
|
|
await test.step("with nothing checked, Continue moves on without adding", async () => {
|
|
await projectSuggestion(page, scratchName).click();
|
|
await setupPrimaryButton(page, "Continue").click();
|
|
await expectSetupStep(page, "Let agents use Paseo");
|
|
await expect(
|
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await expect(
|
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
|
|
).toHaveCount(0);
|
|
});
|
|
} finally {
|
|
await host.close();
|
|
await rm(worktree, { recursive: true, force: true });
|
|
await repo.cleanup();
|
|
await scratch.cleanup();
|
|
}
|
|
});
|
|
|
|
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
|
const host = await startSetupHost("existing");
|
|
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await expect(
|
|
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await expect(setupModal(page)).toHaveCount(0);
|
|
|
|
await showSetupAgainFromSettings(page);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
|
|
await test.step("Paseo tools start on in setup and Continue turns them on", async () => {
|
|
await goToSetupStep(page, "Orchestration");
|
|
await expectSetupStep(page, "Let agents use Paseo");
|
|
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
|
await continueSetup(page);
|
|
});
|
|
|
|
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
|
|
await expectSetupStep(page, "Take your agents with you");
|
|
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
|
|
expect(await openTailscaleDocsFromSetup(page)).toBe(
|
|
"https://paseo.sh/docs/connectivity#tailscale",
|
|
);
|
|
const pairing = await openRelayPairingFromSetup(page);
|
|
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
|
|
await enableRelayAndExpectOffer(page);
|
|
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
|
|
await closeRelayPairing(pairing);
|
|
});
|
|
|
|
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
|
|
await finishSetup(page);
|
|
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
|
await openSettingsHostSection(page, host.serverId, "agents");
|
|
await expect(paseoToolsSwitch(page)).toBeChecked();
|
|
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
|
|
});
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
|
|
const host = await startSetupHost("no-skills");
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await expectSetupStep(page, "Welcome to Paseo");
|
|
await goToSetupStep(page, "Orchestration");
|
|
await expectSetupStep(page, "Let agents use Paseo");
|
|
|
|
await openSkillSelection(page);
|
|
await chooseCustomSkills(page, []);
|
|
await saveSkillSelection(page);
|
|
const skillsSwitch = setupModal(page).getByRole("switch", {
|
|
name: "Orchestration skills",
|
|
exact: true,
|
|
});
|
|
await expect(skillsSwitch).not.toBeChecked();
|
|
|
|
await continueSetup(page);
|
|
await expectSetupStep(page, "Take your agents with you");
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|
|
|
|
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const host = await startSetupHost("compact");
|
|
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
|
// The compact sheet is the only surface on screen, and Orchestration is its last step.
|
|
const step = (title: string) =>
|
|
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
|
|
|
|
try {
|
|
await openAppAsNewUser(page, host);
|
|
await step("Welcome to Paseo");
|
|
await primary("Continue").click();
|
|
await step("Your agents");
|
|
await primary("Continue").click();
|
|
await step("Add your projects");
|
|
await primary("Continue").click();
|
|
|
|
// The setup host starts with Paseo tools off; the switch starts on.
|
|
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
|
|
await step("Let agents use Paseo");
|
|
await expect(paseoToolsSwitch(page)).toBeChecked();
|
|
// Skills are not installed, so leaving them off has nothing to apply.
|
|
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
|
|
await skillsSwitch.click();
|
|
await expect(skillsSwitch).not.toBeChecked();
|
|
gate.holdNextClientRequest("set_daemon_config_request");
|
|
await primary("Finish").click();
|
|
|
|
// While the choices apply, none of them can change.
|
|
await gate.waitForHeldClientRequest();
|
|
await expect(paseoToolsSwitch(page)).toBeDisabled();
|
|
await expect(skillsSwitch).toBeDisabled();
|
|
gate.releaseHeldClientRequest();
|
|
|
|
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
|
|
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
|
|
} finally {
|
|
await host.close();
|
|
}
|
|
});
|