From 566181faeda2bcd70937e6c67ca6ca6e57d8ebb8 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 24 Feb 2026 22:33:28 -0800 Subject: [PATCH] More e2e tests --- tests/e2e/helpers/api.ts | 16 +++ tests/e2e/helpers/seed.ts | 156 ++++++++++++++++++++++++++ tests/e2e/specs/mark-all-read.spec.ts | 36 ++++++ tests/e2e/specs/pagination.spec.ts | 49 ++++++++ 4 files changed, 257 insertions(+) create mode 100644 tests/e2e/helpers/seed.ts create mode 100644 tests/e2e/specs/mark-all-read.spec.ts create mode 100644 tests/e2e/specs/pagination.spec.ts diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 54a3841..14840ba 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -163,6 +163,22 @@ export function sendChannelMessage( }); } +// --- Read state --- + +export interface UnreadCounts { + counts: Record; + mentions: Record; + last_message_times: Record; +} + +export function getUnreads(): Promise { + return fetchJson('/read-state/unreads'); +} + +export function markAllRead(): Promise<{ status: string; timestamp: number }> { + return fetchJson('/read-state/mark-all-read', { method: 'POST' }); +} + // --- Settings --- export interface BotConfig { diff --git a/tests/e2e/helpers/seed.ts b/tests/e2e/helpers/seed.ts new file mode 100644 index 0000000..43a3285 --- /dev/null +++ b/tests/e2e/helpers/seed.ts @@ -0,0 +1,156 @@ +import { execSync } from 'child_process'; +import path from 'path'; +import crypto from 'crypto'; + +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const DEFAULT_E2E_DB = path.join(ROOT, 'tests', 'e2e', '.tmp', 'e2e-test.db'); +const DB_PATH = process.env.MESHCORE_DATABASE_PATH ?? DEFAULT_E2E_DB; + +interface SeedOptions { + channelName: string; + count: number; + startTimestamp?: number; + outgoingEvery?: number; // mark every Nth message as outgoing + includePaths?: boolean; +} + +interface SeedReadStateOptions { + channelName: string; + unreadCount: number; +} + +function runPython(payload: object) { + const b64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); + +const script = String.raw`python3 - <<'PY' +import base64, json, os, sqlite3, time + +payload = json.loads(base64.b64decode(os.environ['PAYLOAD']).decode()) +root = payload['root'] +db_path = payload.get('db_path') or os.path.join(root, 'data', 'meshcore.db') +os.makedirs(os.path.dirname(db_path), exist_ok=True) +conn = sqlite3.connect(db_path) +conn.execute('PRAGMA journal_mode=WAL;') +conn.row_factory = sqlite3.Row + + +def upsert_channel(name: str, key_hex: str): + conn.execute( + """ + INSERT INTO channels (key, name, is_hashtag, on_radio) + VALUES (?, ?, 1, 0) + ON CONFLICT(key) DO UPDATE SET name=excluded.name + """, + (key_hex, name), + ) + conn.commit() + + +def clear_channel_messages(key_hex: str): + conn.execute("DELETE FROM messages WHERE conversation_key = ?", (key_hex,)) + conn.commit() + + +def seed_messages(key_hex: str, opts: dict): + start_ts = int(opts.get('start_ts') or time.time()) + count = opts['count'] + outgoing_every = opts.get('out_every') or 0 + include_paths = bool(opts.get('paths')) + for i in range(count): + ts = start_ts + i + text = f"seed-{i}" + paths_json = None + if include_paths and i % 5 == 0: + paths_json = json.dumps([{"path": f"{i:02x}", "received_at": ts}]) + outgoing = 1 if (outgoing_every and (i % outgoing_every == 0)) else 0 + conn.execute( + """ + INSERT INTO messages (type, conversation_key, text, sender_timestamp, received_at, paths, txt_type, signature, outgoing, acked) + VALUES ('CHAN', ?, ?, ?, ?, ?, 0, NULL, ?, 0) + """, + (key_hex, text, ts, ts, paths_json, outgoing), + ) + conn.commit() + + +def set_channel_last_read(key_hex: str, last_read: int | None): + conn.execute("UPDATE channels SET last_read_at = ? WHERE key = ?", (last_read, key_hex)) + conn.commit() + + +if payload['action'] == 'seed_channel': + name = payload['name'] + key_hex = payload['key_hex'] + upsert_channel(name, key_hex) + clear_channel_messages(key_hex) + seed_messages(key_hex, payload['opts']) +elif payload['action'] == 'seed_unread': + name = payload['name'] + key_hex = payload['key_hex'] + upsert_channel(name, key_hex) + clear_channel_messages(key_hex) + # create unread messages + now = int(time.time()) + for i in range(payload['unread']): + ts = now - i + text = f"unread-{i}" + conn.execute( + """ + INSERT INTO messages (type, conversation_key, text, sender_timestamp, received_at, paths, txt_type, signature, outgoing, acked) + VALUES ('CHAN', ?, ?, ?, ?, NULL, 0, NULL, 0, 0) + """, + (key_hex, text, ts, ts), + ) + set_channel_last_read(key_hex, now - 10_000) # ensure unread +else: + raise SystemExit('unknown action') + +conn.close() +PY`; + + execSync(script, { + env: { ...process.env, PAYLOAD: b64 }, + stdio: 'inherit', + }); +} + +function channelKeyFromName(name: string): string { + // Matches backend: SHA256("#name").digest()[:16] + const hash = crypto.createHash('sha256').update(name).digest('hex'); + return hash.slice(0, 32).toUpperCase(); +} + +export function seedChannelMessages(options: SeedOptions) { + const keyHex = channelKeyFromName( + options.channelName.startsWith('#') ? options.channelName : `#${options.channelName}` + ); + runPython({ + action: 'seed_channel', + root: ROOT, + db_path: DB_PATH, + name: options.channelName, + key_hex: keyHex, + opts: { + count: options.count, + start_ts: options.startTimestamp ?? Math.floor(Date.now() / 1000) - options.count, + out_every: options.outgoingEvery ?? 0, + paths: options.includePaths ?? false, + }, + }); + return { key: keyHex }; +} + +export function seedChannelUnread(options: SeedReadStateOptions) { + const keyHex = channelKeyFromName( + options.channelName.startsWith('#') ? options.channelName : `#${options.channelName}` + ); + runPython({ + action: 'seed_unread', + root: ROOT, + db_path: DB_PATH, + name: options.channelName, + key_hex: keyHex, + unread: options.unreadCount, + }); + return { key: keyHex }; +} diff --git a/tests/e2e/specs/mark-all-read.spec.ts b/tests/e2e/specs/mark-all-read.spec.ts new file mode 100644 index 0000000..aebc1a4 --- /dev/null +++ b/tests/e2e/specs/mark-all-read.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; +import { getUnreads } from '../helpers/api'; +import { seedChannelUnread } from '../helpers/seed'; + +const CHANNEL_NAME = '#markread-e2e'; + +test.describe('Mark all as read', () => { + test('clears server and UI unread state', async ({ page }) => { + // Seed a couple of unread channel messages + seedChannelUnread({ channelName: CHANNEL_NAME, unreadCount: 2 }); + + // Sanity: server reports unreads + const before = await getUnreads(); + expect(Object.values(before.counts).some((c) => c > 0)).toBeTruthy(); + + await page.goto('/'); + await expect(page.getByText(CHANNEL_NAME, { exact: true })).toBeVisible({ timeout: 15_000 }); + + // Sidebar should show the mark-all control + const markAll = page.getByText('Mark all as read'); + await expect(markAll).toBeVisible(); + + await markAll.click(); + + // Server unreads should now be empty + await expect(async () => { + const after = await getUnreads(); + expect(Object.keys(after.counts).length).toBe(0); + expect(Object.keys(after.mentions).length).toBe(0); + }).toPass({ timeout: 10_000, intervals: [1_000] }); + + // Reload to ensure persistence + await page.reload(); + await expect(page.getByText('Mark all as read')).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/pagination.spec.ts b/tests/e2e/specs/pagination.spec.ts new file mode 100644 index 0000000..fb6cf69 --- /dev/null +++ b/tests/e2e/specs/pagination.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { seedChannelMessages } from '../helpers/seed'; + +const CHANNEL_NAME = '#pagination-e2e'; + +test.describe('Message pagination ordering/dedup', () => { + test('loads older pages without duplicates or ordering issues', async ({ page }) => { + // Seed 250 messages; latest has highest index + const seeded = seedChannelMessages({ + channelName: CHANNEL_NAME, + count: 250, + startTimestamp: Math.floor(Date.now() / 1000) - 260, + outgoingEvery: 10, + includePaths: true, + }); + + // Directly open the channel via URL hash to avoid sidebar filtering + await page.goto(`/#channel/${seeded.key}/${CHANNEL_NAME.replace('#', '')}`); + await expect(page.getByPlaceholder(/message/i)).toBeVisible({ timeout: 15_000 }); + + // Latest message should be visible + await expect(page.getByText('seed-249', { exact: true })).toBeVisible({ timeout: 15_000 }); + // Oldest message should not be in the initial page (limit 200) + await expect(page.getByText('seed-0', { exact: true })).toHaveCount(0); + + const list = page.locator('div.h-full.overflow-y-auto').first(); + + // Scroll to top to trigger older fetch + await list.evaluate((el) => { + el.scrollTop = 0; + }); + + // Wait for oldest message to appear after pagination + await expect(page.getByText('seed-0')).toBeVisible({ timeout: 15_000 }); + + // Spot-check ordering: seed-249 appears above seed-200; seed-50 above seed-10 + // Fetch from API to validate ordering and dedup + const texts = await page.evaluate(async (key) => { + const res = await fetch(`/api/messages?type=CHAN&conversation_key=${key}&limit=300`); + const data = await res.json(); + return data.map((m: any) => m.text); + }, seeded.key); + + expect(texts.length).toBeGreaterThanOrEqual(250); + expect(texts[0]).toContain('seed-249'); + expect(texts[texts.length - 1]).toContain('seed-0'); + expect(new Set(texts).size).toBe(texts.length); + }); +});