From 1aef6ba42138a53a6b95e9af3fbdf4f4759fd211 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 17 Oct 2026 13:27:41 +0000 Subject: [PATCH] Browser suite: the server's TypeSafe key is the shell's, and Signals' tests run only when Signals change (#271) /src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it and the server paying for Signals is tested end to end. Signals' tests spend it, so each runs only when the Signals code or the test itself has changed since it last passed (a hash kept under target/signals-tests); IPX_SIGNALS_TESTS=1 runs them anyway. Co-Authored-By: Claude Opus 5.5 --- playwright.config.js | 5 ++-- tests/ui/app.spec.js | 71 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/playwright.config.js b/playwright.config.js index fc1de5b..1549170 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -60,8 +60,9 @@ module.exports = defineConfig({ // production's Tempo and never opens production's database. OTEL_EXPORTER_OTLP_ENDPOINT: '', IPX_DATABASE_URL: '', - // Nor spends the server's TypeSafe key: fixture feeds went to TypeSafe to be categorised. - TYPESAFE_KEY: '', + // The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this. + // Without one, the tests of the server paying for Signals see that it cannot. + TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '', IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev', }, }, diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index caaf3f4..044c4a2 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1,6 +1,38 @@ const { test, expect } = require('@playwright/test'); const { TOKEN } = require('./global-setup'); +// Signals' tests run only when the Signals code has changed since each last passed: they spend the +// TypeSafe key (#271). The Signals code is signals.rs and signals.ts whole, and the lines naming +// Signals, TypeSafe or Jev anywhere else in the daemon and the page; with the test's own source, +// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway. +const fs = require('node:fs'); +const path = require('node:path'); +const ROOT = path.join(__dirname, '../..'); +const SIGNALS_CODE = (() => { + const h = require('node:crypto').createHash('sha256'); + for (const dir of ['crates/ipx/src', 'web/src']) { + for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) { + const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8'); + h.update(f + '\n' + (/^signals\./.test(f) ? text : text.split('\n').filter(l => /signal|typesafe|jev/i.test(l)).join('\n'))); + } + } + return h.digest('hex'); +})(); +function signalsTest(name, fn) { + const stamp = path.join(ROOT, 'target/signals-tests', + require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16)); + const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex'); + // Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone. + test(name, async ({ page }) => { + let had = ''; + try { had = fs.readFileSync(stamp, 'utf8'); } catch {} + test.skip(had === want && !process.env.IPX_SIGNALS_TESTS, 'the Signals code is as it was when this last passed'); + await fn({ page }); + fs.mkdirSync(path.dirname(stamp), { recursive: true }); + fs.writeFileSync(stamp, want); + }); +} + // The token sets a cookie, so every test starts by presenting it once. test.beforeEach(async ({ page }) => { await page.goto(`/?token=${TOKEN}`); @@ -2074,7 +2106,7 @@ test('the page comes compressed, and the compressed copies cannot be asked for b } }); -test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => { +signalsTest('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => { // #254. Jev is the fixture server's stand-in; the show is added here and removed at the end. expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204); const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json(); @@ -2117,7 +2149,7 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re await page.request.patch('/api/me', { data: { typesafe_key: null } }); }); -test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => { +signalsTest('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => { await page.request.patch('/api/me', { data: { typesafe_key: null } }); const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json(); await page.reload(); @@ -2129,7 +2161,7 @@ test('a Signal with no key to judge it links to where one is got and where it go await page.request.delete(`/api/signals/${id}`); }); -test('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => { +signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => { // #271. The test daemon has no key of its own, so each person brings theirs. await page.request.patch('/api/me', { data: { typesafe_key: null } }); await page.reload(); @@ -2152,12 +2184,31 @@ test('Signals are turned on in Settings with a key TypeSafe takes, and off again await page.keyboard.press('Escape'); }); -test('the server pays for Signals only when it has a key to pay with', async ({ page }) => { - const settings = await (await page.request.get('/api/settings')).json(); - expect(settings.typesafe_server_key).toBe(false); +signalsTest('the server pays for Signals only when it has a key to pay with', async ({ page }) => { + // #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay; + // Jev itself is still the fixture server's stand-in. + const { typesafe_server_key: has } = await (await page.request.get('/api/settings')).json(); + await page.request.patch('/api/me', { data: { typesafe_key: null } }); expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204); - expect((await (await page.request.get('/api/me')).json()).signals).toBe('own'); - await page.goto('/admin#server'); - await expect(page.locator('#gsig')).toBeDisabled(); - await page.request.patch('/api/settings', { data: { signals_on_server: false } }); + try { + expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own'); + await page.goto('/admin#server'); + if (!has) { await expect(page.locator('#gsig')).toBeDisabled(); return; } + await expect(page.locator('#gsig')).toBeChecked(); + + // No key of your own, and Signals are there, judged on the server's. + await page.goto('/'); + await expect(page.locator('#feedlist .sigs')).toHaveCount(1); + await page.evaluate(() => prefsModal()); + await expect(page.locator('#stskey')).toHaveCount(0); + await page.keyboard.press('Escape'); + const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json(); + const { id } = await (await page.request.post('/api/signals', { data: { name: 'Bread', keywords: ['baking'] } })).json(); + const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json()).entries.map(e => e.title); + await expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']); + await page.request.delete(`/api/signals/${id}`); + await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`); + } finally { + await page.request.patch('/api/settings', { data: { signals_on_server: false } }); + } });