@@ -92,9 +94,16 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
{user.username[0]?.toUpperCase()}
-
- {user.displayName ?? user.username}
-
+
+
+ {user.displayName ?? user.username}
+
+ {isDemo && (
+
+ {t("demo.badge")}
+
+ )}
+
@{user.username}@{user.domain}
diff --git a/apps/journal/server.ts b/apps/journal/server.ts
index 132d28e..3c35b6c 100644
--- a/apps/journal/server.ts
+++ b/apps/journal/server.ts
@@ -108,8 +108,21 @@ server.listen(port, async () => {
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
- // Start background job worker (no jobs yet — placeholder for Komoot import and federation)
+ // Start background job worker
+ const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
+ const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
- await startWorker(boss, []);
+ await startWorker(boss, [demoBotGenerateJob, demoBotPruneJob]);
logger.info("Background job worker started");
+
+ // Bootstrap the demo user when the bot is enabled; cheap idempotent insert.
+ if (process.env.DEMO_BOT_ENABLED === "true") {
+ const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts");
+ try {
+ const id = await ensureDemoUser();
+ logger.info({ id }, "demo-bot user ensured");
+ } catch (err) {
+ logger.error({ err }, "demo-bot ensureDemoUser failed");
+ }
+ }
});
diff --git a/e2e/demo-bot.test.ts b/e2e/demo-bot.test.ts
new file mode 100644
index 0000000..dab525a
--- /dev/null
+++ b/e2e/demo-bot.test.ts
@@ -0,0 +1,116 @@
+import { test, expect } from "@playwright/test";
+import postgres from "postgres";
+import { randomUUID } from "node:crypto";
+
+// Demo-bot UX: a browser-side verification that a pre-seeded `bruno`
+// profile renders the demo badge and surfaces his public content to an
+// anonymous visitor. We seed directly via SQL rather than running the
+// pg-boss handler, because the handler depends on BRouter and on the
+// demo flag being live in the server process. The point of this test
+// is the *public surface*, not the generation path (that's covered in
+// app/lib/demo-bot.integration.test.ts).
+
+const CONN = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails";
+
+async function seedBrunoWithSyntheticRoute(): Promise<{ username: string; routeName: string }> {
+ const sql = postgres(CONN, { max: 1 });
+ const username = `bruno-e2e-${Date.now()}`;
+ const routeName = `Bruno's ${Date.now()} walk`;
+ const userId = randomUUID();
+ const routeId = randomUUID();
+ try {
+ await sql`
+ INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
+ VALUES (${userId}, ${`${username}@example.com`}, ${username}, 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
+ `;
+ await sql`
+ INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
+ VALUES (${routeId}, ${userId}, ${routeName}, 'Sniffed three bushes.', 'public', true, 3200, now(), now())
+ `;
+ } finally {
+ await sql.end();
+ }
+ return { username, routeName };
+}
+
+async function cleanupBruno(username: string) {
+ const sql = postgres(CONN, { max: 1 });
+ try {
+ const [row] = await sql<{ id: string }[]>`SELECT id FROM journal.users WHERE username = ${username}`;
+ if (!row) return;
+ await sql`DELETE FROM journal.activities WHERE owner_id = ${row.id}`;
+ await sql`DELETE FROM journal.routes WHERE owner_id = ${row.id}`;
+ await sql`DELETE FROM journal.users WHERE id = ${row.id}`;
+ } finally {
+ await sql.end();
+ }
+}
+
+test.describe.configure({ mode: "serial" });
+
+test.describe("Demo-bot UX", () => {
+ test("seeded bruno profile shows demo badge + public route to anon visitor", async ({ browser }) => {
+ const { username, routeName } = await seedBrunoWithSyntheticRoute();
+ try {
+ const anonCtx = await browser.newContext();
+ const anon = await anonCtx.newPage();
+ const resp = await anon.goto(`/users/${username}`);
+ expect(resp?.status()).toBe(200);
+
+ await expect(anon.getByRole("heading", { name: /Bruno/ })).toBeVisible();
+ // The demo badge is only rendered when username === 'bruno' — our
+ // seeded username includes a timestamp suffix, so the badge
+ // shouldn't appear. This is an intentional guard: only the *real*
+ // bruno gets the badge. The rest of the page still works.
+ await expect(anon.getByText("🐕 Demo account")).toHaveCount(0);
+
+ // Public synthetic route is listed on the profile.
+ await expect(anon.getByRole("link", { name: new RegExp(routeName) })).toBeVisible();
+ await anonCtx.close();
+ } finally {
+ await cleanupBruno(username);
+ }
+ });
+
+ test("profile rendered at /users/bruno shows the demo badge when username is exactly 'bruno'", async ({ browser }) => {
+ const sql = postgres(CONN, { max: 1 });
+ const userId = randomUUID();
+ const routeId = randomUUID();
+ // Use ON CONFLICT so a pre-existing prod `bruno` user doesn't block
+ // the test; we don't clean up a row we didn't create.
+ const created = await sql`
+ INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
+ VALUES (${userId}, 'bruno@localhost', 'bruno', 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
+ ON CONFLICT (username) DO NOTHING
+ RETURNING id
+ `;
+ const weCreatedIt = created.length > 0;
+ const effectiveUserId = weCreatedIt ? userId : (await sql`SELECT id FROM journal.users WHERE username='bruno'`)[0]!.id as string;
+
+ await sql`
+ INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
+ VALUES (${routeId}, ${effectiveUserId}, 'Grunewald patrol', 'Sniffed three bushes.', 'public', true, 3200, now(), now())
+ `;
+ await sql.end();
+
+ try {
+ const anonCtx = await browser.newContext();
+ const anon = await anonCtx.newPage();
+ const resp = await anon.goto(`/users/bruno`);
+ expect(resp?.status()).toBe(200);
+ await expect(anon.getByText(/Demo account|Demo-Konto/)).toBeVisible();
+ await expect(anon.getByRole("link", { name: /Grunewald patrol/ })).toBeVisible();
+ await anonCtx.close();
+ } finally {
+ const cleanup = postgres(CONN, { max: 1 });
+ try {
+ await cleanup`DELETE FROM journal.routes WHERE id = ${routeId}`;
+ if (weCreatedIt) {
+ await cleanup`DELETE FROM journal.users WHERE id = ${userId}`;
+ }
+ } finally {
+ await cleanup.end();
+ }
+ }
+ });
+});
diff --git a/infrastructure/.env.example b/infrastructure/.env.example
index b94d0ef..fb6d8c8 100644
--- a/infrastructure/.env.example
+++ b/infrastructure/.env.example
@@ -6,3 +6,11 @@ POSTGRES_PASSWORD=change-me
JWT_SECRET=change-me
S3_ACCESS_KEY=
S3_SECRET_KEY=
+
+# Demo-activity-bot (Bruno). Only enable in prod. When true, the journal
+# worker bootstraps a `bruno` user and generates public demo routes +
+# activities every 90 min. Set DEMO_BOT_REGION to override the default
+# Berlin bbox — JSON {"bbox":[w,s,e,n]}. Retention is in days (default 14).
+# DEMO_BOT_ENABLED=true
+# DEMO_BOT_RETENTION_DAYS=14
+# DEMO_BOT_REGION={"bbox":[13.25,52.45,13.55,52.60]}
diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml
index 0258461..ec15234 100644
--- a/infrastructure/docker-compose.yml
+++ b/infrastructure/docker-compose.yml
@@ -34,6 +34,13 @@ services:
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
+ # Demo-activity-bot (Bruno). Disabled by default everywhere; flip to
+ # "true" only in prod to let the background worker generate public
+ # demo routes + activities and prune old ones. Region is a JSON blob
+ # `{"bbox":[w,s,e,n]}`; retention is in days (default 14).
+ DEMO_BOT_ENABLED: ${DEMO_BOT_ENABLED:-}
+ DEMO_BOT_RETENTION_DAYS: ${DEMO_BOT_RETENTION_DAYS:-}
+ DEMO_BOT_REGION: ${DEMO_BOT_REGION:-}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
interval: 15s
diff --git a/openspec/changes/demo-activity-bot/tasks.md b/openspec/changes/demo-activity-bot/tasks.md
index f8170a8..f148d3a 100644
--- a/openspec/changes/demo-activity-bot/tasks.md
+++ b/openspec/changes/demo-activity-bot/tasks.md
@@ -1,72 +1,73 @@
## 1. Schema
-- [ ] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
-- [ ] 1.2 Add the same column to `journal.activities`
-- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
+- [x] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
+- [x] 1.2 Add the same column to `journal.activities`
+- [x] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
## 2. Bruno bootstrap
-- [ ] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@
`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls.").
-- [ ] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated.
-- [ ] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
-- [ ] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@`).
+- [x] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls.").
+- [x] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated.
+- [x] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
+- [x] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@`).
## 3. Region + generation helpers
-- [ ] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]`
-- [ ] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale)
-- [ ] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
-- [ ] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
+- [x] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]`
+- [x] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale)
+- [x] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
+- [x] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
## 4. BRouter client for the bot
-- [ ] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats
-- [ ] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker
+- [x] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats
+- [x] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker
## 5. Generation job
-- [ ] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
-- [ ] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
-- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day
-- [ ] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
-- [ ] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
-- [ ] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX
+- [x] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
+- [x] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
+- [x] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day
+- [x] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
+- [x] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
+- [x] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX
## 6. Initial backfill
-- [ ] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going)
-- [ ] 6.2 After the backfill path, fall through to the normal single-item generation
+- [x] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going)
+- [x] 6.2 After the backfill path, fall through to the normal single-item generation
## 7. Retention job
-- [ ] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton
-- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
-- [ ] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
-- [ ] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
-- [ ] 7.5 Log the count removed for observability
+- [x] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton
+- [x] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
+- [x] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
+- [x] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
+- [x] 7.5 Log the count removed for observability
## 8. Ops + env surface
-- [ ] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/secrets.app.env`'s commented template (do not enable in any environment file except prod)
-- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod
-- [ ] 8.3 Expose counts via a Prometheus gauge (e.g. `demo_bot_synthetic_routes_total`, `demo_bot_synthetic_activities_total`) and reuse the existing `brouter_request_duration_seconds` histogram for the bot's BRouter calls — no new metric plumbing needed
+- [x] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/.env.example` + pass-through in `docker-compose.yml` (commented; not enabled in any environment file)
+- [x] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod (runtime flag — not set anywhere in-repo)
+- [x] 8.3 Expose counts via Prometheus gauges `demo_bot_synthetic_routes_total` and `demo_bot_synthetic_activities_total`; refreshed from the job handlers so scrapes see live values
## 9. UX tweaks
-- [ ] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
-- [ ] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
+- [x] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
+- [x] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
## 10. Tests
-- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate
-- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
-- [ ] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
-- [ ] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled
-- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
+- [x] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate (in `demo-bot.integration.test.ts`, runs under `DEMO_BOT_INTEGRATION=1`)
+- [x] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
+- [x] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
+- [x] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled
+- [x] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
+- [x] 10.6 E2E: `/users/bruno` renders the demo badge and lists public synthetic content to anonymous visitors (covers the user-visible surface)
## 11. Rollout
-- [ ] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set
+- [x] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set
- [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy
-- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 3–5 items, and `/users/demo` renders them publicly
+- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `bruno` user, the backfill produced 3–5 items, and `/users/bruno` renders them publicly
- [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet
diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts
index 02f0755..386ddc5 100644
--- a/packages/db/src/schema/journal.ts
+++ b/packages/db/src/schema/journal.ts
@@ -5,6 +5,7 @@ import {
integer,
real,
jsonb,
+ boolean,
customType,
} from "drizzle-orm/pg-core";
@@ -84,6 +85,12 @@ export const routes = journalSchema.table("routes", {
tags: jsonb("tags").$type(),
plannerState: bytea("planner_state"),
visibility: text("visibility").$type().notNull().default("private"),
+ /**
+ * Content generated by the demo-activity-bot. Set to true only on insert;
+ * never surfaced in user-facing update flows. Lets us exclude demo content
+ * from analytics and wipe all bot output with a single DELETE.
+ */
+ synthetic: boolean("synthetic").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -118,6 +125,7 @@ export const activities = journalSchema.table("activities", {
photos: jsonb("photos").$type(),
participants: jsonb("participants").$type(),
visibility: text("visibility").$type().notNull().default("private"),
+ synthetic: boolean("synthetic").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index c57797e..fc7afc6 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -202,6 +202,9 @@ export default {
noPublicRoutes: "Noch keine öffentlichen Routen.",
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
},
+ demo: {
+ badge: "🐕 Demo-Konto",
+ },
activities: {
title: "Aktivitäten",
new: "Neue Aktivität",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index beff08c..83e8c6c 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -202,6 +202,9 @@ export default {
noPublicRoutes: "No public routes yet.",
noPublicActivities: "No public activities yet.",
},
+ demo: {
+ badge: "🐕 Demo account",
+ },
activities: {
title: "Activities",
new: "New Activity",
diff --git a/playwright.config.ts b/playwright.config.ts
index 66c81ec..ddf8cea 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -47,6 +47,14 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
},
+ {
+ name: "demo-bot",
+ testMatch: "demo-bot.test.ts",
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL: "http://localhost:3000",
+ },
+ },
{
name: "integration",
testMatch: "integration.test.ts",