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
14 changes: 9 additions & 5 deletions apps/web/app/studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,14 +428,18 @@ export function Studio() {
// Guided tour: drives the SAME deep-link mechanism as permalinks, so
// every step is a real, reachable UI state. Non-blocking, restartable.
const [tourStep, setTourStep] = useState<number | null>(null);
const [tourOffered, setTourOffered] = useState(false);
useEffect(() => {
try {
if (!localStorage.getItem("dspack-studio-tour-done") && !location.hash) setTourOffered(true);
} catch { /* storage unavailable: no offer, tour stays reachable via the header button */ }
if (localStorage.getItem("dspack-studio-tour-done") || location.hash) return;
// Auto-start only when the dismissal can be REMEMBERED: a storage that
// reads fine but throws on write would re-launch the tour every visit.
localStorage.setItem("dspack-studio-tour-probe", "1");
localStorage.removeItem("dspack-studio-tour-probe");
startTour(0);
} catch { /* storage unavailable: no auto-start, tour stays reachable via the header button */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startTour = (n = 0) => {
setTourOffered(false);
setLinkError(null);
setView("replay");
setScenarioId(TOUR_STEPS[n].state.scenario!);
Expand Down Expand Up @@ -535,7 +539,7 @@ export function Studio() {
</p>
<p style={{ fontSize: 13, margin: "10px 0 0" }}>
<button data-testid="tour-start" onClick={() => startTour(0)} className={linkClass}>
{tourOffered ? "first time here? take the one-minute tour" : "guided tour"}
guided tour
</button>
</p>
</header>
Expand Down
41 changes: 41 additions & 0 deletions e2e/tour-first-run.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* First-visit tour auto-start, against the static artifact.
*
* Runs ONLY in the "first-run" Playwright project, which (unlike "studio")
* seeds no storageState: every test here starts as a genuine first-time
* visitor. The rest of the suite runs as a returning visitor by construction.
*/
import { expect, test } from "@playwright/test";

test("a first-time visitor lands in the tour, on a real recorded state", async ({ page }) => {
await page.goto("/");

// Step 1 of the tour: the failed gate moment of the real repair run —
// the same state tour-xray-wire.spec.ts asserts after a manual start.
await expect(page.getByTestId("tour-bar")).toBeVisible();
await expect(page.getByTestId("tour-title")).toContainText("argues back");
await expect(page.getByTestId("gate-ticker")).toContainText("S3✗");
});

test("the tour never recurs after dismissal, and stays restartable", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("tour-bar")).toBeVisible();
await page.getByTestId("tour-skip").click();
await expect(page.getByTestId("tour-bar")).toHaveCount(0);

await page.reload();
// A dismissed tour is remembered: the studio loads directly, with the
// manual entry point still present.
await expect(page.getByTestId("scenario-project-deletion")).toBeVisible();
await expect(page.getByTestId("tour-bar")).toHaveCount(0);
await expect(page.getByTestId("tour-start")).toBeVisible();
});

test("deep-linked first-time visitors are never hijacked by the tour", async ({ page }) => {
await page.goto("/#s=project-deletion&f=clean&e=11");
// The permalink resolves to its own state...
await expect(page.getByTestId("gate-ticker")).toBeVisible();
await expect(page.getByTestId("scenario-project-deletion")).toBeVisible();
// ...and the tour stays out of the way.
await expect(page.getByTestId("tour-bar")).toHaveCount(0);
});
34 changes: 30 additions & 4 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { defineConfig } from "@playwright/test";
* Run `pnpm --filter web build` first (CI does; the webServer commands below
* do not rebuild to keep local iteration fast).
*/
const BASE_URL = "http://localhost:3311";

export default defineConfig({
testDir: "e2e",
timeout: 30_000,
Expand All @@ -18,16 +20,40 @@ export default defineConfig({
// so parallel workers can race each other through it. One worker keeps
// every run deterministic; the static production suite stays parallel.
workers: 1,
// prod-smoke asserts the DEPLOYED site's properties (no local agent,
// injected analytics); it runs via playwright.production.config.ts only.
testIgnore: ["prod-smoke.spec.ts"],
use: {
baseURL: "http://localhost:3311",
baseURL: BASE_URL,
// Deterministic CI: the homepage is alive by default (auto-plays the
// first recording); under reduced motion it jumps to the finished
// surface instead, which is also the stable state tests start from.
contextOptions: { reducedMotion: "reduce" },
},
// The tour auto-starts for first-time visitors, so visitor history is part
// of every test's premise. Two projects make it explicit: "first-run"
// starts with clean storage (the auto-start spec), "studio" is seeded as a
// returning visitor so every other spec keeps asserting the plain studio.
projects: [
{
name: "first-run",
testMatch: "tour-first-run.spec.ts",
},
{
name: "studio",
// prod-smoke asserts the DEPLOYED site's properties (no local agent,
// injected analytics); it runs via playwright.production.config.ts only.
testIgnore: ["prod-smoke.spec.ts", "tour-first-run.spec.ts"],
use: {
storageState: {
cookies: [],
origins: [
{
origin: BASE_URL,
localStorage: [{ name: "dspack-studio-tour-done", value: "1" }],
},
],
},
},
},
],
webServer: [
{
command: "node e2e/serve-static.mjs",
Expand Down
71 changes: 48 additions & 23 deletions playwright.production.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,59 @@ import { defineConfig } from "@playwright/test";
*
* npx playwright test --config playwright.production.config.ts
*/
const PROD = process.env.PROD_URL ?? "https://studio.aesthetic-function.com";

export default defineConfig({
testDir: "e2e",
testMatch: [
"replay.spec.ts",
"alive.spec.ts",
"permalinks.spec.ts",
"tour-xray-wire.spec.ts",
"receipts.spec.ts",
"fork.spec.ts",
"fixture-006.spec.ts",
"inspector.spec.ts",
"wire.spec.ts",
"a11y.spec.ts",
"studio-shell.spec.ts",
// Replay + restyle only; agent-free by construction.
"design-swap.spec.ts",
// The deployed site has no agent, which is exactly the state this suite
// reproduces locally by blocking the port — it runs as-is in production.
"break-offline.spec.ts",
// Agent-free by construction (blocks the agent port itself); the
// validator, config, and downloads are static-site capabilities.
"take-home.spec.ts",
"prod-smoke.spec.ts",
],
timeout: 30_000,
retries: 1,
use: {
baseURL: process.env.PROD_URL ?? "https://studio.aesthetic-function.com",
baseURL: PROD,
contextOptions: { reducedMotion: "reduce" },
},
// Mirrors playwright.config.ts: "first-run" asserts the deployed
// first-visit tour auto-start with clean storage; "studio" runs everything
// else as a returning visitor (seeded tour-done) so no spec meets the tour.
projects: [
{
name: "first-run",
testMatch: "tour-first-run.spec.ts",
},
{
name: "studio",
testMatch: [
"replay.spec.ts",
"alive.spec.ts",
"permalinks.spec.ts",
"tour-xray-wire.spec.ts",
"receipts.spec.ts",
"fork.spec.ts",
"fixture-006.spec.ts",
"inspector.spec.ts",
"wire.spec.ts",
"a11y.spec.ts",
"studio-shell.spec.ts",
// Replay + restyle only; agent-free by construction.
"design-swap.spec.ts",
// The deployed site has no agent, which is exactly the state this suite
// reproduces locally by blocking the port — it runs as-is in production.
"break-offline.spec.ts",
// Agent-free by construction (blocks the agent port itself); the
// validator, config, and downloads are static-site capabilities.
"take-home.spec.ts",
"prod-smoke.spec.ts",
],
use: {
storageState: {
cookies: [],
origins: [
{
origin: new URL(PROD).origin,
localStorage: [{ name: "dspack-studio-tour-done", value: "1" }],
},
],
},
},
},
],
});
Loading