Skip to content

Commit 5db1dc5

Browse files
authored
test(personality): add bounded browser smoke suite (#142)
* test(personality): add bounded browser smoke suite * test(personality): seed server-owned bully mode
1 parent 45ed06b commit 5db1dc5

9 files changed

Lines changed: 502 additions & 3 deletions

CONTRIBUTING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ For focused web tests, pass paths relative to `apps/web` through the dedicated r
7373
bun run test:web:focused src/path/to/example.test.ts
7474
```
7575

76+
Run the bounded Playwright-backed Forkara personality workflow suite with:
77+
78+
```bash
79+
bun run test:personality:smoke
80+
```
81+
82+
The suite uses disposable browser state and mocked Git/external boundaries. It must not publish,
83+
push, create pull requests, delete dependencies, or execute a hard reset.
84+
7685
## Be Realistic
7786

7887
Opening a PR does not create an obligation on our side.

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"test:browser": "vitest run --config vitest.browser.config.ts",
1414
"test:browser:stable": "vitest run --config vitest.browser.stable.config.ts",
1515
"test:browser:geometry": "vitest run --config vitest.browser.geometry.config.ts",
16+
"test:personality:smoke": "vitest run --config vitest.browser.personality.config.ts",
1617
"test:electron:e2e": "playwright test --config playwright.electron.config.ts",
1718
"test:browser:install": "playwright install --with-deps chromium",
1819
"measure:lcp": "node scripts/measure-lcp.mjs"

apps/web/src/components/KeybindingsToast.browser.tsx

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import "../index.css";
22

33
import {
4+
DEFAULT_SERVER_SETTINGS_VIEW,
45
DEVICE_WS_METHODS,
6+
EventId,
57
ORCHESTRATION_WS_METHODS,
68
type MessageId,
79
type OrchestrationReadModel,
810
type ProjectId,
911
type ServerConfig,
12+
type ServerSettingsView,
1013
type ThreadId,
14+
TurnId,
1115
type WsWelcomePayload,
1216
WS_METHODS,
1317
} from "@forkara/contracts";
1418
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
1519
import { ws, http, HttpResponse } from "msw";
1620
import { setupWorker } from "msw/browser";
1721
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22+
import { page } from "vitest/browser";
1823
import { render } from "vitest-browser-react";
1924

2025
import { useComposerDraftStore } from "../composerDraftStore";
@@ -30,6 +35,9 @@ import {
3035
} from "../test/effectRpcWebSocketMock";
3136
import { createBrowserTestServerConfig, createFullscreenTestHost } from "../test/browserHarness";
3237
import { resetWsNativeApiForTest } from "../wsNativeApi";
38+
import { readNativeApi } from "../nativeApi";
39+
import { getAchievementSnapshot, resetAchievementState } from "../achievements/engine";
40+
import { BULLY_MODE_CAPTURE_ACTIVITY_KIND } from "../achievements/bullyMode";
3341

3442
const THREAD_ID = "thread-kb-toast-test" as ThreadId;
3543
const PROJECT_ID = "project-1" as ProjectId;
@@ -38,6 +46,7 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
3846
interface TestFixture {
3947
snapshot: OrchestrationReadModel;
4048
serverConfig: ServerConfig;
49+
serverSettings: ServerSettingsView;
4150
welcome: WsWelcomePayload;
4251
}
4352

@@ -124,6 +133,7 @@ function buildFixture(): TestFixture {
124133
return {
125134
snapshot: createMinimalSnapshot(),
126135
serverConfig: createBaseServerConfig(),
136+
serverSettings: DEFAULT_SERVER_SETTINGS_VIEW,
127137
welcome: {
128138
cwd: "/repo/project",
129139
projectName: "Project",
@@ -153,6 +163,9 @@ function resolveWsRpc(tag: string): unknown {
153163
if (tag === WS_METHODS.serverGetConfig) {
154164
return fixture.serverConfig;
155165
}
166+
if (tag === WS_METHODS.serverGetSettings) {
167+
return fixture.serverSettings;
168+
}
156169
if (tag === WS_METHODS.projectsListDevServers) {
157170
return { servers: [] };
158171
}
@@ -303,7 +316,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
303316

304317
const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));
305318

306-
const screen = await render(<RouterProvider router={router} />, { container: host });
319+
const screen = await render(<RouterProvider router={router} />, {
320+
container: host,
321+
});
307322
try {
308323
await vi.waitFor(
309324
() => {
@@ -329,6 +344,65 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
329344
};
330345
}
331346

347+
function installPersonalitySendBoundary(input?: { rejectTurnStart?: boolean }): {
348+
readonly commands: unknown[];
349+
readonly restore: () => void;
350+
} {
351+
const previousNativeApi = window.nativeApi;
352+
const api = readNativeApi();
353+
if (!api) throw new Error("Expected the browser native API fixture.");
354+
const commands: unknown[] = [];
355+
Object.defineProperty(window, "nativeApi", {
356+
configurable: true,
357+
value: {
358+
...api,
359+
orchestration: {
360+
...api.orchestration,
361+
dispatchCommand: async (
362+
command: Parameters<typeof api.orchestration.dispatchCommand>[0],
363+
) => {
364+
commands.push(command);
365+
if (input?.rejectTurnStart && command.type === "thread.turn.start") {
366+
throw new Error("Recoverable provider fixture failure.");
367+
}
368+
return { sequence: fixture.snapshot.snapshotSequence + 1 };
369+
},
370+
},
371+
},
372+
});
373+
return {
374+
commands,
375+
restore: () => {
376+
if (previousNativeApi) {
377+
Object.defineProperty(window, "nativeApi", {
378+
configurable: true,
379+
value: previousNativeApi,
380+
});
381+
} else {
382+
Reflect.deleteProperty(window, "nativeApi");
383+
}
384+
},
385+
};
386+
}
387+
388+
async function waitForPersonalityComposer(): Promise<{
389+
readonly editor: HTMLElement;
390+
readonly sendButton: HTMLButtonElement;
391+
}> {
392+
let editor: HTMLElement | null = null;
393+
let sendButton: HTMLButtonElement | null = null;
394+
await vi.waitFor(
395+
() => {
396+
editor = document.querySelector<HTMLElement>('[contenteditable="true"]');
397+
sendButton = document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]');
398+
expect(editor).not.toBeNull();
399+
expect(sendButton).not.toBeNull();
400+
},
401+
{ timeout: 20_000, interval: 16 },
402+
);
403+
return { editor: editor!, sendButton: sendButton! };
404+
}
405+
332406
describe("Keybindings update toast", () => {
333407
beforeAll(async () => {
334408
fixture = buildFixture();
@@ -378,6 +452,106 @@ describe("Keybindings update toast", () => {
378452
document.body.innerHTML = "";
379453
});
380454

455+
it("[personality-smoke] sends Bully Mode and per-turn precision through the full app", async () => {
456+
fixture = buildFixture();
457+
resetAchievementState();
458+
fixture.serverSettings = {
459+
...fixture.serverSettings,
460+
bullyModeEnabled: true,
461+
};
462+
const completedTurnId = TurnId.makeUnsafe("turn-personality-bully-completed");
463+
fixture.snapshot = {
464+
...fixture.snapshot,
465+
threads: fixture.snapshot.threads.map((thread) =>
466+
Object.assign({}, thread, {
467+
activities: [
468+
{
469+
id: EventId.makeUnsafe("activity-personality-bully-capture"),
470+
createdAt: NOW_ISO,
471+
kind: BULLY_MODE_CAPTURE_ACTIVITY_KIND,
472+
summary: "Bully Mode captured",
473+
tone: "info" as const,
474+
turnId: completedTurnId,
475+
payload: { bullyModeEnabled: true },
476+
},
477+
{
478+
id: EventId.makeUnsafe("activity-personality-bully-completed"),
479+
createdAt: NOW_ISO,
480+
kind: "turn.completed",
481+
summary: "Turn completed",
482+
tone: "info" as const,
483+
turnId: completedTurnId,
484+
payload: { state: "completed" },
485+
},
486+
],
487+
}),
488+
),
489+
};
490+
const boundary = installPersonalitySendBoundary();
491+
const app = await mountApp();
492+
493+
try {
494+
await expect
495+
.element(page.getByRole("button", { name: "Disable Bully Mode" }))
496+
.toBeInTheDocument();
497+
await page.getByRole("button", { name: /Make No Mistake is off/u }).click();
498+
await page.getByRole("button", { name: /level 1 of 3/u }).click();
499+
await page.getByRole("button", { name: /level 2 of 3/u }).click();
500+
const { sendButton } = await waitForPersonalityComposer();
501+
useComposerDraftStore.getState().setPrompt(THREAD_ID, "Exercise personality composition");
502+
await vi.waitFor(() => expect(sendButton.disabled).toBe(false));
503+
sendButton.click();
504+
505+
await vi.waitFor(() => {
506+
const turnStart = boundary.commands.find(
507+
(command) =>
508+
command !== null &&
509+
typeof command === "object" &&
510+
"type" in command &&
511+
command.type === "thread.turn.start",
512+
) as { responseModifiers?: unknown } | undefined;
513+
expect(turnStart?.responseModifiers).toEqual({ makeNoMistakeLevel: 3 });
514+
});
515+
await expect
516+
.element(page.getByRole("button", { name: /Make No Mistake is off/u }))
517+
.toBeInTheDocument();
518+
expect(getAchievementSnapshot().some((unlock) => unlock.id === "dirt_in_your_eye")).toBe(
519+
true,
520+
);
521+
} finally {
522+
boundary.restore();
523+
resetAchievementState();
524+
await app.cleanup();
525+
}
526+
});
527+
528+
it("[personality-smoke] keeps a recoverable No Forks Given failure inline", async () => {
529+
fixture = buildFixture();
530+
localStorage.setItem(
531+
"synara:app-settings:v1",
532+
JSON.stringify({ noForksGivenModeEnabled: true }),
533+
);
534+
const boundary = installPersonalitySendBoundary({ rejectTurnStart: true });
535+
const app = await mountApp();
536+
537+
try {
538+
await page.getByRole("button", { name: /Make No Mistake is off/u }).click();
539+
const { sendButton } = await waitForPersonalityComposer();
540+
useComposerDraftStore.getState().setPrompt(THREAD_ID, "Trigger recoverable fixture failure");
541+
await vi.waitFor(() => expect(sendButton.disabled).toBe(false));
542+
sendButton.click();
543+
544+
await vi.waitFor(() => {
545+
expect(document.body.textContent).toContain("Recoverable provider fixture failure.");
546+
});
547+
await expect.element(page.getByText("Make No Mistake · 1")).toBeVisible();
548+
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
549+
} finally {
550+
boundary.restore();
551+
await app.cleanup();
552+
}
553+
});
554+
381555
it("does not show success toasts for passive keybinding reloads", async () => {
382556
const mounted = await mountApp();
383557

@@ -397,7 +571,10 @@ describe("Keybindings update toast", () => {
397571

398572
try {
399573
await sendServerConfigUpdatedPush([
400-
{ kind: "keybindings.malformed-config", message: "Expected JSON array" },
574+
{
575+
kind: "keybindings.malformed-config",
576+
message: "Expected JSON array",
577+
},
401578
]);
402579
await waitForToast("Invalid keybindings configuration");
403580
} finally {

0 commit comments

Comments
 (0)