← back to Wallco Ai
Add Ollama-free integration tests (admin-gate, csv-export, age-themes-render, pairings-api)
925dc4e68e5cc3ffcfbdc022f2d906d14a5fe55c · 2026-05-11 22:12:47 -0700 · SteveStudio2
18 tests across 4 specs, all passing in <5s. No Ollama dependency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
M tests/integration/admin-gate.spec.jsM tests/integration/age-themes-render.spec.jsM tests/integration/csv-export.spec.jsM tests/integration/pairings-api.spec.js
Diff
commit 925dc4e68e5cc3ffcfbdc022f2d906d14a5fe55c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 22:12:47 2026 -0700
Add Ollama-free integration tests (admin-gate, csv-export, age-themes-render, pairings-api)
18 tests across 4 specs, all passing in <5s. No Ollama dependency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
tests/integration/admin-gate.spec.js | 61 +++++++--------
tests/integration/age-themes-render.spec.js | 10 ++-
tests/integration/csv-export.spec.js | 110 +++++++++-------------------
tests/integration/pairings-api.spec.js | 73 +++++++-----------
4 files changed, 95 insertions(+), 159 deletions(-)
diff --git a/tests/integration/admin-gate.spec.js b/tests/integration/admin-gate.spec.js
index fb5412d..251ec70 100644
--- a/tests/integration/admin-gate.spec.js
+++ b/tests/integration/admin-gate.spec.js
@@ -5,21 +5,19 @@
* and ?review=1 is absent. They return 200 (with content) when ?review=1 is
* present, because the server trusts that query param as the admin signal.
*
- * Host-header spoofing: Playwright cannot override the Host header for
- * requests it makes through page.goto() — the browser always sends
- * 'Host: 127.0.0.1' regardless. We instead use page.evaluate() with
- * fetch() directly, which lets us set arbitrary headers. The server checks
- * req.hostname (derived from Host header), so this correctly exercises the
- * gate.
+ * Host-header spoofing: page.request.fetch() is Playwright's HTTP client
+ * layer that sits outside the browser sandbox and correctly relays arbitrary
+ * headers including Host. The server reads req.hostname from the Host header,
+ * so spoofing "wallco.ai" correctly exercises the gate.
*
- * NOTE: When using fetch via page.evaluate, the URL must be absolute
- * (including origin) because the page context is about:blank initially.
+ * Timeouts: page.request.fetch calls get an explicit 10s timeout to override
+ * the config's 8s actionTimeout, which can be tight on a loaded machine.
+ * page.goto uses waitUntil:'commit' (HTTP response received) rather than
+ * 'domcontentloaded' to avoid blocking on external font CDN resources.
*/
'use strict';
const { test, expect } = require('@playwright/test');
-const BASE = 'http://127.0.0.1:9792';
-
// Dismiss the wallco.ai age-prompt overlay before any navigation.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
@@ -30,25 +28,22 @@ test.beforeEach(async ({ page }) => {
// ── /age-themes ───────────────────────────────────────────────────────────────
test('A-1 · /age-themes without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
- // Navigate to about:blank so we have a stable context for fetch.
- await page.goto('about:blank');
-
- const status = await page.evaluate(async (base) => {
- const res = await fetch(base + '/age-themes', {
- headers: { Host: 'wallco.ai' },
- redirect: 'follow',
- });
- return res.status;
- }, BASE);
-
- expect(status).toBe(404);
+ const response = await page.request.fetch('/age-themes', {
+ headers: { Host: 'wallco.ai' },
+ timeout: 10_000,
+ });
+ expect(response.status()).toBe(404);
});
test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page }) => {
- const response = await page.goto(`${BASE}/age-themes?review=1`, { waitUntil: 'domcontentloaded' });
+ // waitUntil:'commit' fires as soon as the HTTP response is received —
+ // avoids blocking on external font CDN resources.
+ const response = await page.goto('/age-themes?review=1', { waitUntil: 'commit', timeout: 15_000 });
expect(response.status()).toBe(200);
- // Page must contain at least one h1 or a band-row element.
+ // Wait for DOM to be interactive before querying.
+ await page.waitForLoadState('domcontentloaded');
+
const hasContent = await page.evaluate(() =>
document.querySelectorAll('h1, section.band-row').length > 0
);
@@ -58,20 +53,14 @@ test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page
// ── /moodboard ────────────────────────────────────────────────────────────────
test('A-3 · /moodboard without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
- await page.goto('about:blank');
-
- const status = await page.evaluate(async (base) => {
- const res = await fetch(base + '/moodboard', {
- headers: { Host: 'wallco.ai' },
- redirect: 'follow',
- });
- return res.status;
- }, BASE);
-
- expect(status).toBe(404);
+ const response = await page.request.fetch('/moodboard', {
+ headers: { Host: 'wallco.ai' },
+ timeout: 10_000,
+ });
+ expect(response.status()).toBe(404);
});
test('A-4 · /moodboard?review=1 returns 200', async ({ page }) => {
- const response = await page.goto(`${BASE}/moodboard?review=1`, { waitUntil: 'domcontentloaded' });
+ const response = await page.goto('/moodboard?review=1', { waitUntil: 'commit', timeout: 15_000 });
expect(response.status()).toBe(200);
});
diff --git a/tests/integration/age-themes-render.spec.js b/tests/integration/age-themes-render.spec.js
index 7f6ffea..7e130fe 100644
--- a/tests/integration/age-themes-render.spec.js
+++ b/tests/integration/age-themes-render.spec.js
@@ -10,6 +10,10 @@
*
* None of these checks require Ollama — the page is fully server-side rendered
* from src/bands.json.
+ *
+ * waitUntil:'commit' is used for the initial navigation to avoid blocking on
+ * external Google Fonts CDN; we then wait for the first band-row to appear
+ * which confirms DOM is fully rendered (it's all SSR — no JS rendering).
*/
'use strict';
const { test, expect } = require('@playwright/test');
@@ -19,8 +23,8 @@ test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
});
- await page.goto('/age-themes?review=1', { waitUntil: 'domcontentloaded' });
- // Wait for the first band section to be visible before each test.
+ await page.goto('/age-themes?review=1', { waitUntil: 'commit', timeout: 15_000 });
+ // Wait for the first band section to confirm SSR content is present.
await expect(page.locator('section.band-row').first()).toBeVisible({ timeout: 10_000 });
});
@@ -33,7 +37,7 @@ test('C-1 · page has exactly 8 band-row sections', async ({ page }) => {
// ── C-2: APCA badge count ─────────────────────────────────────────────────────
-test('C-2 · each band-row has exactly 6 APCA Lc badges (3 per swatch × 2 swatches)', async ({ page }) => {
+test('C-2 · each band-row has exactly 6 APCA Lc badges (3 per swatch x 2 swatches)', async ({ page }) => {
const sections = page.locator('section.band-row');
const count = await sections.count();
expect(count).toBe(8);
diff --git a/tests/integration/csv-export.spec.js b/tests/integration/csv-export.spec.js
index 5bd6c4e..430aa1b 100644
--- a/tests/integration/csv-export.spec.js
+++ b/tests/integration/csv-export.spec.js
@@ -2,16 +2,11 @@
* Spec B — CSV export endpoints: /api/export/keep.csv and /api/export/all.csv.
*
* These endpoints are pure I/O — they read reviews.json and return a CSV.
- * No Ollama involved. The pretest reset (npm run reset-state in tests/) wipes
- * reviews.json to {} before the parent test suite runs, so when these specs
- * run the file is either empty ({}) or has state from earlier tests in the
- * same suite run (the existing Ollama specs run before integration/ in the
- * default testDir order).
+ * No Ollama involved. All requests use page.request.fetch() so they work
+ * without a loaded browser page.
*
- * To guarantee a known-empty state for the "header-only body" assertions we
- * reset reviews.json inline before those specific tests using fs.writeFileSync.
- * The other assertions (200, correct Content-Type, header columns) hold
- * regardless of reviews.json contents.
+ * For the "header-only" assertion (B-3) we reset reviews.json to an empty
+ * object inline before the test using fs.writeFileSync.
*
* Column order (from src/review.js lines 475 and 509):
* design_id, title, category, kind, dominant_hex, score_design, score_color,
@@ -22,59 +17,41 @@ const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
-const BASE = 'http://127.0.0.1:9792';
const REVIEWS_FILE = path.join(__dirname, '..', '..', 'data', 'reviews.json');
const EXPECTED_HEADER = 'design_id,title,category,kind,dominant_hex,score_design,score_color,score_style,verdict,why,image_url,pinned_count,pinned_summary,reviewed_at';
-// ── helpers ───────────────────────────────────────────────────────────────────
-
-/** Reset reviews.json to empty object — inline state control. */
+/** Reset reviews.json to empty object — inline state control for B-3. */
function emptyReviews() {
fs.writeFileSync(REVIEWS_FILE, '{}');
}
// ── /api/export/keep.csv ──────────────────────────────────────────────────────
-test('B-1 · keep.csv returns 200 with text/csv content-type', async ({ page }) => {
- await page.goto('about:blank');
-
- const result = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/export/keep.csv');
- return {
- status: res.status,
- contentType: res.headers.get('content-type'),
- disposition: res.headers.get('content-disposition'),
- };
- }, BASE);
-
- expect(result.status).toBe(200);
- expect(result.contentType).toContain('text/csv');
- expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-keep-/);
+test('B-1 · keep.csv returns 200 with text/csv content-type and attachment header', async ({ page }) => {
+ const response = await page.request.fetch('/api/export/keep.csv');
+
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toContain('text/csv');
+ expect(response.headers()['content-disposition']).toMatch(
+ /^attachment;\s*filename="wallco-keep-/
+ );
});
test('B-2 · keep.csv first line is exactly the expected column header', async ({ page }) => {
- await page.goto('about:blank');
-
- const body = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/export/keep.csv');
- return res.text();
- }, BASE);
-
+ const response = await page.request.fetch('/api/export/keep.csv');
+ const body = await response.text();
const firstLine = body.split('\n')[0];
expect(firstLine).toBe(EXPECTED_HEADER);
});
test('B-3 · keep.csv has exactly 1 line (header only) when reviews.json is empty', async ({ page }) => {
emptyReviews();
- await page.goto('about:blank');
- const body = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/export/keep.csv');
- return res.text();
- }, BASE);
+ const response = await page.request.fetch('/api/export/keep.csv');
+ const body = await response.text();
- // The server appends a trailing newline; strip it, then count lines.
+ // The server appends a trailing newline; strip it before counting.
const lines = body.replace(/\n$/, '').split('\n');
expect(lines).toHaveLength(1);
expect(lines[0]).toBe(EXPECTED_HEADER);
@@ -82,48 +59,31 @@ test('B-3 · keep.csv has exactly 1 line (header only) when reviews.json is empt
// ── /api/export/all.csv ───────────────────────────────────────────────────────
-test('B-4 · all.csv returns 200 with text/csv content-type', async ({ page }) => {
- await page.goto('about:blank');
-
- const result = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/export/all.csv');
- return {
- status: res.status,
- contentType: res.headers.get('content-type'),
- disposition: res.headers.get('content-disposition'),
- };
- }, BASE);
-
- expect(result.status).toBe(200);
- expect(result.contentType).toContain('text/csv');
- expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-all-/);
+test('B-4 · all.csv returns 200 with text/csv content-type and attachment header', async ({ page }) => {
+ const response = await page.request.fetch('/api/export/all.csv');
+
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toContain('text/csv');
+ expect(response.headers()['content-disposition']).toMatch(
+ /^attachment;\s*filename="wallco-all-/
+ );
});
test('B-5 · all.csv first line is exactly the expected column header', async ({ page }) => {
- await page.goto('about:blank');
-
- const body = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/export/all.csv');
- return res.text();
- }, BASE);
-
+ const response = await page.request.fetch('/api/export/all.csv');
+ const body = await response.text();
const firstLine = body.split('\n')[0];
expect(firstLine).toBe(EXPECTED_HEADER);
});
test('B-6 · keep.csv and all.csv have identical header rows (regression guard)', async ({ page }) => {
- await page.goto('about:blank');
-
- const [keepBody, allBody] = await page.evaluate(async (base) => {
- const [kr, ar] = await Promise.all([
- fetch(base + '/api/export/keep.csv'),
- fetch(base + '/api/export/all.csv'),
- ]);
- return [await kr.text(), await ar.text()];
- }, BASE);
-
- const keepHeader = keepBody.split('\n')[0];
- const allHeader = allBody.split('\n')[0];
+ const [keepResp, allResp] = await Promise.all([
+ page.request.fetch('/api/export/keep.csv'),
+ page.request.fetch('/api/export/all.csv'),
+ ]);
+
+ const keepHeader = (await keepResp.text()).split('\n')[0];
+ const allHeader = (await allResp.text()).split('\n')[0];
expect(keepHeader).toBe(EXPECTED_HEADER);
expect(allHeader).toBe(EXPECTED_HEADER);
diff --git a/tests/integration/pairings-api.spec.js b/tests/integration/pairings-api.spec.js
index 2c768c5..7ea8fcd 100644
--- a/tests/integration/pairings-api.spec.js
+++ b/tests/integration/pairings-api.spec.js
@@ -1,71 +1,54 @@
/**
* Spec D — Pairings + moodboard + reviews API fast-path tests.
*
- * These test the JSON API layer directly without any Ollama involvement:
- * - /api/pairings/99999 (bogus ID) → 404 { error: "design not found" }
- * - POST /api/moodboard/99999/pin without pin_key → 400
- * - /api/reviews/all → 200 valid JSON object
+ * These test the JSON API layer directly without any Ollama involvement.
+ * All requests use page.request.fetch() which operates outside the browser
+ * sandbox and correctly sends/receives JSON without needing a loaded page.
*
- * All requests are issued via page.evaluate fetch() so that we can inspect
- * status codes and JSON bodies without going through Playwright's
- * page.request helper (which has slightly different error semantics).
+ * - GET /api/pairings/99999 → 404 { error: "design not found" }
+ * - POST /api/moodboard/99999/pin → 400 when pin_key is absent
+ * - GET /api/reviews/all → 200 valid JSON object (never array)
*/
'use strict';
const { test, expect } = require('@playwright/test');
-const BASE = 'http://127.0.0.1:9792';
-
-test.beforeEach(async ({ page }) => {
- // Stable context for fetch calls.
- await page.goto('about:blank');
-});
-
// ── D-1: pairings — bogus ID ──────────────────────────────────────────────────
test('D-1 · GET /api/pairings/99999 returns 404 with {error:"design not found"}', async ({ page }) => {
- const result = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/pairings/99999');
- const body = await res.json();
- return { status: res.status, body };
- }, BASE);
+ const response = await page.request.fetch('/api/pairings/99999');
- expect(result.status).toBe(404);
- expect(result.body).toHaveProperty('error');
- expect(result.body.error).toBe('design not found');
+ expect(response.status()).toBe(404);
+
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(body.error).toBe('design not found');
});
// ── D-2: moodboard pin — missing pin_key ─────────────────────────────────────
test('D-2 · POST /api/moodboard/99999/pin without pin_key returns 400', async ({ page }) => {
- const result = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/moodboard/99999/pin', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}), // intentionally omit pin_key
- });
- const body = await res.json();
- return { status: res.status, body };
- }, BASE);
+ const response = await page.request.fetch('/api/moodboard/99999/pin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ data: '{}', // intentionally omit pin_key
+ });
+
+ expect(response.status()).toBe(400);
- expect(result.status).toBe(400);
- expect(result.body).toHaveProperty('error');
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
});
// ── D-3: reviews/all — valid JSON object ──────────────────────────────────────
test('D-3 · GET /api/reviews/all returns 200 with a valid JSON object', async ({ page }) => {
- const result = await page.evaluate(async (base) => {
- const res = await fetch(base + '/api/reviews/all');
- const body = await res.json();
- return {
- status: res.status,
- bodyType: typeof body,
- isArray: Array.isArray(body),
- };
- }, BASE);
+ const response = await page.request.fetch('/api/reviews/all');
+
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toContain('application/json');
- expect(result.status).toBe(200);
+ const body = await response.json();
// reviews/all returns an object keyed by design ID, never an array.
- expect(result.bodyType).toBe('object');
- expect(result.isArray).toBe(false);
+ expect(typeof body).toBe('object');
+ expect(Array.isArray(body)).toBe(false);
});
← 3a43f9b 404 v2 (debate consensus): numeral tightening + strip pill +
·
back to Wallco Ai
·
gamify: user votes + head-to-head polls + Elo + 5-variation 5bdabe9 →