feat(accessibility): harden chat and keyboard interactions
Stream capability-authorized chat reads with cancellation, virtualize viewer rendering, centralize modal focus management, and add keyboard-accessible queue and VOD menus. Expose command palette combobox state and keep the document language synchronized with the selected locale.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { readChatFile } from './chat-reader';
|
||||
|
||||
describe('chat reader', () => {
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('streams a large live chat without retaining more than the configured message limit', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||
directories.push(directory);
|
||||
const filePath = join(directory, 'stream.chat.jsonl');
|
||||
const lines = Array.from({ length: 4_000 }, (_, index) => JSON.stringify({ type: 'msg', u: `viewer-${index}`, msg: `message-${index}` }));
|
||||
writeFileSync(filePath, lines.join('\n'), 'utf8');
|
||||
|
||||
let yielded = false;
|
||||
const resultPromise = readChatFile(filePath, { maxMessages: 120, yieldEveryChunks: 1 });
|
||||
void new Promise<void>((resolve) => setImmediate(() => {
|
||||
yielded = true;
|
||||
resolve();
|
||||
}));
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(yielded).toBe(true);
|
||||
expect(result).toMatchObject({ success: true, format: 'live', total: 4_000, truncated: true });
|
||||
if (!result.success) throw new Error(result.error || 'Chat reader failed');
|
||||
expect(result.messages).toHaveLength(120);
|
||||
expect(result.messages?.[0]).toMatchObject({ u: 'viewer-0', msg: 'message-0' });
|
||||
expect(result.messages?.[119]).toMatchObject({ u: 'viewer-119', msg: 'message-119' });
|
||||
});
|
||||
|
||||
it('stops an active stream when the caller cancels the read', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||
directories.push(directory);
|
||||
const filePath = join(directory, 'stream.chat.jsonl');
|
||||
writeFileSync(filePath, Array.from({ length: 30_000 }, (_, index) => JSON.stringify({ type: 'msg', msg: String(index) })).join('\n'), 'utf8');
|
||||
const controller = new AbortController();
|
||||
|
||||
const reading = readChatFile(filePath, { signal: controller.signal, yieldEveryChunks: 1 });
|
||||
controller.abort();
|
||||
|
||||
await expect(reading).resolves.toEqual({ success: false, cancelled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
export interface ChatReadSuccess {
|
||||
success: true;
|
||||
format: 'replay' | 'live';
|
||||
messages: Array<Record<string, unknown>>;
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ChatReadFailure {
|
||||
success: false;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export type ChatReadResult = ChatReadSuccess | ChatReadFailure;
|
||||
|
||||
export interface ChatReadOptions {
|
||||
maxMessages?: number;
|
||||
signal?: AbortSignal;
|
||||
yieldEveryChunks?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 50_000;
|
||||
const MAX_BUFFERED_ENTRY_CHARS = 512 * 1024;
|
||||
|
||||
function normalizeMaxMessages(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value < 1) return DEFAULT_MAX_MESSAGES;
|
||||
return Math.min(value, DEFAULT_MAX_MESSAGES);
|
||||
}
|
||||
|
||||
function normalizeYieldEveryChunks(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value < 1) return 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function yieldToEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function addMessage(messages: Array<Record<string, unknown>>, candidate: unknown, maxMessages: number): void {
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return;
|
||||
if ((candidate as { type?: unknown }).type === 'header') return;
|
||||
if (messages.length < maxMessages) messages.push(candidate as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function findJsonValueEnd(input: string): number | null {
|
||||
const first = input[0];
|
||||
if (!first) return null;
|
||||
if (first === '"') {
|
||||
let escaped = false;
|
||||
for (let index = 1; index < input.length; index++) {
|
||||
const char = input[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') return index + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (first !== '{' && first !== '[') {
|
||||
const separator = input.search(/[\],]/);
|
||||
return separator < 0 ? null : separator;
|
||||
}
|
||||
const closing = first === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === first) depth++;
|
||||
if (char === closing) {
|
||||
depth--;
|
||||
if (depth === 0) return index + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readLiveChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||
const abort = () => stream.destroy();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
let total = 0;
|
||||
let chunkCount = 0;
|
||||
let carry = '';
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
carry += chunk;
|
||||
if (carry.length > MAX_BUFFERED_ENTRY_CHARS && !carry.includes('\n')) throw new Error('Chat line exceeds read limit');
|
||||
let newlineIndex = carry.indexOf('\n');
|
||||
while (newlineIndex >= 0) {
|
||||
const line = carry.slice(0, newlineIndex).trim();
|
||||
carry = carry.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
try {
|
||||
const candidate = JSON.parse(line);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
newlineIndex = carry.indexOf('\n');
|
||||
}
|
||||
chunkCount++;
|
||||
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||
}
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
const finalLine = carry.trim();
|
||||
if (finalLine) {
|
||||
try {
|
||||
const candidate = JSON.parse(finalLine);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return { success: true, format: 'live', messages, truncated: total > messages.length, total };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
return { success: false, error: String(error) };
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function readReplayChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||
const abort = () => stream.destroy();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
let total = 0;
|
||||
let chunkCount = 0;
|
||||
let beforeMessages = '';
|
||||
let entries = '';
|
||||
let foundMessages = false;
|
||||
let closed = false;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
if (!foundMessages) {
|
||||
beforeMessages += chunk;
|
||||
const match = /"messages"\s*:\s*\[/.exec(beforeMessages);
|
||||
if (!match || match.index === undefined) {
|
||||
beforeMessages = beforeMessages.slice(-128);
|
||||
continue;
|
||||
}
|
||||
foundMessages = true;
|
||||
entries = beforeMessages.slice(match.index + match[0].length);
|
||||
beforeMessages = '';
|
||||
} else {
|
||||
entries += chunk;
|
||||
}
|
||||
if (entries.length > MAX_BUFFERED_ENTRY_CHARS) throw new Error('Chat entry exceeds read limit');
|
||||
while (entries) {
|
||||
const leading = /^\s*,?\s*/.exec(entries)?.[0] ?? '';
|
||||
entries = entries.slice(leading.length);
|
||||
if (!entries) break;
|
||||
if (entries[0] === ']') {
|
||||
closed = true;
|
||||
entries = '';
|
||||
break;
|
||||
}
|
||||
const valueEnd = findJsonValueEnd(entries);
|
||||
if (valueEnd === null) break;
|
||||
const value = entries.slice(0, valueEnd);
|
||||
entries = entries.slice(valueEnd);
|
||||
try {
|
||||
const candidate = JSON.parse(value);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
chunkCount++;
|
||||
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||
if (closed) break;
|
||||
}
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
if (!foundMessages || !closed) return { success: false, error: 'Unsupported chat file format' };
|
||||
return { success: true, format: 'replay', messages, truncated: total > messages.length, total };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
return { success: false, error: String(error) };
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChatFile(filePath: string, options: ChatReadOptions = {}): Promise<ChatReadResult> {
|
||||
if (options.signal?.aborted) return { success: false, cancelled: true };
|
||||
const maxMessages = normalizeMaxMessages(options.maxMessages);
|
||||
const yieldEveryChunks = normalizeYieldEveryChunks(options.yieldEveryChunks);
|
||||
return filePath.toLowerCase().endsWith('.jsonl')
|
||||
? readLiveChat(filePath, maxMessages, options.signal, yieldEveryChunks)
|
||||
: readReplayChat(filePath, maxMessages, options.signal, yieldEveryChunks);
|
||||
}
|
||||
Reference in New Issue
Block a user