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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:27:41 +00:00
parent 9a41641eaa
commit 1aef6ba421
2 changed files with 64 additions and 12 deletions

View File

@@ -60,8 +60,9 @@ module.exports = defineConfig({
// production's Tempo and never opens production's database. // production's Tempo and never opens production's database.
OTEL_EXPORTER_OTLP_ENDPOINT: '', OTEL_EXPORTER_OTLP_ENDPOINT: '',
IPX_DATABASE_URL: '', IPX_DATABASE_URL: '',
// Nor spends the server's TypeSafe key: fixture feeds went to TypeSafe to be categorised. // The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
TYPESAFE_KEY: '', // 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', IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
}, },
}, },

View File

@@ -1,6 +1,38 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { TOKEN } = require('./global-setup'); 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. // The token sets a cookie, so every test starts by presenting it once.
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto(`/?token=${TOKEN}`); 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. // #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); 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(); 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 } }); 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 } }); 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(); const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
await page.reload(); 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}`); 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. // #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.request.patch('/api/me', { data: { typesafe_key: null } });
await page.reload(); 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'); await page.keyboard.press('Escape');
}); });
test('the server pays for Signals only when it has a key to pay with', async ({ page }) => { signalsTest('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(); // #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay;
expect(settings.typesafe_server_key).toBe(false); // 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 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'); try {
expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own');
await page.goto('/admin#server'); await page.goto('/admin#server');
await expect(page.locator('#gsig')).toBeDisabled(); 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 } }); await page.request.patch('/api/settings', { data: { signals_on_server: false } });
}
}); });