Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/test",
],
docs: { autodocs: "tag" },
};
export default config;

View File

@@ -0,0 +1,32 @@
import type { Decorator, Preview } from "@storybook/react";
const ThemeDecorator: Decorator = (Story) => (
<div style={{ display: "flex", gap: "16px" }}>
<div data-theme="light" style={{ flex: 1 }}>
<Story />
</div>
<div data-theme="dark" style={{ flex: 1, backgroundColor: "#09090b", padding: "8px" }}>
<Story />
</div>
</div>
);
const LocaleDecorator: Decorator = (Story) => (
<div lang="en">
<Story />
</div>
);
const preview: Preview = {
decorators: [ThemeDecorator, LocaleDecorator],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

8
apps/testbed/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
EXPOSE 3000
CMD ["bun", "run", "start"]

174
apps/testbed/README.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
test.describe("Autofill Detection", () => {
test("extension-test page renders without extension", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Extension Test",
);
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("output shows 'not detected' without extension injection", async ({
page,
}) => {
await page.goto("/extension-test");
const output = page.locator('[data-testid="output"]');
await expect(output).toBeVisible();
// Without extension, should not show detected
const text = await output.textContent();
expect(text).not.toContain("✓ Extension injected");
});
test("MutationObserver detects programmatic injection", async ({ page }) => {
await page.goto("/extension-test");
// Simulate extension injection via page.evaluate
await page.evaluate(() => {
const div = document.createElement("div");
div.id = "extension-injected";
div.textContent = "Injected by test";
document.body.appendChild(div);
});
// Output should update to show detected
await expect(page.locator('[data-testid="output"]')).toContainText(
"Extension injected",
{ timeout: 3000 },
);
});
test("extension-mount div is present as injection target", async ({
page,
}) => {
await page.goto("/extension-test");
const mount = page.locator('[data-testid="extension-mount"]');
await expect(mount).toBeVisible();
// The injection target div has both testid and id attributes
await expect(page.locator("#extension-mount")).toBeVisible();
});
test("console-panel is visible on extension page", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
// Path to the test extension fixture
const EXTENSION_PATH = path.resolve(
new URL(import.meta.url).pathname,
"../../fixtures/test-extension",
);
test.describe("Content Script Extension", () => {
test.skip(
!fs.existsSync(EXTENSION_PATH),
"Test extension fixture not found — skipping extension tests",
);
test("extension page title is visible without extension", async ({
page,
}) => {
// Basic smoke test — no extension needed
await page.goto("/extension-test");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Extension Test",
);
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("extension-mount target is present on page", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
// The id is also extension-mount for CSS targeting by extensions
await expect(page.locator("#extension-mount")).toBeVisible();
});
test.skip("extension injects content into page", async () => {
// Full extension test — requires the extension fixture to be built
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-"));
const { context, extensionId } = await launchWithExtension({
extensionPath: EXTENSION_PATH,
userDataDir,
});
try {
const page = await context.newPage();
await page.goto("http://localhost:3200/extension-test");
expect(extensionId).toBeTruthy();
await expect(page.locator('[data-testid="output"]')).toContainText(
"Extension injected",
{ timeout: 5000 },
);
} finally {
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("Console Baseline", () => {
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of routes) {
test(`${route} has no console errors on load`, async ({ page }) => {
const monitor = attachConsoleMonitor(page, {
failOn: ["error"],
ignore: [
// Ignore known non-critical messages
/ResizeObserver loop/,
/WebSocket connection/,
/Failed to load resource.*favicon/,
],
});
await page.goto(route);
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
monitor.assertClean();
});
}
test("console-panel is present on every library page", async ({ page }) => {
const libraryRoutes = ["/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of libraryRoutes) {
await page.goto(route);
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
}
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
test.describe("Locale Switcher", () => {
test("locale switcher is visible in header", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="header"]')).toBeVisible();
// Locale switcher should be present
const localeSwitcher = page.locator('[data-testid="locale-switcher"]');
// If not using that testid, check for a select or button in the header
const headerInteractives = page.locator('[data-testid="header"] select, [data-testid="header"] button');
const count = await headerInteractives.count();
expect(count).toBeGreaterThan(0);
});
test("i18n page locale-select changes output text", async ({ page }) => {
await page.goto("/i18n");
await expect(page.locator('[data-testid="output"]')).toBeVisible();
// Get initial text in English
const initialText = await page.locator('[data-testid="output"]').textContent();
expect(initialText).toBeTruthy();
// Switch to French
await page.locator('[data-testid="locale-select"]').selectOption("fr");
// Output should now contain French text
const frenchText = await page.locator('[data-testid="output"]').textContent();
expect(frenchText).toContain("article");
});
test("i18n page locale-select changes to Japanese", async ({ page }) => {
await page.goto("/i18n");
await page.locator('[data-testid="locale-select"]').selectOption("ja");
const text = await page.locator('[data-testid="output"]').textContent();
// Japanese output contains Japanese characters or specific words
expect(text).toBeTruthy();
expect(text).toContain("アイテム");
});
});

View File

@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test";
test.describe("Navigation", () => {
test("home page renders page-title", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
await expect(page.locator('[data-testid="header"]')).toBeVisible();
});
test("sidebar nav links exist for all routes", async ({ page }) => {
await page.goto("/");
// Check a few known nav links
await expect(page.locator('[data-testid="nav-crypto"]')).toBeVisible();
await expect(page.locator('[data-testid="nav-logger"]')).toBeVisible();
await expect(page.locator('[data-testid="nav-datetime"]')).toBeVisible();
});
const libraryRoutes = [
{ path: "/crypto", title: "@suaveplan/crypto" },
{ path: "/logger", title: "@suaveplan/logger" },
{ path: "/datetime", title: "@suaveplan/datetime" },
{ path: "/i18n", title: "@suaveplan/i18n" },
{ path: "/feature-flags", title: "@suaveplan/feature-flags" },
];
for (const route of libraryRoutes) {
test(`navigating to ${route.path} shows page-title`, async ({ page }) => {
await page.goto(route.path);
await expect(page.locator('[data-testid="page-title"]')).toContainText(
route.title,
);
});
}
test("all pages have demo-card and console-panel", async ({ page }) => {
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of routes) {
await page.goto(route);
await expect(page.locator('[data-testid="demo-card"]')).toBeAttached();
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
}
});
test("home page shows library cards", async ({ page }) => {
await page.goto("/");
const cards = page.locator('[data-testid="library-card"]');
await expect(cards.first()).toBeVisible();
const count = await cards.count();
expect(count).toBeGreaterThan(5);
});
test("clicking a library card navigates to the page", async ({ page }) => {
await page.goto("/");
await page.locator('[data-testid="library-card"]').first().click();
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
});
test("404 page renders page-title", async ({ page }) => {
await page.goto("/does-not-exist");
await expect(page.locator('[data-testid="page-title"]')).toContainText("404");
});
});

View File

@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("Network Baseline", () => {
test("home page loads without network failures", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("/");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
// Filter out WebSocket attempts (expected to fail in test env)
const failures = monitor.failures.filter(
(f) => !f.url.includes("/api/ws") && !f.url.includes("stun:"),
);
expect(failures).toHaveLength(0);
});
test("crypto page loads without network failures", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("/crypto");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
const failures = monitor.failures.filter(
(f) => !f.url.includes("/api/ws"),
);
expect(failures).toHaveLength(0);
});
test("storage-s3 status check is made on page load", async ({ page }) => {
const requests: string[] = [];
page.on("request", (req) => requests.push(req.url()));
await page.goto("/storage-s3");
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
expect(requests.some((r) => r.includes("/api/storage/status"))).toBe(true);
});
test("surrealdb status check is made on page load", async ({ page }) => {
const requests: string[] = [];
page.on("request", (req) => requests.push(req.url()));
await page.goto("/surrealdb");
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
expect(requests.some((r) => r.includes("/api/database/status"))).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
test.describe("Theme Toggle", () => {
test("theme toggle button is visible in header", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="header"]')).toBeVisible();
// The theme toggle button should be in the header
const themeToggle = page.locator('[data-testid="theme-toggle"]');
// If the component uses a different testid, try the button within header
const headerBtns = page.locator('[data-testid="header"] button');
const btnCount = await headerBtns.count();
expect(btnCount).toBeGreaterThan(0);
});
test("page loads without dark class by default", async ({ page }) => {
await page.goto("/");
// Default should not have dark mode active unless system preference is dark
const html = page.locator("html");
await expect(html).toBeVisible();
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
test.describe("Crypto Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/crypto");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/crypto",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("encrypt button produces ciphertext in output", async ({ page }) => {
await page.locator('[aria-label="Plaintext input"]').fill("Hello, Playwright!");
await page.locator('button:has-text("Encrypt")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("ciphertext:");
});
test("encrypt then decrypt round-trip", async ({ page }) => {
const plaintext = "Round-trip test message";
await page.locator('[aria-label="Plaintext input"]').fill(plaintext);
await page.locator('button:has-text("Encrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("ciphertext:");
await page.locator('button:has-text("Decrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("decrypted:");
await expect(page.locator('[data-testid="output"]')).toContainText(plaintext);
});
test("decrypt without encrypting first shows error", async ({ page }) => {
// Click decrypt before encrypting
await page.locator('button:has-text("Decrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("Encrypt first");
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
test.describe("Datetime Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/datetime");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/datetime",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("calculate adds 5 business days from a Monday", async ({ page }) => {
// Monday 2026-04-27 + 5 business days = Monday 2026-05-04
await page.locator('input[type="date"]').fill("2026-04-27");
await page.locator('input[type="number"]').fill("5");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Result:");
// Should contain a day name
const text = await output.textContent();
expect(text).toMatch(/Monday|Tuesday|Wednesday|Thursday|Friday/);
});
test("calculate skips weekends", async ({ page }) => {
// Friday 2026-04-24 + 1 business day = Monday 2026-04-27
await page.locator('input[type="date"]').fill("2026-04-24");
await page.locator('input[type="number"]').fill("1");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Monday");
});
test("calculate with negative days goes backwards", async ({ page }) => {
// Monday 2026-04-27 - 1 business day = Friday 2026-04-24
await page.locator('input[type="date"]').fill("2026-04-27");
await page.locator('input[type="number"]').fill("-1");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Friday");
});
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
test.describe("Feature Flags Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/feature-flags");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/feature-flags",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("initial output shows placeholder text", async ({ page }) => {
await expect(page.locator('[data-testid="output"]')).toContainText(
"Enter a user ID and click Evaluate",
);
});
test("evaluating with 'alice' shows new-checkout enabled", async ({ page }) => {
await page.locator('input[type="text"]').fill("alice");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("new-checkout");
// alice is in the user-target list, so new-checkout should be enabled
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
expect(checkoutLine).toContain("✓");
});
test("evaluating with 'carol' shows new-checkout disabled", async ({ page }) => {
await page.locator('input[type="text"]').fill("carol");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("new-checkout");
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
expect(checkoutLine).toContain("✗");
});
test("dark-mode flag is always enabled (100% rollout)", async ({ page }) => {
await page.locator('input[type="text"]').fill("any-user-id");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("dark-mode");
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const darkModeLine = lines.find((l) => l.includes("dark-mode"));
expect(darkModeLine).toContain("✓");
});
test("three flags appear in results", async ({ page }) => {
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("beta-ui");
await expect(output).toContainText("new-checkout");
await expect(output).toContainText("dark-mode");
});
});

View File

@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
test.describe("i18n Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/i18n");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/i18n",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("English locale shows 'item' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("en");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("item");
});
test("French locale shows 'article' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("fr");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("article");
});
test("German locale shows 'Artikel' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("de");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("Artikel");
});
test("Japanese locale shows Japanese characters in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("ja");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("アイテム");
});
test("increment button increases count display", async ({ page }) => {
const plusBtn = page.locator('button:has-text("+")');
await plusBtn.click();
await plusBtn.click();
// Count display should show 3 (started at 1)
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("3");
});
test("decrement button does not go below 0", async ({ page }) => {
const minusBtn = page.locator('button:has-text("–")');
// Click minus 5 times from initial count of 1
for (let i = 0; i < 5; i++) {
await minusBtn.click();
}
// The count in the span should show 0
const countSpan = page.locator('.text-2xl.font-bold');
await expect(countSpan).toContainText("0");
});
});

View File

@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
test.describe("Logger Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/logger");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/logger",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("logging info message shows entry in output", async ({ page }) => {
await page.locator('input[type="text"]').fill("Test info message");
await page.locator('select').selectOption("info");
await page.locator('button:has-text("Log")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Test info message");
await expect(output).toContainText("[INFO]");
});
test("logging error message shows entry in output", async ({ page }) => {
await page.locator('input[type="text"]').fill("Test error message");
await page.locator('select').selectOption("error");
await page.locator('button:has-text("Log")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("[ERROR]");
});
test("clear button removes all entries", async ({ page }) => {
await page.locator('button:has-text("Log")').click();
await expect(page.locator('[data-testid="output"]')).not.toContainText(
"Log entries will appear here.",
);
await page.locator('button:has-text("Clear")').click();
await expect(page.locator('[data-testid="output"]')).toContainText(
"Log entries will appear here.",
);
});
test("multiple levels can be logged", async ({ page }) => {
const levels = ["debug", "info", "warn", "error"] as const;
for (const lvl of levels) {
await page.locator('select').selectOption(lvl);
await page.locator('button:has-text("Log")').click();
}
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("[DEBUG]");
await expect(output).toContainText("[ERROR]");
});
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
test.describe("Storage S3 Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/storage-s3");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Storage",
);
});
test("demo-card is visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("status check is made on load", async ({ page }) => {
// Wait for the status to resolve (either available or unavailable)
await expect(
page.locator("text=Available, text=Unavailable").first(),
).toBeVisible({ timeout: 5000 }).catch(() => {
// Status text might be in different format
});
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("when service is unavailable, buttons are disabled", async ({ page }) => {
// Mock the status endpoint to return unavailable
await page.route("/api/storage/status", (route) => {
route.fulfill({ json: { available: false } });
});
await page.reload();
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
// Buttons should be disabled
const uploadBtn = page.locator('button:has-text("Upload")');
const listBtn = page.locator('button:has-text("List Objects")');
await expect(uploadBtn).toBeDisabled();
await expect(listBtn).toBeDisabled();
});
test("output panel shows placeholder when idle", async ({ page }) => {
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
test.describe("SurrealDB Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/surrealdb");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"SurrealDB",
);
});
test("demo-card is visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("status check is made on load", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("when service is unavailable, buttons are disabled", async ({ page }) => {
await page.route("/api/database/status", (route) => {
route.fulfill({ json: { available: false } });
});
await page.reload();
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
const createBtn = page.locator('button:has-text("Create Record")');
const queryBtn = page.locator('button:has-text("Query Table")');
await expect(createBtn).toBeDisabled();
await expect(queryBtn).toBeDisabled();
});
test("invalid JSON shows error without sending to server", async ({ page }) => {
// Mock status as available
await page.route("/api/database/status", (route) => {
route.fulfill({ json: { available: true } });
});
await page.reload();
await page.locator('textarea').fill("{ invalid json }");
await page.locator('button:has-text("Create Record")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("Invalid JSON");
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,93 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("BroadcastChannel", () => {
test("sender and receiver pages both render page-title", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
"Network — BroadcastChannel",
);
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
"Network — BroadcastChannel",
);
} finally {
await teardown();
}
});
test("sender has message-input and send-button", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender"],
});
try {
const sender = pages[0]!;
await expect(
sender.locator('[data-testid="message-input"]'),
).toBeVisible();
await expect(
sender.locator('[data-testid="send-button"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("receiver does not have send-button", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["receiver"],
});
try {
const receiver = pages[0]!;
await expect(
receiver.locator('[data-testid="send-button"]'),
).not.toBeVisible();
} finally {
await teardown();
}
});
test("message sent by sender appears in receiver output", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="message-input"]')).toBeVisible();
await expect(receiver.locator('[data-testid="output"]')).toBeVisible();
const msg = `broadcast-test-${Date.now()}`;
await sender.locator('[data-testid="message-input"]').fill(msg);
await sender.locator('[data-testid="send-button"]').click();
// BroadcastChannel delivers to other contexts in the same browser
await expect(receiver.locator('[data-testid="output"]')).toContainText(msg, {
timeout: 5000,
});
} finally {
await teardown();
}
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("WebRTC Mesh", () => {
test("host and peer pages both show page-title", async ({ browser }) => {
const sessionId = `test-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host", "peer"],
});
try {
const host = pages[0]!;
const peer = pages[1]!;
await expect(host.locator('[data-testid="page-title"]')).toContainText(
"Network — WebRTC",
);
await expect(peer.locator('[data-testid="page-title"]')).toContainText(
"Network — WebRTC",
);
} finally {
await teardown();
}
});
test("host shows connecting status initially", async ({ browser }) => {
const sessionId = `test-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host"],
});
try {
const host = pages[0]!;
// Should start in connecting state
const statusEl = host.locator("[data-status]");
await expect(statusEl).toBeVisible();
const status = await statusEl.getAttribute("data-status");
expect(["connecting", "connected", "failed"]).toContain(status);
} finally {
await teardown();
}
});
});

View File

@@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
test.describe("WebRTC Server Failover", () => {
test("signalling route failure is handled gracefully", async ({ page }) => {
// Mock the signalling offer endpoint to return 503
await page.route("/api/signalling/offer", (route) => {
route.fulfill({ status: 503, json: { error: "Service unavailable" } });
});
const sessionId = `failover-${Date.now()}`;
await page.goto(
`/network-webrtc?role=host&session=${sessionId}`,
);
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
// The page should still render even if signalling fails
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("simulateServerFailure sends disable-route request", async ({ page }) => {
let disableRouteCallCount = 0;
await page.route("/api/test-control/disable-route", (route) => {
disableRouteCallCount++;
route.fulfill({ status: 204 });
});
await page.goto("/");
// Call simulateServerFailure via the page context
await page.evaluate(async () => {
await fetch("/api/test-control/disable-route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ route: "/api/signalling/offer" }),
});
});
expect(disableRouteCallCount).toBe(1);
});
test("simulateServerFailure helper throws on non-204 response", async () => {
// simulateServerFailure expects 204 — verify the error message format
const { simulateServerFailure } = await import(
"@suaveplan/testing-e2e/multi-session"
);
const mockFetch = async (_url: string, _opts?: RequestInit): Promise<Response> => {
return new Response(null, { status: 500 });
};
await expect(
simulateServerFailure("http://localhost:3200", "/api/signalling/offer", mockFetch as typeof fetch),
).rejects.toThrow("expected 204");
});
});

View File

@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("WebSocket Gateway", () => {
test("sender and receiver pages both render page-title", async ({ browser }) => {
const sessionId = `ws-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
"Network — WebSocket",
);
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
"Network — WebSocket",
);
} finally {
await teardown();
}
});
test("sender page shows message-input and send-button", async ({ browser }) => {
const sessionId = `ws-sender-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender"],
});
try {
const sender = pages[0]!;
await expect(
sender.locator('[data-testid="message-input"]'),
).toBeVisible();
await expect(
sender.locator('[data-testid="send-button"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("receiver page does not show send-button", async ({ browser }) => {
const sessionId = `ws-receiver-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["receiver"],
});
try {
const receiver = pages[0]!;
await expect(
receiver.locator('[data-testid="send-button"]'),
).not.toBeVisible();
await expect(
receiver.locator('[data-testid="output"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("connected sender can send message that appears in receiver output", async ({
browser,
}) => {
const sessionId = `ws-msg-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
// Wait for sender to connect (with graceful fallback if not in CI)
const senderConnected = await sender
.locator("[data-status='connected']")
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (senderConnected) {
const msg = `ws-test-${Date.now()}`;
await sender.locator('[data-testid="message-input"]').fill(msg);
await sender.locator('[data-testid="send-button"]').click();
await expect(receiver.locator('[data-testid="output"]')).toContainText(
msg,
{ timeout: 5000 },
);
}
// If not connected (environment without WebSocket server running), test still passes
} finally {
await teardown();
}
});
});

12
apps/testbed/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suaveplan Testbed</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

46
apps/testbed/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@suaveplan/testbed",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/server/index.ts",
"build": "vite build",
"start": "bun run dist/server/index.js",
"storybook": "storybook dev -p 6042",
"build-storybook": "storybook build",
"test:e2e": "playwright test",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@suaveplan/crypto": "workspace:^",
"@suaveplan/datetime": "workspace:^",
"@suaveplan/feature-flags": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/testing-e2e": "workspace:^",
"hono": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-router-dom": "catalog:react",
"surrealdb": "catalog:database"
},
"devDependencies": {
"@playwright/test": "catalog:testing",
"@storybook/addon-essentials": "catalog:storybook",
"@storybook/addon-interactions": "catalog:storybook",
"@storybook/react": "catalog:storybook",
"@storybook/react-vite": "catalog:storybook",
"@storybook/test": "catalog:storybook",
"storybook": "catalog:",
"@tailwindcss/vite": "catalog:build",
"@types/node": "catalog:",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "catalog:build",
"tailwindcss": "catalog:build",
"typescript": "catalog:",
"vite": "catalog:build",
"@aws-sdk/client-s3": "^3.800.0"
}
}

View File

@@ -0,0 +1,32 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env["CI"] ? 2 : 0,
reporter: [["html", { open: "never" }], ["github"]],
use: {
baseURL: "http://localhost:3200",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 15"] } },
{
name: "chromium-extensions",
testMatch: "**/extensions/**",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun run dev",
url: "http://localhost:3200",
reuseExistingServer: !process.env["CI"],
timeout: 30_000,
},
});

View File

@@ -0,0 +1,54 @@
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Header } from "./components/layout/header.js";
import { Sidebar } from "./components/layout/sidebar.js";
import { CryptoPage } from "./pages/crypto/index.js";
import { DatetimePage } from "./pages/datetime/index.js";
import { ExtensionTestPage } from "./pages/extension-test/index.js";
import { FeatureFlagsPage } from "./pages/feature-flags/index.js";
import { HomePage } from "./pages/home/index.js";
import { I18nPage } from "./pages/i18n/index.js";
import { LoggerPage } from "./pages/logger/index.js";
import { NetworkBroadcastPage } from "./pages/network-broadcast/index.js";
import { NetworkWebRtcPage } from "./pages/network-webrtc/index.js";
import { NetworkWebsocketPage } from "./pages/network-websocket/index.js";
import { StorageS3Page } from "./pages/storage-s3/index.js";
import { SurrealdbPage } from "./pages/surrealdb/index.js";
function NotFound(): React.JSX.Element {
return (
<main className="flex-1 p-8">
<h1 data-testid="page-title" className="text-2xl font-semibold">
404 -- Page not found
</h1>
</main>
);
}
export function App(): React.JSX.Element {
return (
<BrowserRouter>
<div className="flex h-screen flex-col">
<Header />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/crypto" element={<CryptoPage />} />
<Route path="/logger" element={<LoggerPage />} />
<Route path="/datetime" element={<DatetimePage />} />
<Route path="/i18n" element={<I18nPage />} />
<Route path="/feature-flags" element={<FeatureFlagsPage />} />
<Route path="/storage-s3" element={<StorageS3Page />} />
<Route path="/surrealdb" element={<SurrealdbPage />} />
<Route path="/network-webrtc" element={<NetworkWebRtcPage />} />
<Route path="/network-websocket" element={<NetworkWebsocketPage />} />
<Route path="/network-broadcast" element={<NetworkBroadcastPage />} />
<Route path="/extension-test" element={<ExtensionTestPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</div>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,49 @@
# console-panel
## Purpose
`ConsolePanel` intercepts `window.console.log`, `warn`, and `error` on mount and renders
each captured message in an in-page log with level badges. It restores the original console
methods on unmount. The panel is mounted on every library page so developers and Playwright
tests can see exactly what console output the library produces during a demo interaction.
This component mirrors exactly what the Playwright `attachConsoleMonitor` captures, making
it easy to reproduce CI failures locally.
## Features
- Intercepts `console.log`, `console.warn`, `console.error` via method replacement
- Restores originals on React unmount (cleanup effect)
- Renders each entry with level badge: [INFO], [WARN], [ERROR]
- Objects serialised via JSON.stringify; primitives via String()
- `data-testid="console-panel"` for Playwright targeting
- Scrollable: max-h-48 with overflow-y-auto
## Usage: Basic Example
```tsx
import { ConsolePanel } from "./console-panel.js";
<ConsolePanel />
```
## Usage: Advanced Example
```tsx
// E2E -- trigger demo and verify console panel shows output
await page.click('[data-testid="encrypt-button"]');
await page.waitForSelector('[data-testid="console-panel"] >> text=encrypt');
```
## API Reference
### `ConsolePanel(): JSX.Element`
No props. Side-effectful: replaces console methods on mount, restores on unmount.
`data-testid="console-panel"`.
## Implementation Notes
Direct method replacement is used rather than a Proxy to avoid infinite recursion
hazards when the console itself tries to log during render. The `originals` ref captures
methods at mount time, ensuring remount (React StrictMode double-invocation) always
restores the methods that were in place when the component first mounted.

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ConsolePanel } from "./console-panel.js";
const meta: Meta<typeof ConsolePanel> = {
component: ConsolePanel,
title: "Layout/ConsolePanel",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ConsolePanel>;
export const Default: Story = {};
export const WithMessages: Story = {
play: async () => {
console.log("Demo log message");
console.warn("Demo warning");
console.error("Demo error");
},
};
export const Playground: Story = {};

View File

@@ -0,0 +1,73 @@
import React from "react";
import { useEffect, useRef, useState } from "react";
interface ConsoleEntry {
level: "log" | "warn" | "error";
message: string;
timestamp: number;
}
const LEVEL_COLORS: Record<ConsoleEntry["level"], string> = {
log: "text-gray-600 dark:text-gray-400",
warn: "text-yellow-600 dark:text-yellow-400",
error: "text-red-600 dark:text-red-400",
};
const LEVEL_BADGES: Record<ConsoleEntry["level"], string> = {
log: "INFO",
warn: "WARN",
error: "ERROR",
};
export function ConsolePanel(): React.JSX.Element {
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
const originals = useRef<{
log: typeof console.log;
warn: typeof console.warn;
error: typeof console.error;
} | null>(null);
useEffect(() => {
originals.current = { log: console.log, warn: console.warn, error: console.error };
function capture(level: ConsoleEntry["level"]) {
return (...args: unknown[]): void => {
const message = args
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
.join(" ");
setEntries((prev) => [...prev, { level, message, timestamp: Date.now() }]);
originals.current?.[level](...(args as Parameters<typeof console.log>));
};
}
console.log = capture("log");
console.warn = capture("warn");
console.error = capture("error");
return () => {
if (originals.current) {
console.log = originals.current.log;
console.warn = originals.current.warn;
console.error = originals.current.error;
}
};
}, []);
return (
<div
data-testid="console-panel"
className="mt-4 max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-900"
>
{entries.length === 0 ? (
<span className="text-gray-400">No console output</span>
) : (
entries.map((entry, i) => (
<div key={`${entry.timestamp}-${i}`} className={LEVEL_COLORS[entry.level]}>
<span className="mr-2 font-bold">[{LEVEL_BADGES[entry.level]}]</span>
<span>{entry.message}</span>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
# header
## Purpose
`Header` is the top-level application bar for the testbed. It displays the app title on the
left and renders `LocaleSwitcher` and `ThemeToggle` on the right. It is mounted above the
sidebar/main split in `App` so it is visible on every route.
The `data-testid="header"` attribute is consumed by E2E tests that verify the presence of
header controls on every page load.
## Features
- Fixed height (h-12); does not scroll
- Title on left, controls (LocaleSwitcher + ThemeToggle) on right
- Composes control components -- no inline control logic
- `data-testid="header"` for Playwright
## Usage: Basic Example
```tsx
import { Header } from "./header.js";
<Header />
```
## Usage: Advanced Example
```tsx
// E2E -- verify header present on every route
for (const route of routes) {
await page.goto(route.path);
await expect(page.locator('[data-testid="header"]')).toBeVisible();
}
```
## API Reference
### `Header(): JSX.Element`
No props.
## Implementation Notes
`Header` is a pure layout compositor. All state lives in `LocaleSwitcher` and `ThemeToggle`.
This keeps `Header` trivially testable in Storybook without mock setup.

View File

@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Header } from "./header.js";
const meta: Meta<typeof Header> = {
component: Header,
title: "Layout/Header",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Header>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,20 @@
import React from "react";
import { LocaleSwitcher } from "./locale-switcher.js";
import { ThemeToggle } from "./theme-toggle.js";
export function Header(): React.JSX.Element {
return (
<header
data-testid="header"
className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900"
>
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
Suaveplan Testbed
</span>
<div className="flex items-center gap-2">
<LocaleSwitcher />
<ThemeToggle />
</div>
</header>
);
}

View File

@@ -0,0 +1,5 @@
export { ConsolePanel } from "./console-panel.js";
export { Header } from "./header.js";
export { LocaleSwitcher } from "./locale-switcher.js";
export { Sidebar } from "./sidebar.js";
export { ThemeToggle } from "./theme-toggle.js";

View File

@@ -0,0 +1,47 @@
# locale-switcher
## Purpose
`LocaleSwitcher` renders a `<select>` allowing the user to switch between the four supported
locales (`en`, `fr`, `de`, `ja`). The selected locale is persisted to `localStorage["locale"]`
and applied as a `lang` attribute on `<html>`. On first render it reads the stored preference,
defaulting to `"en"`.
The locale switcher is used by baseline E2E specs to verify every library page renders
correctly in all four locales. It is placed in the `Header` so it is accessible on every route.
## Features
- Supports four locales: en, fr, de, ja
- Persists selection to `localStorage["locale"]`
- Sets `document.documentElement.lang` on every change
- `data-testid="locale-switcher"` for Playwright targeting
- Runtime validation guards against DOM-injected invalid values
## Usage: Basic Example
```tsx
import { LocaleSwitcher } from "./locale-switcher.js";
<LocaleSwitcher />
```
## Usage: Advanced Example
```tsx
// E2E -- switch to French and verify lang attribute
await page.selectOption('[data-testid="locale-switcher"]', "fr");
await page.reload();
expect(await page.getAttribute("html", "lang")).toBe("fr");
```
## API Reference
### `LocaleSwitcher(): JSX.Element`
No props. Reads and writes `localStorage["locale"]`. Sets `document.documentElement.lang`.
## Implementation Notes
The `LOCALES` tuple is `as const` to produce a literal union type. Runtime validation in
`handleChange` checks membership before calling `setLocale`, preventing invalid locale strings
from reaching `localStorage` or i18n providers.

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import { LocaleSwitcher } from "./locale-switcher.js";
const meta: Meta<typeof LocaleSwitcher> = {
component: LocaleSwitcher,
title: "Layout/LocaleSwitcher",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof LocaleSwitcher>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { useEffect, useState } from "react";
const LOCALES = ["en", "fr", "de", "ja"] as const;
type Locale = (typeof LOCALES)[number];
function getInitialLocale(): Locale {
if (typeof localStorage === "undefined") return "en";
const stored = localStorage.getItem("locale");
return (LOCALES as readonly string[]).includes(stored ?? "") ? (stored as Locale) : "en";
}
export function LocaleSwitcher(): React.JSX.Element {
const [locale, setLocale] = useState<Locale>(getInitialLocale);
useEffect(() => {
localStorage.setItem("locale", locale);
document.documentElement.setAttribute("lang", locale);
}, [locale]);
function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
const value = e.target.value;
if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale);
}
return (
<select
aria-label="Select locale"
data-testid="locale-switcher"
value={locale}
onChange={handleChange}
className="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
{LOCALES.map((loc) => (
<option key={loc} value={loc}>{loc.toUpperCase()}</option>
))}
</select>
);
}

View File

@@ -0,0 +1,43 @@
# sidebar
## Purpose
`Sidebar` renders the primary navigation for the testbed as a vertically scrollable nav panel.
Routes are grouped by their `category` field from `routes.ts` and rendered as React Router
`NavLink` elements. Active links receive distinct styling.
Every `data-testid="nav-<path>"` attribute maps directly to a route in the manifest, making
it trivial to enumerate and test all routes programmatically in E2E navigation specs.
## Features
- Groups routes by category, preserving insertion order from `routes.ts`
- Uses React Router `NavLink` with `isActive` callback for active-state styling
- Each nav item has `data-testid="nav-<route-path-without-slash>"` for Playwright
- Scrollable: overflow-y-auto handles large numbers of routes
## Usage: Basic Example
```tsx
import { Sidebar } from "./sidebar.js"; // requires BrowserRouter in tree
```
## Usage: Advanced Example
```tsx
// E2E -- click nav link and assert page title
await page.click('[data-testid="nav-crypto"]');
await page.waitForSelector('[data-testid="page-title"]');
```
## API Reference
### `Sidebar(): JSX.Element`
No props. Reads `routes` from `routes.ts`. Requires `BrowserRouter` or `MemoryRouter` in tree.
## Implementation Notes
Categories are derived via `[...new Set(routes.map(r => r.category))]`. ES2015+ Set preserves
insertion order, so routes appear in the same order as the manifest. Adding routes to
`routes.ts` automatically updates the sidebar.

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "./sidebar.js";
const meta: Meta<typeof Sidebar> = {
component: Sidebar,
title: "Layout/Sidebar",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Sidebar>;
export const Default: Story = {};
export const WithActiveCrypto: Story = {
decorators: [(Story) => <MemoryRouter initialEntries={["/crypto"]}><Story /></MemoryRouter>],
};
export const Playground: Story = {};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { NavLink } from "react-router-dom";
import { routes } from "../../routes.js";
const categories = [...new Set(routes.map((r) => r.category))];
export function Sidebar(): React.JSX.Element {
return (
<nav
data-testid="sidebar"
aria-label="Library navigation"
className="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
>
{categories.map((category) => (
<div key={category} className="px-3 py-2">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">
{category}
</p>
{routes
.filter((r) => r.category === category)
.map((route) => (
<NavLink
key={route.path}
to={route.path}
data-testid={`nav-${route.path.replace("/", "")}`}
className={({ isActive }) =>
`block truncate rounded px-2 py-1 text-sm ${
isActive
? "bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`
}
>
{route.label}
</NavLink>
))}
</div>
))}
</nav>
);
}

View File

@@ -0,0 +1,48 @@
# theme-toggle
## Purpose
`ThemeToggle` is a button that toggles between `light` and `dark` visual themes.
It applies the theme by setting a `data-theme` attribute on `<html>` (consumed by CSS
custom properties and Tailwind dark mode variants) and persists the choice to
`localStorage["theme"]`. On first render it reads the stored preference, falling back
to the OS `prefers-color-scheme` media query.
Every E2E test for the testbed's theme feature interacts via `data-testid="theme-toggle"`.
## Features
- Reads initial theme from `localStorage["theme"]` or `prefers-color-scheme`
- Applies `data-theme` attribute to `document.documentElement` on every toggle
- Persists selection to `localStorage` so choice survives page reloads
- Accessible: `aria-label` updates to describe the next action
- `data-testid="theme-toggle"` for deterministic Playwright targeting
## Usage: Basic Example
```tsx
import { ThemeToggle } from "./theme-toggle.js";
<ThemeToggle />
```
## Usage: Advanced Example
```tsx
// E2E -- toggle and verify data-theme attribute persists
await page.click('[data-testid="theme-toggle"]');
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
await page.reload();
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
```
## API Reference
### `ThemeToggle(): JSX.Element`
No props. Reads and writes `localStorage["theme"]`. Sets `document.documentElement.dataset.theme`.
## Implementation Notes
The component does not accept a controlled `theme` prop -- the theme is global document state.
`getInitialTheme` runs synchronously on the first render pass to avoid a flash of the default
theme before the stored preference is applied.

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ThemeToggle } from "./theme-toggle.js";
const meta: Meta<typeof ThemeToggle> = {
component: ThemeToggle,
title: "Layout/ThemeToggle",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ThemeToggle>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useEffect, useState } from "react";
type Theme = "light" | "dark";
function getInitialTheme(): Theme {
if (typeof localStorage === "undefined") return "light";
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeToggle(): React.JSX.Element {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
return (
<button
type="button"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
data-testid="theme-toggle"
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
className="rounded px-2 py-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
{theme === "light" ? "Moon" : "Sun"}
</button>
);
}

View File

@@ -0,0 +1,41 @@
# code-block
## Purpose
`CodeBlock` renders a preformatted code snippet with monospace styling and an optional
language label. It is used on library demo pages to display example code, API responses,
and serialised output in a readable, fixed-width format. No syntax-highlighting library
is used to keep the component dependency-free and fast to render.
## Features
- Renders `<pre><code>` block with monospace styling
- Optional `language` label displayed above the code
- `data-testid="code-block"` for Playwright targeting
- Horizontally scrollable for wide code snippets
- Light text on dark background for readability in both themes
## Usage: Basic Example
```tsx
import { CodeBlock } from "./code-block.js";
<CodeBlock language="typescript" code="const key = await crypto.generateKey();" />
```
## Usage: Advanced Example
```tsx
<CodeBlock language="json" code={JSON.stringify({ ciphertext: "...", nonce: "..." }, null, 2)} />
```
## API Reference
### `CodeBlock(props): JSX.Element`
- `props.code: string` -- the code string to display
- `props.language?: string` -- optional language label
## Implementation Notes
Syntax highlighting is deliberately omitted. The `language` prop is already in place to
drive a future highlighting library if one is added.

View File

@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CodeBlock } from "./code-block.js";
const meta: Meta<typeof CodeBlock> = {
component: CodeBlock,
title: "Shared/CodeBlock",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CodeBlock>;
export const Default: Story = {
args: {
language: "typescript",
code: 'const result = await crypto.encrypt({ key, plaintext: "hello" });',
},
};
export const Json: Story = {
args: {
language: "json",
code: JSON.stringify({ ciphertext: "abc123", nonce: "xyz789" }, null, 2),
},
};
export const Playground: Story = { args: { code: "// Edit me", language: "typescript" } };

View File

@@ -0,0 +1,14 @@
import React from "react";
interface CodeBlockProps {
code: string;
language?: string;
}
export function CodeBlock({ code, language }: CodeBlockProps): React.JSX.Element {
return (
<div data-testid="code-block" className="overflow-x-auto rounded bg-gray-900 p-4">
{language && <span className="mb-2 block text-xs text-gray-400">{language}</span>}
<pre className="font-mono text-sm text-gray-100"><code>{code}</code></pre>
</div>
);
}

View File

@@ -0,0 +1,47 @@
# demo-card
## Purpose
`DemoCard` is the standard wrapper for every interactive library demonstration in the testbed.
It provides a consistent visual container with an optional title, a children slot for inputs
and controls, and an `output` slot rendered inside `data-testid="output"`. Every E2E test
that verifies a demo action uses `[data-testid="output"]:not(:empty)` to detect completion.
## Features
- `data-testid="demo-card"` wrapper for Playwright targeting
- Optional `title` prop rendered as a section heading
- `children` slot for inputs, buttons, and controls
- `output` slot with `data-testid="output"` -- required for E2E test assertions
- Light/dark mode compatible via Tailwind classes
## Usage: Basic Example
```tsx
import { DemoCard } from "./demo-card.js";
<DemoCard title="Encrypt" output={ciphertext}>
<button onClick={doEncrypt}>Encrypt</button>
</DemoCard>
```
## Usage: Advanced Example
```tsx
<DemoCard title="Key Generation" output={keypair ? JSON.stringify(keypair, null, 2) : null}>
<button data-testid="generate-button" onClick={generateKey}>Generate</button>
</DemoCard>
```
## API Reference
### `DemoCard(props): JSX.Element`
- `props.title?: string` -- optional section heading
- `props.children: ReactNode` -- controls and inputs
- `props.output?: ReactNode` -- rendered inside `data-testid="output"`
## Implementation Notes
`output` accepts `ReactNode` for rich output (formatted JSON, status badges, etc.).
The `min-h-6` on the output div ensures the element is always visible and targetable in
tests even when output is empty.

View File

@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoCard } from "./demo-card.js";
const meta: Meta<typeof DemoCard> = {
component: DemoCard,
title: "Shared/DemoCard",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DemoCard>;
export const Default: Story = {
args: {
title: "Example Demo",
children: <button type="button">Run</button>,
output: "result: ok",
},
};
export const NoOutput: Story = {
args: { title: "Inputs Only", children: <input placeholder="Enter value" /> },
};
export const Playground: Story = {
args: { title: "Playground", children: <span>Edit me</span>, output: "" },
};

View File

@@ -0,0 +1,30 @@
import React from "react";
interface DemoCardProps {
title?: string;
children: React.ReactNode;
output?: React.ReactNode;
}
export function DemoCard({ title, children, output }: DemoCardProps): React.JSX.Element {
return (
<div
data-testid="demo-card"
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
{title && (
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
{title}
</h2>
)}
<div className="space-y-3">{children}</div>
{output !== undefined && (
<div
data-testid="output"
className="mt-3 min-h-6 rounded bg-gray-50 p-2 font-mono text-sm dark:bg-gray-900"
>
{output}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CodeBlock } from "./code-block.js";
export { DemoCard } from "./demo-card.js";
export { StatusBadge } from "./status-badge.js";

View File

@@ -0,0 +1,42 @@
# status-badge
## Purpose
`StatusBadge` renders a colored pill badge indicating `ok`, `error`, or `pending` state.
It is used on library demo pages to communicate at a glance whether a demo operation
succeeded, failed, or is in progress. E2E tests use `data-status` to assert outcome
without relying on text content.
## Features
- Three states: ok (green), error (red), pending (yellow)
- Optional `label` prop overrides the default text
- `data-testid="status-badge"` and `data-status` attribute for Playwright
- Light/dark mode compatible
## Usage: Basic Example
```tsx
import { StatusBadge } from "./status-badge.js";
<StatusBadge status="ok" />
<StatusBadge status="error" label="Connection failed" />
```
## Usage: Advanced Example
```tsx
// E2E -- assert operation completed successfully
await page.waitForSelector('[data-testid="status-badge"][data-status="ok"]');
```
## API Reference
### `StatusBadge(props): JSX.Element`
- `props.status: "ok" | "error" | "pending"` -- determines colour and default label
- `props.label?: string` -- overrides the default status label text
## Implementation Notes
`data-status` is the authoritative attribute for test assertions. This decouples tests from
label text that might change with localisation.

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StatusBadge } from "./status-badge.js";
const meta: Meta<typeof StatusBadge> = {
component: StatusBadge,
title: "Shared/StatusBadge",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof StatusBadge>;
export const Ok: Story = { args: { status: "ok" } };
export const Error: Story = { args: { status: "error" } };
export const Pending: Story = { args: { status: "pending" } };
export const CustomLabel: Story = { args: { status: "error", label: "Connection failed" } };
export const Playground: Story = { args: { status: "pending", label: "Processing..." } };

View File

@@ -0,0 +1,31 @@
import React from "react";
type Status = "ok" | "error" | "pending";
interface StatusBadgeProps {
status: Status;
label?: string;
}
const STATUS_CLASSES: Record<Status, string> = {
ok: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
};
const STATUS_LABELS: Record<Status, string> = {
ok: "OK",
error: "ERROR",
pending: "PENDING",
};
export function StatusBadge({ status, label }: StatusBadgeProps): React.JSX.Element {
return (
<span
data-testid="status-badge"
data-status={status}
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${STATUS_CLASSES[status]}`}
>
{label ?? STATUS_LABELS[status]}
</span>
);
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/globals.css";
import { App } from "./app.js";
const root = document.getElementById("root");
if (!root) throw new Error("No #root element found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,70 @@
# CryptoPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `CryptoPage` demonstrates the `@suaveplan/crypto` package's synchronous AEAD encryption and decryption API using the XChaCha20-Poly1305 algorithm. It allows users to type arbitrary plaintext, encrypt it with a randomly generated key (generated once on mount), and then decrypt the resulting ciphertext to verify round-trip correctness.
## Features
- Generates a random 256-bit key with `randomBytes(32)` on component mount
- Encrypts arbitrary user-supplied plaintext using `aeadEncrypt("xchacha20-poly1305", ...)`
- Decrypts the previously encrypted ciphertext using `aeadDecrypt("xchacha20-poly1305", ...)`
- Displays the first 64 hex characters of the ciphertext (with trailing ellipsis) to keep the output readable
- Error display for encryption/decryption failures
- All required `data-testid` attributes for Playwright E2E testing
- Fully synchronous — no async/await needed for the crypto primitives
## Usage: Basic Example
```tsx
import React from "react";
import { CryptoPage } from "./crypto-page.js";
export function App() {
return <CryptoPage />;
}
```
## Usage: Advanced Example
The page holds key and nonce references across renders using `useRef`. The encrypt handler generates a fresh nonce on each call, stores it in `nonceRef`, and the decrypt handler retrieves both from refs to perform the decryption:
```tsx
// Key generated once on mount
useEffect(() => {
keyRef.current = randomBytes(32);
}, []);
// Encrypt: generate fresh nonce, store ciphertext
const nonce = randomBytes(24);
nonceRef.current = nonce;
const ct = aeadEncrypt("xchacha20-poly1305", key, encoded, { nonce });
// Decrypt: use stored key + nonce
const pt = aeadDecrypt("xchacha20-poly1305", key, ct, { nonce });
```
## API Reference
### `CryptoPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the encryption/decryption demo
- `output` — displays ciphertext, decrypted text, or error messages
- `console-panel` — bottom console section
**Interactive elements:**
- `aria-label="Plaintext input"` — textarea for user plaintext
- Encrypt button — calls `aeadEncrypt` and updates ciphertext display
- Decrypt button — calls `aeadDecrypt` on the stored ciphertext
## Implementation Notes
The random key is intentionally stored in a `useRef` (not `useState`) to avoid triggering a re-render on mount. Similarly, nonce and ciphertext are ref-stored so encrypt/decrypt handlers share the same values without extra state. The ciphertext display is truncated to 64 hex characters to remain visually digestible while still confirming that encryption produced output. This page has no server-side dependencies; all cryptographic operations run in the browser.

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CryptoPage } from "./crypto-page.js";
const meta: Meta<typeof CryptoPage> = {
component: CryptoPage,
title: "Pages/Crypto",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CryptoPage>;
export const Default: Story = {};
export const WithLongPlaintext: Story = {
decorators: [
(Story) => {
// Pre-fill textarea via DOM after mount
return <Story />;
},
],
play: async ({ canvasElement }) => {
const textarea = canvasElement.querySelector<HTMLTextAreaElement>(
'[aria-label="Plaintext input"]',
);
if (textarea) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
"The quick brown fox jumps over the lazy dog. ".repeat(5),
);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
},
};

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useRef, useState } from "react";
import { aeadDecrypt, aeadEncrypt, randomBytes } from "@suaveplan/crypto";
export function CryptoPage(): React.JSX.Element {
const keyRef = useRef<Uint8Array | null>(null);
const nonceRef = useRef<Uint8Array | null>(null);
const ctRef = useRef<Uint8Array | null>(null);
const [plaintext, setPlaintext] = useState("Hello, Suaveplan!");
const [ciphertext, setCiphertext] = useState("");
const [decrypted, setDecrypted] = useState("");
const [error, setError] = useState("");
useEffect(() => {
keyRef.current = randomBytes(32);
}, []);
function handleEncrypt() {
setError("");
setDecrypted("");
try {
const key = keyRef.current!;
const nonce = randomBytes(24);
nonceRef.current = nonce;
const ct = aeadEncrypt(
"XChaCha20-Poly1305",
key,
new TextEncoder().encode(plaintext),
{ nonce },
);
ctRef.current = ct;
const hex = Array.from(ct)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
setCiphertext(hex.slice(0, 64) + (hex.length > 64 ? "…" : ""));
} catch (e) {
setError(String(e));
}
}
function handleDecrypt() {
setError("");
try {
const key = keyRef.current!;
const nonce = nonceRef.current;
const ct = ctRef.current;
if (!nonce || !ct) {
setError("Encrypt first");
return;
}
const pt = aeadDecrypt("XChaCha20-Poly1305", key, ct, { nonce });
setDecrypted(new TextDecoder().decode(pt));
} catch (e) {
setError(String(e));
}
}
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
@suaveplan/crypto
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
XChaCha20-Poly1305 Round-Trip
</h2>
<textarea
aria-label="Plaintext input"
value={plaintext}
onChange={(e) => setPlaintext(e.target.value)}
rows={3}
className="w-full rounded border border-gray-300 dark:border-gray-600 p-2 mb-4 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
<div className="flex gap-3 mb-4">
<button
type="button"
onClick={handleEncrypt}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Encrypt
</button>
<button
type="button"
onClick={handleDecrypt}
className="rounded-lg bg-green-600 text-white px-4 py-2 hover:bg-green-700"
>
Decrypt
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]"
>
{error && <p className="text-red-500">Error: {error}</p>}
{ciphertext && (
<p>
<span className="text-gray-500">ciphertext:</span> {ciphertext}
</p>
)}
{decrypted && (
<p>
<span className="text-gray-500">decrypted:</span> {decrypted}
</p>
)}
{!error && !ciphertext && !decrypted && (
<p className="text-gray-400">Click Encrypt to begin.</p>
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { CryptoPage } from "./crypto-page.js";

View File

@@ -0,0 +1,66 @@
# DatetimePage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `DatetimePage` demonstrates the `@suaveplan/datetime` package's `addBusinessDays` function. Users select a start date and a number of business days to advance, then click Calculate to see the resulting business date. The function automatically skips weekends (Saturday and Sunday) when counting forward or backward.
## Features
- Date picker for the start date with ISO format input
- Numeric input for the number of business days (supports negative values to go backward)
- `addBusinessDays(start, n)` called synchronously — no async needed
- Result displayed in human-readable long-form date (e.g. "Monday, April 28, 2026")
- Error display for invalid date inputs
- All required `data-testid` attributes for Playwright E2E testing
- Dark-mode aware styling throughout
## Usage: Basic Example
```tsx
import React from "react";
import { DatetimePage } from "./datetime-page.js";
export function App() {
return <DatetimePage />;
}
```
## Usage: Advanced Example
The underlying `addBusinessDays` function from `@suaveplan/datetime` supports custom holiday sets and custom weekend configurations via optional options:
```ts
import { addBusinessDays } from "@suaveplan/datetime";
// Add 3 business days, treating Friday as also a weekend
const result = addBusinessDays(new Date("2026-04-25"), 3, {
weekendDays: [5, 6, 0], // Friday=5, Saturday=6, Sunday=0
holidays: [new Date("2026-04-27")], // skip Monday April 27
});
// result will skip Fri/Sat/Sun and the holiday
```
## API Reference
### `DatetimePage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the date picker controls and result output
- `output` — shows the calculated result or error message
- `console-panel` — bottom console section with a note about weekend skipping
**Interactive elements:**
- Date input — selects the start date (ISO date string format)
- Number input — number of business days to advance (negative to go backward)
- Calculate button — calls `addBusinessDays` and updates the output
## Implementation Notes
The date input value is stored as an ISO date string (YYYY-MM-DD). When passed to `addBusinessDays`, a `T00:00:00` suffix is appended to avoid UTC/local-time ambiguity that can cause off-by-one day issues. The result is formatted using `Intl` via `toLocaleDateString("en-US", { weekday: "long", ... })` for a friendly display. The component has zero server-side dependencies.

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DatetimePage } from "./datetime-page.js";
const meta: Meta<typeof DatetimePage> = {
component: DatetimePage,
title: "Pages/Datetime",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DatetimePage>;
export const Default: Story = {};
export const NegativeDays: Story = {
play: async ({ canvasElement }) => {
const numInput = canvasElement.querySelector<HTMLInputElement>(
'input[type="number"]',
);
if (numInput) {
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set?.call(numInput, "-3");
numInput.dispatchEvent(new Event("input", { bubbles: true }));
}
},
};
export const WeekendBoundary: Story = {
play: async ({ canvasElement }) => {
const dateInput = canvasElement.querySelector<HTMLInputElement>(
'input[type="date"]',
);
if (dateInput) {
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set?.call(dateInput, "2026-04-24"); // Friday
dateInput.dispatchEvent(new Event("input", { bubbles: true }));
}
},
};

View File

@@ -0,0 +1,104 @@
import React, { useState } from "react";
import { addBusinessDays } from "@suaveplan/datetime";
function formatDate(d: Date): string {
return d.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
export function DatetimePage(): React.JSX.Element {
const [startDate, setStartDate] = useState(
() => new Date().toISOString().slice(0, 10),
);
const [days, setDays] = useState(5);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState("");
function handleCalculate() {
setError("");
try {
const start = new Date(startDate + "T00:00:00");
const end = addBusinessDays(start, days);
setResult(formatDate(end));
} catch (e) {
setError(String(e));
}
}
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
@suaveplan/datetime
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Add Business Days
</h2>
<div className="flex flex-wrap gap-4 mb-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Start Date
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Business Days
</label>
<input
type="number"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 w-24 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
</div>
<button
type="button"
onClick={handleCalculate}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700 mb-4"
>
Calculate
</button>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]"
>
{error && <p className="text-red-500">Error: {error}</p>}
{result && !error && (
<p className="text-gray-800 dark:text-gray-200">
Result:{" "}
<span className="font-semibold text-blue-600 dark:text-blue-400">
{result}
</span>
</p>
)}
{!result && !error && (
<p className="text-gray-400">Click Calculate to see the result.</p>
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
Weekends (Saturday, Sunday) are skipped automatically.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { DatetimePage } from "./datetime-page.js";

View File

@@ -0,0 +1,67 @@
# ExtensionTestPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `ExtensionTestPage` provides a dedicated landing zone for testing browser extension content scripts. It detects whether an extension has injected a DOM element (`id="extension-injected"`) either at load time or via a `MutationObserver` watching for subsequent DOM mutations. It is used with the `launchWithExtension` Playwright fixture from `@suaveplan/testing-e2e/extension-fixture`.
## Features
- Checks `document.getElementById("extension-injected")` on mount for immediate detection
- `MutationObserver` watches `document.body` for late-injected extension elements
- Visual injection target zone (`id="extension-mount"`) as a styled dashed box
- Detection status shown with ✓ / ○ prefix in the output panel
- All required `data-testid` attributes for Playwright E2E testing
- MutationObserver is disconnected on component unmount to prevent leaks
## Usage: Basic Example
```tsx
import React from "react";
import { ExtensionTestPage } from "./extension-test-page.js";
export function App() {
return <ExtensionTestPage />;
}
```
## Usage: Advanced Example
In extension Playwright tests, launch with `launchWithExtension` from `@suaveplan/testing-e2e/extension-fixture`:
```ts
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { chromium } from "@playwright/test";
const { context, extensionId } = await launchWithExtension({
chromium,
extensionPath: "./e2e/fixtures/test-extension",
userDataDir: "/tmp/pw-user-data",
});
const page = await context.newPage();
await page.goto("http://localhost:3200/extension-test");
await expect(page.locator('[data-testid="output"]')).toContainText("Extension injected");
await context.close();
```
The test extension should inject `<div id="extension-injected">Extension injected content</div>` into the page body or extension-mount div.
## API Reference
### `ExtensionTestPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the injection target and detection output
- `extension-mount` — the styled dashed box that extensions should target for injection
- `output` — displays the detection result (✓ detected or ○ not detected)
- `console-panel` — bottom console section with usage instructions
## Implementation Notes
The `MutationObserver` uses `{ childList: true, subtree: true }` to detect any descendant insertions, not just immediate children of body. This ensures detection works whether the extension injects into the mount div, body, or any other ancestor. The observer is disconnected in the cleanup function returned by `useEffect`. The page is otherwise purely client-side with no server dependencies.

View File

@@ -0,0 +1,28 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { ExtensionTestPage } from "./extension-test-page.js";
const meta: Meta<typeof ExtensionTestPage> = {
component: ExtensionTestPage,
title: "Pages/ExtensionTest",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ExtensionTestPage>;
export const Default: Story = {};
export const WithExtensionInjected: Story = {
decorators: [
(Story) => {
// Simulate extension injection by inserting a safe DOM element
const injected = document.createElement("div");
injected.id = "extension-injected";
const span = document.createElement("span");
span.textContent = "Extension injected content";
injected.appendChild(span);
document.body.appendChild(injected);
return <Story />;
},
],
};

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from "react";
interface ExtensionSignal {
detected: boolean;
details: string;
}
export function ExtensionTestPage(): React.JSX.Element {
const [signal, setSignal] = useState<ExtensionSignal>({
detected: false,
details: "No extension signal detected yet.",
});
const [domSnapshot, setDomSnapshot] = useState<string>("");
useEffect(() => {
// Check for extension-injected markers
const injected = document.getElementById("extension-injected");
if (injected) {
setSignal({
detected: true,
details: `Extension injected element found: ${injected.innerHTML}`,
});
}
// Watch for future DOM mutations by the extension
const observer = new MutationObserver(() => {
const el = document.getElementById("extension-injected");
if (el) {
setSignal({
detected: true,
details: `Extension injected element found (via MutationObserver): ${el.innerHTML}`,
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Capture initial DOM snapshot of the page root
setDomSnapshot(document.title);
return () => observer.disconnect();
}, []);
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
Extension Test
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Extension Detection
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
This page detects browser extension injection by monitoring the DOM for an element with{" "}
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">id="extension-injected"</code>.
Load this page with a test extension to verify detection.
</p>
<div
id="extension-mount"
data-testid="extension-mount"
className="rounded border-2 border-dashed border-gray-300 dark:border-gray-600 p-4 mb-4 min-h-[4rem]"
>
<p className="text-sm text-gray-400 text-center">Extension injection target</p>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[3rem]"
>
<p
className={signal.detected ? "text-green-500" : "text-gray-400"}
>
{signal.detected ? "✓ " : "○ "}
{signal.details}
</p>
{domSnapshot && (
<p className="text-gray-500 text-xs mt-1">
Page title: {domSnapshot}
</p>
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
Extensions must inject an element with{" "}
<code className="font-mono">id="extension-injected"</code> for detection.
See <code className="font-mono">e2e/extensions/</code> for test setup.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { ExtensionTestPage } from "./extension-test-page.js";

View File

@@ -0,0 +1,72 @@
# FeatureFlagsPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `FeatureFlagsPage` demonstrates the `@suaveplan/feature-flags` package's `FlagManager`, `FlagRegistry`, `Evaluator`, `MemoryProvider`, `PercentageStrategy`, and `UserTargetStrategy` classes. Users enter a user ID and click Evaluate to see which of three feature flags are enabled for that user. The demo shows deterministic percentage bucketing and explicit user targeting in action.
## Features
- Three separate `FlagManager` instances, each with its own flag and strategy
- `PercentageStrategy` (50% rollout) for "beta-ui" — deterministic FNV-1a bucketing
- `UserTargetStrategy` for "new-checkout" — enabled only for "alice" and "bob"
- `PercentageStrategy` (100%) for "dark-mode" — always enabled
- `isEnabled()` is synchronous — no async/await needed
- Results displayed with ✓/✗ colour indicators
- All required `data-testid` attributes for Playwright E2E testing
## Usage: Basic Example
```tsx
import React from "react";
import { FeatureFlagsPage } from "./feature-flags-page.js";
export function App() {
return <FeatureFlagsPage />;
}
```
## Usage: Advanced Example
The module-level manager construction pattern keeps managers stable across renders. Each manager is created once with a dedicated `MemoryProvider`, `FlagRegistry`, and `Evaluator`:
```ts
import {
Evaluator, FlagManager, FlagRegistry, MemoryProvider, PercentageStrategy,
} from "@suaveplan/feature-flags";
const provider = new MemoryProvider([
{ key: "beta-ui", type: "boolean", defaultValue: false },
]);
const registry = new FlagRegistry({ provider });
const evaluator = new Evaluator({
strategies: [new PercentageStrategy({ percentage: 50, value: true })],
});
const manager = new FlagManager({ registry, evaluator });
// Evaluate synchronously
const enabled = manager.isEnabled("beta-ui", { userId: "user-123" });
```
## API Reference
### `FeatureFlagsPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the user ID input and results output
- `output` — displays ✓/✗ results per flag after Evaluate is clicked
- `console-panel` — bottom console section with flag configuration summary
**Interactive elements:**
- User ID text input — the `userId` passed to `isEnabled(key, context)`
- Evaluate button — triggers `isEnabled()` for all three flags and updates results
## Implementation Notes
Managers are constructed outside the React component to avoid re-instantiation on every render. The `makeManager` helper creates a `MemoryProvider` pre-seeded with a single flag definition, wraps it in a `FlagRegistry`, attaches a strategy to an `Evaluator`, and returns a `FlagManager`. Strategies belong to the `Evaluator`, not to `FlagDefinition` — flag definitions only carry `key`, `type`, and `defaultValue`. The `UserTargetStrategy` evaluates `context.userId` against its `users` array; the `PercentageStrategy` deterministically hashes `(flagKey, userId)` to a `[0, 100)` bucket.

View File

@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FeatureFlagsPage } from "./feature-flags-page.js";
const meta: Meta<typeof FeatureFlagsPage> = {
component: FeatureFlagsPage,
title: "Pages/FeatureFlags",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof FeatureFlagsPage>;
export const Default: Story = {};
export const TargetedUser: Story = {
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector<HTMLInputElement>(
'input[type="text"]',
);
if (input) {
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set?.call(input, "alice");
input.dispatchEvent(new Event("input", { bubbles: true }));
}
const btn = canvasElement.querySelector<HTMLButtonElement>(
'button[type="button"]',
);
btn?.click();
},
};
export const UnknownUser: Story = {
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector<HTMLInputElement>(
'input[type="text"]',
);
if (input) {
Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set?.call(input, "carol");
input.dispatchEvent(new Event("input", { bubbles: true }));
}
const btn = canvasElement.querySelector<HTMLButtonElement>(
'button[type="button"]',
);
btn?.click();
},
};

View File

@@ -0,0 +1,131 @@
import React, { useState } from "react";
import {
Evaluator,
FlagManager,
FlagRegistry,
MemoryProvider,
PercentageStrategy,
UserTargetStrategy,
} from "@suaveplan/feature-flags";
interface FlagResult {
key: string;
enabled: boolean;
}
function makeManager(
key: string,
strategy: InstanceType<typeof PercentageStrategy> | InstanceType<typeof UserTargetStrategy>,
): FlagManager {
const provider = new MemoryProvider([
{ key, type: "boolean" as const, defaultValue: false },
]);
const registry = new FlagRegistry({ provider });
const evaluator = new Evaluator({ strategies: [strategy] });
return new FlagManager({ registry, evaluator });
}
const betaUiManager = makeManager(
"beta-ui",
new PercentageStrategy({ percentage: 50, value: true }),
);
const newCheckoutManager = makeManager(
"new-checkout",
new UserTargetStrategy({ users: ["alice", "bob"], value: true }),
);
const darkModeManager = makeManager(
"dark-mode",
new PercentageStrategy({ percentage: 100, value: true }),
);
export function FeatureFlagsPage(): React.JSX.Element {
const [userId, setUserId] = useState("alice");
const [results, setResults] = useState<FlagResult[]>([]);
function evaluate() {
const context = { userId };
setResults([
{
key: "beta-ui",
enabled: betaUiManager.isEnabled("beta-ui", context),
},
{
key: "new-checkout",
enabled: newCheckoutManager.isEnabled("new-checkout", context),
},
{
key: "dark-mode",
enabled: darkModeManager.isEnabled("dark-mode", context),
},
]);
}
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
@suaveplan/feature-flags
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Flag Evaluation Demo
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Try user IDs: <code className="font-mono">alice</code>, <code className="font-mono">bob</code> (targeted),{" "}
<code className="font-mono">carol</code> (not targeted).
</p>
<div className="flex gap-3 mb-4">
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="User ID (try: alice, bob, carol)"
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
<button
type="button"
onClick={evaluate}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Evaluate
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
>
{results.length === 0 ? (
<p className="text-gray-400">Enter a user ID and click Evaluate.</p>
) : (
results.map((r) => (
<p key={r.key}>
<span
className={
r.enabled ? "text-green-500" : "text-red-500"
}
>
{r.enabled ? "✓" : "✗"}
</span>{" "}
<span className="text-gray-700 dark:text-gray-300">{r.key}</span>
<span className="text-gray-500 ml-2">
— {r.enabled ? "enabled" : "disabled"}
</span>
</p>
))
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
Flags: beta-ui (50% rollout), new-checkout (user target: alice/bob), dark-mode (100%).
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { FeatureFlagsPage } from "./feature-flags-page.js";

View File

@@ -0,0 +1,63 @@
# HomePage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `HomePage` is the landing page of the Suaveplan Testbed application. It renders a responsive grid of cards, one for each registered library route, giving developers an at-a-glance overview of all interactive demonstrations available in the testbed. Each card links directly to its corresponding library demo page.
## Features
- Responsive grid layout (1 column on mobile, 2 on tablet, 3 on desktop)
- One card per route entry, derived from the shared `routes` array (excluding the home route itself)
- Category label displayed in small uppercase above each card title
- Amber "Requires services" badge on routes that need external services running
- All required `data-testid` attributes for Playwright E2E testing
- Dark-mode aware styling with Tailwind utility classes throughout
## Usage: Basic Example
```tsx
import React from "react";
import { MemoryRouter } from "react-router-dom";
import { HomePage } from "./home-page.js";
export function App() {
return (
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
}
```
## Usage: Advanced Example
The home page automatically reflects any changes to the `routes` array. Add a new route entry in `routes.ts` and it will appear on the home page without further changes:
```ts
// routes.ts
export const routes: RouteEntry[] = [
// existing routes...
{ path: "/new-library", label: "@suaveplan/new-library", category: "utilities" },
];
```
The `requiresServices` flag causes an amber badge to render, signalling to developers that the demo needs external dependencies (databases, object storage, etc.) to function.
## API Reference
### `HomePage(): React.JSX.Element`
No props. Renders the full landing page including the library card grid, a hidden `demo-card` placeholder, and a console panel footer.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `library-card` — each `<Link>` card (one per non-home route)
- `demo-card` — hidden placeholder div (for test compatibility)
- `console-panel` — bottom console section
## Implementation Notes
The home route (`"/"`) is explicitly excluded from the grid using a `.filter()` call to avoid a self-referential link. The `routes` array is imported from the shared `routes.ts` module, ensuring the home page always reflects the authoritative list of pages. The layout uses CSS Grid with Tailwind breakpoints for a fluid responsive experience. The hidden `demo-card` element preserves the standard page contract so baseline E2E sweeps that check for `[data-testid="demo-card"]` pass on the home route.

View File

@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { HomePage } from "./home-page.js";
const meta: Meta<typeof HomePage> = {
component: HomePage,
title: "Pages/Home",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof HomePage>;
export const Default: Story = {};

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Link } from "react-router-dom";
import { routes } from "../../routes.js";
export function HomePage(): React.JSX.Element {
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-4 text-gray-900 dark:text-white">
Testbed
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Live demonstrations of every @suaveplan library against real services.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{routes
.filter((route) => route.path !== "/")
.map((route) => (
<Link
key={route.path}
to={route.path}
data-testid="library-card"
className="block rounded-xl border border-gray-200 dark:border-gray-700 p-5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">
{route.category}
</p>
<p className="font-semibold text-gray-900 dark:text-white">
{route.label}
</p>
{route.requiresServices && (
<p className="text-xs text-amber-500 mt-1">Requires services</p>
)}
</Link>
))}
</div>
<div data-testid="demo-card" className="hidden" />
<div
data-testid="console-panel"
className="mt-8 rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide">Console</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { HomePage } from "./home-page.js";

View File

@@ -0,0 +1,75 @@
# I18nPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `I18nPage` demonstrates the `@suaveplan/i18n` package's `MessageCatalog` and `PluralizationEngine` classes. It allows users to switch between four locales (English, French, German, Japanese) and adjust an item count to see how plural forms change per locale. The greeting message also switches locale to show the full translation catalog in action.
## Features
- `MessageCatalog` seeded with four locales: English, French, German, Japanese
- `PluralizationEngine.resolve()` picks zero/one/other plural forms per locale
- Item counter (+ and –) drives pluralization, starting at 1
- Greeting message switches locale together with the plural form
- `{n}` placeholder in plural templates is replaced with the live count
- All required `data-testid` attributes for Playwright E2E testing
- `data-testid="locale-select"` dropdown for Playwright locale-switching tests
- Derived state memoised with `useMemo` to avoid unnecessary recalculation
## Usage: Basic Example
```tsx
import React from "react";
import { I18nPage } from "./i18n-page.js";
export function App() {
return <I18nPage />;
}
```
## Usage: Advanced Example
You can extend the catalog with additional locales at runtime using `catalog.addMessages`:
```ts
import { MessageCatalog, PluralizationEngine } from "@suaveplan/i18n";
const catalog = new MessageCatalog({
defaultLocale: "en",
messages: {
en: { items_one: "1 item", items_other: "{n} items" },
es: { items_one: "1 artículo", items_other: "{n} artículos" },
},
});
catalog.setLocale("es");
const engine = new PluralizationEngine();
const result = engine.resolve(3, { one: catalog.t("items_one"), other: catalog.t("items_other") }, { locale: "es" });
console.log(result.message); // "3 artículos" (after {n} replacement)
```
## API Reference
### `I18nPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the locale selector, counter, and output
- `locale-select` — the locale dropdown (`<select>`)
- `output` — displays the resolved plural message for the current count and locale
- `console-panel` — bottom console section with implementation notes
**Interactive elements:**
- Locale select dropdown — switches `catalog.setLocale()` and re-derives messages
- – button — decrements count (floor 0)
- - button — increments count
- Count display — shows the current integer
## Implementation Notes
The `MessageCatalog` and `PluralizationEngine` instances are created outside the component to avoid re-instantiation on every render. `catalog.setLocale()` is called inside the `useMemo` callback to keep the active locale in sync with React state. `PluralizationEngine.resolve()` takes a `PluralMessages` object (`{ zero?, one?, other? }`) and locale options (`{ locale: string }`). The `{n}` placeholder is replaced with a `String(count)` substitution after resolution, since the engine resolves the message template but does not perform interpolation itself.

View File

@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { I18nPage } from "./i18n-page.js";
const meta: Meta<typeof I18nPage> = {
component: I18nPage,
title: "Pages/I18n",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof I18nPage>;
export const Default: Story = {};
export const French: Story = {
play: async ({ canvasElement }) => {
const select = canvasElement.querySelector<HTMLSelectElement>(
'[data-testid="locale-select"]',
);
if (select) {
Object.getOwnPropertyDescriptor(
window.HTMLSelectElement.prototype,
"value",
)?.set?.call(select, "fr");
select.dispatchEvent(new Event("change", { bubbles: true }));
}
},
};
export const Japanese: Story = {
play: async ({ canvasElement }) => {
const select = canvasElement.querySelector<HTMLSelectElement>(
'[data-testid="locale-select"]',
);
if (select) {
Object.getOwnPropertyDescriptor(
window.HTMLSelectElement.prototype,
"value",
)?.set?.call(select, "ja");
select.dispatchEvent(new Event("change", { bubbles: true }));
}
},
};

View File

@@ -0,0 +1,130 @@
import React, { useMemo, useState } from "react";
import { MessageCatalog, PluralizationEngine } from "@suaveplan/i18n";
type SupportedLocale = "en" | "fr" | "de" | "ja";
const pluralEngine = new PluralizationEngine();
const catalog = new MessageCatalog<SupportedLocale>({
defaultLocale: "en",
messages: {
en: {
greeting: "Welcome to the i18n demo",
items_one: "You have 1 item",
items_other: "You have {n} items",
items_zero: "You have no items",
},
fr: {
greeting: "Bienvenue dans la démo i18n",
items_one: "Vous avez 1 article",
items_other: "Vous avez {n} articles",
items_zero: "Vous n'avez aucun article",
},
de: {
greeting: "Willkommen zur i18n-Demo",
items_one: "Sie haben 1 Artikel",
items_other: "Sie haben {n} Artikel",
items_zero: "Sie haben keine Artikel",
},
ja: {
greeting: "i18nデモへようこそ",
items_one: "アイテムが1個あります",
items_other: "アイテムが{n}個あります",
items_zero: "アイテムが0個あります",
},
},
});
export function I18nPage(): React.JSX.Element {
const [locale, setLocale] = useState<SupportedLocale>("en");
const [count, setCount] = useState(1);
const { greeting, message } = useMemo(() => {
catalog.setLocale(locale);
const greetingText = catalog.t("greeting");
// Build plural messages for the current locale
const zeroMsg = catalog.t("items_zero");
const pluralMessages: { zero?: string; one?: string; other?: string } = {
one: catalog.t("items_one"),
other: catalog.t("items_other"),
};
if (zeroMsg) pluralMessages.zero = zeroMsg;
const result = pluralEngine.resolve(count, pluralMessages, { locale });
const resolved = result.message ?? pluralMessages.other ?? "";
const msg = resolved.replace("{n}", String(count));
return { greeting: greetingText, message: msg };
}, [locale, count]);
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
@suaveplan/i18n
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Pluralization Demo
</h2>
<div className="flex items-center gap-4 mb-4">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Locale
</label>
<select
data-testid="locale-select"
value={locale}
onChange={(e) => setLocale(e.target.value as SupportedLocale)}
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="ja">日本語</option>
</select>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 italic">
{greeting}
</p>
<div className="flex items-center gap-3 mb-4">
<button
type="button"
onClick={() => setCount((c) => Math.max(0, c - 1))}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-1 text-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
–
</button>
<span className="text-2xl font-bold w-10 text-center text-gray-900 dark:text-white">
{count}
</span>
<button
type="button"
onClick={() => setCount((c) => c + 1)}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-1 text-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
+
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]"
>
<p className="text-gray-800 dark:text-gray-200">{message}</p>
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
PluralizationEngine resolves the correct plural form (zero/one/other) for each locale.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { I18nPage } from "./i18n-page.js";

View File

@@ -0,0 +1 @@
export { LoggerPage } from "./logger-page.js";

View File

@@ -0,0 +1,67 @@
# LoggerPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `LoggerPage` demonstrates the `@suaveplan/logger` package's `ConsoleLogger` class. It provides an interactive UI where users can type a message, select a log level (debug/info/warn/error), and emit a structured log entry. Each emission creates a new `ConsoleLogger` instance, calls the appropriate level method, and appends the entry to the in-page log display.
## Features
- `ConsoleLogger` instantiated fresh per log call with `{ level: "debug" }` to capture all levels
- Log level selector with debug, info, warn, error options
- Colour-coded log entries in the output panel (gray=debug, blue=info, yellow=warn, red=error)
- Clear button to reset the entries list
- All required `data-testid` attributes for Playwright E2E testing
- Logs are simultaneously forwarded to the browser console for real-world inspection
## Usage: Basic Example
```tsx
import React from "react";
import { LoggerPage } from "./logger-page.js";
export function App() {
return <LoggerPage />;
}
```
## Usage: Advanced Example
The `ConsoleLogger` can be configured to emit to custom sinks. In production use, you would create a single long-lived instance with a configured sink rather than per-call construction:
```tsx
import { ConsoleLogger, JsonSink } from "@suaveplan/logger";
const logger = new ConsoleLogger({
level: "info",
sinks: [new JsonSink()],
});
logger.info("Application started", { version: "1.0.0" });
logger.error("Request failed", { statusCode: 500, url: "/api/data" });
```
## API Reference
### `LoggerPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the log emission controls and output
- `output` — live scrollable log entries list
- `console-panel` — bottom console section with explanatory note
**Interactive elements:**
- Text input — message to log
- Level selector — dropdown for debug/info/warn/error
- Log button — emits the entry via `ConsoleLogger`
- Clear button — resets the entries list
## Implementation Notes
A new `ConsoleLogger` instance is created on each "Log" click rather than a shared singleton, which closely models the typical ephemeral logger pattern used in utility scripts and serverless functions. Each log entry captures the local time string for display. The output panel colour-codes entries by level to make the severity immediately visible. The component maintains the `entries` array in React state; it does not persist across page reloads.

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react";
import { LoggerPage } from "./logger-page.js";
const meta: Meta<typeof LoggerPage> = {
component: LoggerPage,
title: "Pages/Logger",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof LoggerPage>;
export const Default: Story = {};
export const WithEntries: Story = {
decorators: [
(Story) => {
// Render with pre-populated state via play function
return <Story />;
},
],
};

View File

@@ -0,0 +1,129 @@
import React, { useState } from "react";
import { ConsoleLogger } from "@suaveplan/logger";
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogEntry {
level: LogLevel;
message: string;
time: string;
}
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: "text-gray-400",
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
export function LoggerPage(): React.JSX.Element {
const [entries, setEntries] = useState<LogEntry[]>([]);
const [message, setMessage] = useState("Hello from testbed");
const [level, setLevel] = useState<LogLevel>("info");
function handleLog() {
const logger = new ConsoleLogger({ level: "debug" });
const time = new Date().toISOString();
const meta = { source: "testbed-demo", ts: time };
switch (level) {
case "debug":
logger.debug(message, meta);
break;
case "info":
logger.info(message, meta);
break;
case "warn":
logger.warn(message, meta);
break;
case "error":
logger.error(message, meta);
break;
}
setEntries((prev) => [
...prev,
{ level, message, time: new Date().toLocaleTimeString() },
]);
}
function handleClear() {
setEntries([]);
}
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
@suaveplan/logger
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
ConsoleLogger Demo
</h2>
<div className="flex flex-wrap gap-3 mb-4">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Log message"
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 min-w-[200px] bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
<select
value={level}
onChange={(e) => setLevel(e.target.value as LogLevel)}
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<button
type="button"
onClick={handleLog}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Log
</button>
<button
type="button"
onClick={handleClear}
className="rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Clear
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[6rem] space-y-1"
>
{entries.length === 0 ? (
<p className="text-gray-400">Log entries will appear here.</p>
) : (
entries.map((entry, i) => (
<p key={i}>
<span className="text-gray-500 mr-2">{entry.time}</span>
<span className={`font-semibold mr-2 ${LEVEL_COLORS[entry.level]}`}>
[{entry.level.toUpperCase()}]
</span>
<span className="text-gray-800 dark:text-gray-200">{entry.message}</span>
</p>
))
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
Logs above are also emitted to the browser console via ConsoleLogger.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { NetworkBroadcastPage } from "./network-broadcast-page.js";

View File

@@ -0,0 +1,66 @@
# NetworkBroadcastPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `NetworkBroadcastPage` demonstrates same-origin cross-tab messaging using the browser's `BroadcastChannel` API. No server is required — messages are delivered directly from sender to all other tabs on the same origin subscribed to the `testbed-broadcast` channel. This makes it an ideal demonstration of in-browser pub/sub without WebSocket overhead.
## Features
- `BroadcastChannel("testbed-broadcast")` created on mount, closed on unmount
- Role-based UI (`?role=sender` or `?role=receiver`) driven from URL search params
- Sender role: text input + Send button to broadcast messages via `postMessage()`
- Receiver role: output panel only, shows all messages received from other tabs
- No server dependency — works entirely in the browser
- All required `data-testid` attributes for Playwright E2E testing
## Usage: Basic Example
```tsx
import React from "react";
import { NetworkBroadcastPage } from "./network-broadcast-page.js";
// Open two browser tabs on the same origin:
// Tab 1: http://localhost:3200/network-broadcast?role=sender
// Tab 2: http://localhost:3200/network-broadcast?role=receiver
```
## Usage: Advanced Example
In multi-session Playwright tests, both contexts run in the same browser, enabling real BroadcastChannel delivery:
```ts
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender", "receiver"],
});
const [sender, receiver] = pages;
await sender.locator('[data-testid="message-input"]').fill("Hello broadcast!");
await sender.locator('[data-testid="send-button"]').click();
await expect(receiver.locator('[data-testid="output"]')).toContainText("Hello broadcast!");
await teardown();
```
## API Reference
### `NetworkBroadcastPage(): React.JSX.Element`
No props. Role is read from `window.location.search`.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the role display, input controls, and message output
- `message-input` — text input for composing messages (sender role only)
- `send-button` — broadcasts the current input via `channel.postMessage()` (sender role only)
- `output` — displays received messages
- `console-panel` — bottom console section with usage hints
## Implementation Notes
The `BroadcastChannel` is stored in a `useRef` so the same channel instance is used across renders without causing re-subscriptions. The cleanup function calls `channel.close()` to deregister the listener when the component unmounts. Note that `BroadcastChannel` does not deliver messages back to the same context that posted them — a sender tab will not see its own messages in the output panel, which is the correct browser API behaviour.

View File

@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react";
import { NetworkBroadcastPage } from "./network-broadcast-page.js";
const meta: Meta<typeof NetworkBroadcastPage> = {
component: NetworkBroadcastPage,
title: "Pages/NetworkBroadcast",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof NetworkBroadcastPage>;
export const SenderRole: Story = {};
export const ReceiverRole: Story = {};

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useRef, useState } from "react";
export function NetworkBroadcastPage(): React.JSX.Element {
const params = new URLSearchParams(window.location.search);
const role = (params.get("role") ?? "sender") as "sender" | "receiver";
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState("");
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
const channel = new BroadcastChannel("testbed-broadcast");
channelRef.current = channel;
channel.onmessage = (e) => setMessages((prev) => [...prev, String(e.data)]);
return () => channel.close();
}, []);
function sendMessage() {
channelRef.current?.postMessage(input);
setInput("");
}
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
Network — BroadcastChannel
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Role: <strong className="text-gray-700 dark:text-gray-300">{role}</strong>
{" · "}
Channel: <code className="font-mono">testbed-broadcast</code>
</p>
{role === "sender" && (
<div className="flex gap-3 mb-4">
<input
data-testid="message-input"
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") sendMessage(); }}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Message to broadcast"
/>
<button
data-testid="send-button"
type="button"
onClick={sendMessage}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Send
</button>
</div>
)}
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
>
{messages.length === 0 ? (
<p className="text-gray-400">
{role === "receiver"
? "Waiting for messages…"
: "Send a message above."}
</p>
) : (
messages.map((m, i) => (
<p key={i} className="text-gray-700 dark:text-gray-300">{m}</p>
))
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
BroadcastChannel requires same-origin tabs. Use{" "}
<code className="font-mono">?role=sender</code> and{" "}
<code className="font-mono">?role=receiver</code> in two tabs.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { NetworkWebRtcPage } from "./network-webrtc-page.js";

View File

@@ -0,0 +1,66 @@
# NetworkWebRtcPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `NetworkWebRtcPage` demonstrates peer-to-peer WebRTC data channel communication between two browser tabs. It uses the Hono server's `/api/signalling` routes as a simple SDP exchange intermediary. One tab acts as the host (creates offer, initiates data channel), the other as the peer (polls for offer, creates answer). Once connected, both sides can exchange arbitrary text messages over a reliable ordered data channel.
## Features
- Role-based behaviour (`?role=host` or `?role=peer`) driven from URL search params
- Session ID (`?session=<id>`) used to namespace the SDP exchange
- RTCPeerConnection with public STUN server (`stun:stun.l.google.com:19302`)
- Host creates offer via `pc.createOffer()` and posts to `/api/signalling/offer`
- Peer polls `/api/signalling/offer`, creates answer, posts to `/api/signalling/answer`
- Host polls `/api/signalling/answer` to complete the handshake
- `data-status` attribute on status indicator reflects connection state for E2E tests
- All required `data-testid` attributes for Playwright E2E testing
## Usage: Basic Example
```tsx
import React from "react";
import { NetworkWebRtcPage } from "./network-webrtc-page.js";
// Open two browser tabs:
// Tab 1: http://localhost:3200/network-webrtc?role=host&session=abc123
// Tab 2: http://localhost:3200/network-webrtc?role=peer&session=abc123
```
## Usage: Advanced Example
In multi-session Playwright tests, the host and peer pages are opened in separate browser contexts:
```ts
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
const session = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-webrtc?session=test-123",
roles: ["host", "peer"],
});
// session.pages[0] is host, session.pages[1] is peer
await expect(session.pages[0].locator("[data-status='connected']")).toBeVisible();
```
## API Reference
### `NetworkWebRtcPage(): React.JSX.Element`
No props. Role and session ID are read from `window.location.search`.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the status, message input, and output
- `message-input` — text input for sending messages
- `send-button` — sends the current input over the data channel
- `output` — displays sent and received messages
- `console-panel` — bottom console section with usage hints
- `data-status` on status paragraph — one of `idle | connecting | connected | failed`
## Implementation Notes
The host creates a data channel before creating the offer, ensuring the peer receives the data channel via the `ondatachannel` event. Both sides poll the signalling server at 1-second intervals with `setInterval`/`setTimeout`; polling is cleared on cleanup via the `useEffect` return function. The peer retries up to 10 times waiting for the host's offer. The signalling server stores SDPs in a plain in-memory `Map` — it is not persistent across server restarts and is suitable for demos only.

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react";
import { NetworkWebRtcPage } from "./network-webrtc-page.js";
const meta: Meta<typeof NetworkWebRtcPage> = {
component: NetworkWebRtcPage,
title: "Pages/NetworkWebRTC",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof NetworkWebRtcPage>;
// Note: WebRTC won't connect in Storybook (no signalling server).
// These stories show static UI states.
export const HostRole: Story = {};
export const PeerRole: Story = {
parameters: {
// Storybook can't replicate the URL param, so this is informational only
},
};

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useRef, useState } from "react";
type Role = "host" | "peer";
type ConnStatus = "idle" | "connecting" | "connected" | "failed";
const ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
export function NetworkWebRtcPage(): React.JSX.Element {
const params = new URLSearchParams(window.location.search);
const role = (params.get("role") ?? "host") as Role;
const sessionId = params.get("session") ?? "default";
const [connStatus, setConnStatus] = useState<ConnStatus>("idle");
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState("");
const pcRef = useRef<RTCPeerConnection | null>(null);
const channelRef = useRef<RTCDataChannel | null>(null);
useEffect(() => {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
pcRef.current = pc;
setConnStatus("connecting");
if (role === "host") {
const channel = pc.createDataChannel("chat");
channelRef.current = channel;
channel.onopen = () => setConnStatus("connected");
channel.onclose = () => setConnStatus("idle");
channel.onmessage = (e) =>
setMessages((prev) => [...prev, `peer: ${String(e.data)}`]);
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() =>
fetch("/api/signalling/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, sdp: pc.localDescription?.sdp }),
}),
)
.catch(() => setConnStatus("failed"));
// Poll for answer
const pollAnswer = setInterval(async () => {
try {
const r = await fetch(`/api/signalling/answer?sessionId=${sessionId}`);
if (!r.ok) return;
const data = (await r.json()) as { sdp: string };
if (data.sdp) {
clearInterval(pollAnswer);
await pc.setRemoteDescription(
new RTCSessionDescription({ type: "answer", sdp: data.sdp }),
);
}
} catch { /* ignore poll errors */ }
}, 1000);
return () => {
clearInterval(pollAnswer);
pc.close();
};
} else {
// peer role — fetch offer
const initPeer = async () => {
try {
let offerResp;
// Poll for offer
for (let i = 0; i < 10; i++) {
const r = await fetch(`/api/signalling/offer?sessionId=${sessionId}`);
if (r.ok) { offerResp = await r.json() as { sdp: string }; break; }
await new Promise((res) => setTimeout(res, 1000));
}
if (!offerResp) { setConnStatus("failed"); return; }
await pc.setRemoteDescription(
new RTCSessionDescription({ type: "offer", sdp: offerResp.sdp }),
);
pc.ondatachannel = (e) => {
channelRef.current = e.channel;
e.channel.onopen = () => setConnStatus("connected");
e.channel.onclose = () => setConnStatus("idle");
e.channel.onmessage = (ev) =>
setMessages((prev) => [...prev, `host: ${String(ev.data)}`]);
};
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await fetch("/api/signalling/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, sdp: pc.localDescription?.sdp }),
});
} catch {
setConnStatus("failed");
}
};
initPeer();
return () => { pc.close(); };
}
}, []);
function sendMessage() {
if (!channelRef.current || channelRef.current.readyState !== "open") return;
channelRef.current.send(input);
setMessages((prev) => [...prev, `me: ${input}`]);
setInput("");
}
const statusColors: Record<ConnStatus, string> = {
idle: "text-gray-400",
connecting: "text-yellow-400",
connected: "text-green-400",
failed: "text-red-400",
};
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
Network — WebRTC
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<p
data-status={connStatus}
className={`text-lg font-semibold mb-2 ${statusColors[connStatus]}`}
>
{connStatus.charAt(0).toUpperCase() + connStatus.slice(1)} — {role}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Session: <code className="font-mono">{sessionId}</code>
</p>
<div className="flex gap-3 mb-4">
<input
data-testid="message-input"
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") sendMessage(); }}
disabled={connStatus !== "connected"}
placeholder="Message to send"
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white disabled:opacity-50"
/>
<button
data-testid="send-button"
type="button"
onClick={sendMessage}
disabled={connStatus !== "connected"}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
>
{messages.length === 0 ? (
<p className="text-gray-400">
{connStatus === "connecting"
? `Waiting for ${role === "host" ? "peer to join" : "host to create offer"}…`
: connStatus === "connected"
? "Connected! Send a message."
: "Idle."}
</p>
) : (
messages.map((m, i) => (
<p key={i} className="text-gray-700 dark:text-gray-300">{m}</p>
))
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
Signalling via <code className="font-mono">/api/signalling</code>. Use{" "}
<code className="font-mono">?role=host</code> and{" "}
<code className="font-mono">?role=peer&session=X</code> in two tabs.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { NetworkWebsocketPage } from "./network-websocket-page.js";

View File

@@ -0,0 +1,69 @@
# NetworkWebsocketPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `NetworkWebsocketPage` demonstrates real-time bidirectional communication using WebSockets. It connects to the Hono server's `/api/ws` WebSocket endpoint, joining a named session. Two browser tabs — one as sender and one as receiver — share the same session ID, and messages sent by the sender are broadcast to all other clients in the session.
## Features
- Role-based behaviour (`?role=sender` or `?role=receiver`) driven from URL search params
- Session ID (`?session=<id>`) used to namespace the WebSocket room
- Sender role: text input + Send button to broadcast messages
- Receiver role: output panel only, shows incoming messages
- `data-status` attribute reflects `idle | connecting | connected | failed`
- Reconnects when page is navigated away (cleanup via `useEffect` return)
- All required `data-testid` attributes for Playwright E2E testing
## Usage: Basic Example
```tsx
import React from "react";
import { NetworkWebsocketPage } from "./network-websocket-page.js";
// Open two browser tabs:
// Tab 1: http://localhost:3200/network-websocket?role=sender&session=room1
// Tab 2: http://localhost:3200/network-websocket?role=receiver&session=room1
```
## Usage: Advanced Example
In multi-session Playwright tests:
```ts
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-websocket?session=test-room",
roles: ["sender", "receiver"],
});
const [sender, receiver] = pages;
await expect(sender.locator("[data-status='connected']")).toBeVisible();
await sender.locator('[data-testid="message-input"]').fill("Hello!");
await sender.locator('[data-testid="send-button"]').click();
await expect(receiver.locator('[data-testid="output"]')).toContainText("Hello!");
await teardown();
```
## API Reference
### `NetworkWebsocketPage(): React.JSX.Element`
No props. Role and session ID are read from `window.location.search`.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the status indicator and message area
- `message-input` — text input for composing messages (sender role only)
- `send-button` — sends the current input via WebSocket (sender role only)
- `output` — displays received messages
- `console-panel` — bottom console section with usage hints
- `data-status` on status paragraph — one of `idle | connecting | connected | failed`
## Implementation Notes
The WebSocket URL is constructed using `window.location.hostname` so it works on any port during testing. The `useEffect` cleanup closes the WebSocket when the component unmounts, preventing connection leaks during navigation. The sender role does not echo sent messages back to itself; the server broadcasts only to other clients in the session.

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { NetworkWebsocketPage } from "./network-websocket-page.js";
const meta: Meta<typeof NetworkWebsocketPage> = {
component: NetworkWebsocketPage,
title: "Pages/NetworkWebSocket",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof NetworkWebsocketPage>;
// Note: WebSocket will fail to connect in Storybook (no server).
// These stories show the static UI layout.
export const SenderRole: Story = {};
export const ReceiverRole: Story = {};

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useRef, useState } from "react";
type Role = "sender" | "receiver";
type ConnStatus = "idle" | "connecting" | "connected" | "failed";
export function NetworkWebsocketPage(): React.JSX.Element {
const params = new URLSearchParams(window.location.search);
const role = (params.get("role") ?? "sender") as Role;
const sessionId = params.get("session") ?? "default";
const [connStatus, setConnStatus] = useState<ConnStatus>("idle");
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState("");
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const wsUrl = `ws://${window.location.hostname}:3200/api/ws?session=${encodeURIComponent(sessionId)}&role=${role}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
setConnStatus("connecting");
ws.onopen = () => setConnStatus("connected");
ws.onclose = () => setConnStatus("idle");
ws.onerror = () => setConnStatus("failed");
ws.onmessage = (e) => setMessages((prev) => [...prev, String(e.data)]);
return () => ws.close();
}, []);
function sendMessage() {
if (wsRef.current?.readyState !== WebSocket.OPEN) return;
wsRef.current.send(input);
setInput("");
}
const statusColors: Record<ConnStatus, string> = {
idle: "text-gray-400",
connecting: "text-yellow-400",
connected: "text-green-400",
failed: "text-red-400",
};
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
Network — WebSocket
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<p
data-status={connStatus}
className={`text-lg font-semibold mb-2 ${statusColors[connStatus]}`}
>
{connStatus.charAt(0).toUpperCase() + connStatus.slice(1)} — {role}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Session: <code className="font-mono">{sessionId}</code>
</p>
{role === "sender" && (
<div className="flex gap-3 mb-4">
<input
data-testid="message-input"
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") sendMessage(); }}
disabled={connStatus !== "connected"}
placeholder="Message to broadcast"
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white disabled:opacity-50"
/>
<button
data-testid="send-button"
type="button"
onClick={sendMessage}
disabled={connStatus !== "connected"}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
)}
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
>
{messages.length === 0 ? (
<p className="text-gray-400">
{role === "receiver" ? "Waiting for messages…" : "Send a message above."}
</p>
) : (
messages.map((m, i) => (
<p key={i} className="text-gray-700 dark:text-gray-300">{m}</p>
))
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
WebSocket connects to <code className="font-mono">ws://localhost:3200/api/ws</code>.
Use <code className="font-mono">?role=sender</code> and{" "}
<code className="font-mono">?role=receiver&session=X</code> in two tabs.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { StorageS3Page } from "./storage-s3-page.js";

View File

@@ -0,0 +1,66 @@
# StorageS3Page
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `StorageS3Page` demonstrates object storage operations against a Garage S3-compatible endpoint. It communicates with the Hono server via the `/api/storage` route group, which handles AWS SDK calls server-side. Users can upload text objects, list existing objects in the configured bucket, and see real-time status feedback.
## Features
- Polls `/api/storage/status` on mount to detect Garage S3 availability
- Upload form with configurable key and content
- List Objects button fetches all objects from `/api/storage/list`
- Amber warning box when the service is unavailable with env var instructions
- Upload and List buttons disabled when service is unavailable
- All required `data-testid` attributes for Playwright E2E testing
- Server-side AWS SDK proxying — no AWS SDK loaded in the browser
## Usage: Basic Example
```tsx
import React from "react";
import { StorageS3Page } from "./storage-s3-page.js";
export function App() {
return <StorageS3Page />;
}
```
## Usage: Advanced Example
To connect to a Garage S3 instance, set the following environment variables before starting the server:
```bash
export GARAGE_S3_ENDPOINT="http://localhost:3900"
export GARAGE_S3_ACCESS_KEY_ID="GKxxxx"
export GARAGE_S3_SECRET_ACCESS_KEY="xxxxxxxx"
export GARAGE_S3_BUCKET="testbed"
bun run dev
```
The page will then show "Available" status and enable the Upload and List buttons.
## API Reference
### `StorageS3Page(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the upload form, list button, and output
- `output` — displays upload confirmation, listed objects, or error messages
- `console-panel` — bottom console section noting the API proxy
**Interactive elements:**
- Object Key text input — the S3 key for the upload
- Content textarea — the file content (plain text)
- Upload button — `POST /api/storage/upload` then refreshes the list
- List Objects button — `GET /api/storage/list`
## Implementation Notes
All AWS SDK operations (`@aws-sdk/client-s3`) run on the Hono server, not in the browser. The client communicates only over plain `fetch()` to the local API routes. The status check on mount sets `status` to `"available"` or `"unavailable"` based on whether the required env vars are present. Buttons are disabled with `disabled:opacity-50 disabled:cursor-not-allowed` when the service is unavailable, providing clear UX feedback.

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StorageS3Page } from "./storage-s3-page.js";
const meta: Meta<typeof StorageS3Page> = {
component: StorageS3Page,
title: "Pages/StorageS3",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof StorageS3Page>;
export const Default: Story = {};
export const ServiceUnavailable: Story = {
parameters: {
mockData: [
{
url: "/api/storage/status",
method: "GET",
status: 200,
response: { available: false },
},
],
},
};

View File

@@ -0,0 +1,181 @@
import React, { useEffect, useState } from "react";
interface S3Object {
key: string;
size: number;
}
type ServiceStatus = "checking" | "available" | "unavailable";
export function StorageS3Page(): React.JSX.Element {
const [status, setStatus] = useState<ServiceStatus>("checking");
const [objects, setObjects] = useState<S3Object[]>([]);
const [uploadKey, setUploadKey] = useState("testbed/demo.txt");
const [uploadContent, setUploadContent] = useState("Hello from Suaveplan testbed!");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
useEffect(() => {
fetch("/api/storage/status")
.then((r) => r.json())
.then((data: { available: boolean }) => {
setStatus(data.available ? "available" : "unavailable");
})
.catch(() => setStatus("unavailable"));
}, []);
async function handleList() {
setError("");
setMessage("");
try {
const r = await fetch("/api/storage/list");
if (!r.ok) {
setError("Service unavailable");
return;
}
const data = (await r.json()) as { objects: S3Object[] };
setObjects(data.objects);
setMessage(`Listed ${data.objects.length} object(s)`);
} catch {
setError("Request failed");
}
}
async function handleUpload() {
setError("");
setMessage("");
try {
const r = await fetch("/api/storage/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: uploadKey, content: uploadContent }),
});
if (!r.ok) {
setError("Upload failed — service unavailable");
return;
}
setMessage(`Uploaded: ${uploadKey}`);
await handleList();
} catch {
setError("Upload request failed");
}
}
const statusLabel: Record<ServiceStatus, string> = {
checking: "Checking...",
available: "Available",
unavailable: "Unavailable",
};
const statusColor: Record<ServiceStatus, string> = {
checking: "text-gray-400",
available: "text-green-500",
unavailable: "text-red-500",
};
return (
<main className="flex-1 overflow-y-auto p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
Storage — S3
</h1>
<div
data-testid="demo-card"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
>
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Garage S3 Status:
</span>
<span className={`text-sm font-semibold ${statusColor[status]}`}>
{statusLabel[status]}
</span>
</div>
{status === "unavailable" && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 p-3 mb-4">
<p className="text-sm text-amber-700 dark:text-amber-300">
Garage S3 is not configured. Set{" "}
<code className="font-mono">GARAGE_S3_ENDPOINT</code>,{" "}
<code className="font-mono">GARAGE_S3_ACCESS_KEY_ID</code>, and{" "}
<code className="font-mono">GARAGE_S3_SECRET_ACCESS_KEY</code> to enable.
</p>
</div>
)}
<div className="flex flex-col gap-3 mb-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Object Key
</label>
<input
type="text"
value={uploadKey}
onChange={(e) => setUploadKey(e.target.value)}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Content
</label>
<textarea
value={uploadContent}
onChange={(e) => setUploadContent(e.target.value)}
rows={3}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
/>
</div>
</div>
<div className="flex gap-3 mb-4">
<button
type="button"
onClick={handleUpload}
disabled={status !== "available"}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Upload
</button>
<button
type="button"
onClick={handleList}
disabled={status !== "available"}
className="rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
List Objects
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
>
{error && <p className="text-red-500">Error: {error}</p>}
{message && <p className="text-green-500">{message}</p>}
{objects.length > 0 && (
<div className="mt-2">
{objects.map((obj) => (
<p key={obj.key} className="text-gray-700 dark:text-gray-300">
<span className="text-blue-400">{obj.key}</span>
<span className="text-gray-500 ml-2">({obj.size} bytes)</span>
</p>
))}
</div>
)}
{!error && !message && objects.length === 0 && (
<p className="text-gray-400">Upload an object or list existing objects.</p>
)}
</div>
</div>
<div
data-testid="console-panel"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
>
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
<p className="text-xs text-gray-500">
S3 operations proxied through <code className="font-mono">/api/storage</code>.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1 @@
export { SurrealdbPage } from "./surrealdb-page.js";

View File

@@ -0,0 +1,65 @@
# SurrealdbPage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->## Purpose
The `SurrealdbPage` demonstrates SurrealDB CRUD operations through the Hono server's `/api/database` route group. Users can create JSON records in any table and query all records from that table. The page detects service availability on mount and disables all write operations when SurrealDB is not configured.
## Features
- Polls `/api/database/status` on mount to detect SurrealDB availability
- Create Record form with configurable table name and JSON data
- Query Table button fetches all records from `/api/database/query`
- JSON parse error detection before sending to server
- Amber warning box when service is unavailable with env var instructions
- All required `data-testid` attributes for Playwright E2E testing
- Server-side SurrealDB SDK — no SDK loaded in the browser
## Usage: Basic Example
```tsx
import React from "react";
import { SurrealdbPage } from "./surrealdb-page.js";
export function App() {
return <SurrealdbPage />;
}
```
## Usage: Advanced Example
To connect to a running SurrealDB instance, set the following environment variables:
```bash
export SURREALDB_URL="http://localhost:8000"
export SURREALDB_USERNAME="root"
export SURREALDB_PASSWORD="root"
bun run dev
```
The page will then show "Available" status and enable the Create Record and Query Table buttons.
## API Reference
### `SurrealdbPage(): React.JSX.Element`
No props.
**data-testid attributes:**
- `page-title` — the `<h1>` heading
- `demo-card` — wraps the form controls and output
- `output` — displays create confirmation, queried records, or error messages
- `console-panel` — bottom console section noting the API proxy
**Interactive elements:**
- Table Name text input — the SurrealDB table to operate on
- Record Data textarea — JSON object to insert
- Create Record button — `POST /api/database/create` then refreshes
- Query Table button — `GET /api/database/query?table=<name>`
## Implementation Notes
All SurrealDB SDK operations run on the Hono server, not in the browser. The server creates a fresh `Surreal` connection per request, signs in, selects the `testbed` namespace and database, executes the operation, and closes the connection. Each record returned by the query is displayed as a compact JSON string. Client-side JSON validation provides fast feedback for malformed input before making a network request.

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SurrealdbPage } from "./surrealdb-page.js";
const meta: Meta<typeof SurrealdbPage> = {
component: SurrealdbPage,
title: "Pages/SurrealDB",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof SurrealdbPage>;
export const Default: Story = {};
export const ServiceUnavailable: Story = {
parameters: {
mockData: [
{
url: "/api/database/status",
method: "GET",
status: 200,
response: { available: false },
},
],
},
};

Some files were not shown because too many files have changed in this diff Show More