import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, type WebScaffold, } from './scaffold.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url)) const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') const SESSION_ID = 'message-feedback-protocol' const MESSAGE_ID = '11111111-1111-4111-8111-111111111111' interface ProtocolExchange { readonly endpoint: string readonly request: unknown readonly status: number readonly response: unknown } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } /** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */ function createdVersion(response: unknown): string { if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true || !isRecord(response.result.value) || response.result.value.ok !== true || !isRecord(response.result.value.value) || typeof response.result.value.value.version !== 'string') { throw new Error('messageFeedback.put did not return a successful versioned item') } return response.result.value.value.version } /** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */ function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string { return JSON.stringify(exchanges, (key, value: unknown) => { if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}' if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}' return value }, 2) } describe('message feedback Host Remote protocol', () => { let scaffold: WebScaffold beforeAll(async () => { scaffold = await launchWebScaffold() await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID) }) afterAll(async () => { await scaffold?.close() }) it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => { const exchanges: ProtocolExchange[] = [] const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { const payload = { args: { request } } const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload, }), }) const body: unknown = await response.json() exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body }) return body } await invoke('feedback-invalid', 'messageFeedback/put', { sessionId: SESSION_ID, messageId: MESSAGE_ID, rating: 'invalid-rating', ifVersion: null, }) await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID }) const created = await invoke('feedback-put', 'messageFeedback/put', { sessionId: SESSION_ID, messageId: MESSAGE_ID, rating: 'positive', note: 'Useful answer', ifVersion: null, }) const version = createdVersion(created) expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID }) await invoke('feedback-conflict', 'messageFeedback/put', { sessionId: SESSION_ID, messageId: MESSAGE_ID, rating: 'negative', ifVersion: null, }) await invoke('feedback-delete', 'messageFeedback/delete', { sessionId: SESSION_ID, messageId: MESSAGE_ID, ifVersion: version, }) await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID }) expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl']) }) })