mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
More e2e tests
This commit is contained in:
@@ -163,6 +163,22 @@ export function sendChannelMessage(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Read state ---
|
||||
|
||||
export interface UnreadCounts {
|
||||
counts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
last_message_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export function getUnreads(): Promise<UnreadCounts> {
|
||||
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 {
|
||||
|
||||
156
tests/e2e/helpers/seed.ts
Normal file
156
tests/e2e/helpers/seed.ts
Normal file
@@ -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 };
|
||||
}
|
||||
36
tests/e2e/specs/mark-all-read.spec.ts
Normal file
36
tests/e2e/specs/mark-all-read.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
49
tests/e2e/specs/pagination.spec.ts
Normal file
49
tests/e2e/specs/pagination.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user