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 } }); + } });