diff --git a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts
new file mode 100644
index 0000000..77f6ca2
--- /dev/null
+++ b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts
@@ -0,0 +1,75 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { accessSync } from "node:fs";
+import { setTimeout as delay } from "node:timers/promises";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+let server: ChildProcessWithoutNullStreams;
+const port = 4291;
+const baseUrl = `http://127.0.0.1:${port}`;
+
+beforeAll(async () => {
+ accessSync(new URL("../dist/index.html", import.meta.url));
+ server = spawn(process.execPath, ["server.js"], {
+ cwd: new URL("..", import.meta.url),
+ env: { ...process.env, PORT: String(port) }
+ });
+
+ await waitForServer();
+});
+
+afterAll(() => {
+ server?.kill();
+});
+
+describe("LogicSRC web contracts", () => {
+ it("serves SPA routes from the built app shell", async () => {
+ for (const route of ["/", "/openspec", "/docs", "/blog", "/about", "/terms", "/privacy"]) {
+ const response = await fetch(`${baseUrl}${route}`);
+ const text = await response.text();
+
+ expect(response.status, route).toBe(200);
+ expect(response.headers.get("content-type")).toContain("text/html");
+ expect(text).toContain('
');
+ }
+ });
+
+ it("serves sitemap.xml as XML with canonical routes", async () => {
+ const response = await fetch(`${baseUrl}/sitemap.xml`);
+ const text = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toContain("application/xml");
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(text).toContain("https://logicsrc.com/openspec");
+ expect(text).toContain("https://logicsrc.com/blog");
+ });
+
+ it("serves blog/rss.xml as RSS XML", async () => {
+ const response = await fetch(`${baseUrl}/blog/rss.xml`);
+ const text = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toContain("application/xml");
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(text).toContain("LogicSRC OpenSpec Compatibility");
+ });
+});
+
+async function waitForServer() {
+ let lastError: unknown;
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ try {
+ const response = await fetch(`${baseUrl}/health`);
+ if (response.ok) {
+ return;
+ }
+ } catch (error) {
+ lastError = error;
+ }
+
+ await delay(250);
+ }
+
+ throw new Error(`LogicSRC web server did not start: ${String(lastError)}`);
+}
diff --git a/apps/logicsrc-web/e2e/logicsrc.spec.ts b/apps/logicsrc-web/e2e/logicsrc.spec.ts
new file mode 100644
index 0000000..09d974a
--- /dev/null
+++ b/apps/logicsrc-web/e2e/logicsrc.spec.ts
@@ -0,0 +1,37 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("LogicSRC PWA", () => {
+ test("renders OpenSpec comparison and compatibility commands", async ({ page }) => {
+ await page.goto("/openspec");
+
+ await expect(page.getByRole("heading", { name: "LogicSRC", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "LogicSRC vs OpenSpec.dev" })).toBeVisible();
+ await expect(page.getByText("OpenSpec.dev currently positions itself as no-MCP.")).toBeVisible();
+ await expect(page.getByText("logicsrc --openspec agentswarm --yolo")).toBeVisible();
+ await expect(page.getByText("openspec import")).toBeVisible();
+ await expect(page.getByText("openspec export")).toBeVisible();
+ });
+
+ test("renders top-level docs and legal route targets", async ({ page }) => {
+ await page.goto("/privacy");
+
+ await expect(page.getByRole("heading", { name: "Top-Level Pages" })).toBeVisible();
+ await expect(page.getByText("/docs · Docs")).toBeVisible();
+ await expect(page.getByText("/blog · Blog")).toBeVisible();
+ await expect(page.getByText("/terms · Terms")).toBeVisible();
+ await expect(page.getByText("/privacy · Privacy")).toBeVisible();
+ });
+
+ test("serves sitemap and RSS XML endpoints", async ({ request }) => {
+ const sitemap = await request.get("/sitemap.xml");
+ const rss = await request.get("/blog/rss.xml");
+
+ expect(sitemap.status()).toBe(200);
+ expect(sitemap.headers()["content-type"]).toMatch(/(?:application|text)\/xml/);
+ await expect(sitemap.text()).resolves.toContain("https://logicsrc.com/openspec");
+
+ expect(rss.status()).toBe(200);
+ expect(rss.headers()["content-type"]).toMatch(/(?:application|text)\/xml/);
+ await expect(rss.text()).resolves.toContain("LogicSRC OpenSpec Compatibility");
+ });
+});
diff --git a/apps/logicsrc-web/package.json b/apps/logicsrc-web/package.json
index 9f6f430..c56e0d6 100644
--- a/apps/logicsrc-web/package.json
+++ b/apps/logicsrc-web/package.json
@@ -6,11 +6,18 @@
"scripts": {
"build": "node scripts/check-assets.js && vite build",
"dev": "vite --host 0.0.0.0",
- "start": "node server.js"
+ "start": "node server.js",
+ "test": "vitest run contract",
+ "test:contract": "vitest run contract",
+ "test:e2e": "playwright test"
},
"dependencies": {
"@logicsrc/commandboard-api": "file:../commandboard-api",
"typescript": "^5.9.3",
"vite": "^7.2.6"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.57.0",
+ "vitest": "^4.0.8"
}
}
diff --git a/apps/logicsrc-web/playwright.config.ts b/apps/logicsrc-web/playwright.config.ts
new file mode 100644
index 0000000..e85e8b4
--- /dev/null
+++ b/apps/logicsrc-web/playwright.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ expect: {
+ timeout: 5_000
+ },
+ fullyParallel: true,
+ reporter: process.env.CI ? [["github"], ["list"]] : "list",
+ use: {
+ baseURL: "http://127.0.0.1:5174",
+ trace: "on-first-retry"
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] }
+ },
+ {
+ name: "mobile-chrome",
+ use: { ...devices["Pixel 7"] }
+ }
+ ],
+ webServer: {
+ command: "npm run dev -- --port 5174",
+ url: "http://127.0.0.1:5174",
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000
+ }
+});
diff --git a/package-lock.json b/package-lock.json
index bb40e33..22edd74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -128,6 +128,10 @@
"@logicsrc/commandboard-api": "file:../commandboard-api",
"typescript": "^5.9.3",
"vite": "^7.2.6"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.57.0",
+ "vitest": "^4.0.8"
}
},
"apps/logicsrc-web/node_modules/vite": {
diff --git a/package.json b/package.json
index 81791f0..dd84515 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,8 @@
"test": "npm run test --workspaces --if-present",
"check": "npm run build && npm run test",
"schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures",
- "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract",
- "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e"
+ "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract",
+ "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e"
},
"devDependencies": {
"@types/node": "^24.10.1",