message-feedback-protocol.snapshot.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { readFile } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  5. import {
  6. assertFixtureInventory,
  7. compareOrRefreshGolden,
  8. fixtureIdentity,
  9. launchWebScaffold,
  10. seedSession,
  11. type WebScaffold,
  12. } from './scaffold.ts'
  13. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol', import.meta.url))
  14. const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
  15. const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
  16. const SESSION_ID = 'message-feedback-protocol'
  17. const MESSAGE_ID = fixtureIdentity('message', 2)
  18. interface ProtocolExchange {
  19. readonly endpoint: string
  20. readonly request: unknown
  21. readonly status: number
  22. readonly response: unknown
  23. }
  24. function isRecord(value: unknown): value is Record<string, unknown> {
  25. return typeof value === 'object' && value !== null
  26. }
  27. /** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
  28. function createdVersion(response: unknown): string {
  29. if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
  30. || !isRecord(response.result.value) || response.result.value.ok !== true
  31. || !isRecord(response.result.value.value)
  32. || typeof response.result.value.value.version !== 'string') {
  33. throw new Error('messageFeedback.put did not return a successful versioned item')
  34. }
  35. return response.result.value.value.version
  36. }
  37. /** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
  38. function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
  39. return JSON.stringify(exchanges, (key, value: unknown) => {
  40. if (key === 'messageId' && value === MESSAGE_ID) return '{{message:2}}'
  41. if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
  42. if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
  43. return value
  44. }, 2)
  45. }
  46. describe('message feedback Host Remote protocol', () => {
  47. let scaffold: WebScaffold
  48. beforeAll(async () => {
  49. scaffold = await launchWebScaffold()
  50. await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
  51. })
  52. afterAll(async () => {
  53. await scaffold?.close()
  54. })
  55. it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
  56. const exchanges: ProtocolExchange[] = []
  57. const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
  58. const payload = { args: { request } }
  59. const response = await scaffold.hostFetch(`/api/${endpoint}`, {
  60. method: 'POST',
  61. headers: { 'content-type': 'application/json' },
  62. body: JSON.stringify({
  63. type: 'client-request',
  64. rpcId,
  65. method: endpoint,
  66. payload,
  67. }),
  68. })
  69. const body: unknown = await response.json()
  70. exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
  71. return body
  72. }
  73. await invoke('feedback-invalid', 'messageFeedback/put', {
  74. sessionId: SESSION_ID,
  75. messageId: MESSAGE_ID,
  76. rating: 'invalid-rating',
  77. ifVersion: null,
  78. })
  79. await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
  80. const created = await invoke('feedback-put', 'messageFeedback/put', {
  81. sessionId: SESSION_ID,
  82. messageId: MESSAGE_ID,
  83. rating: 'positive',
  84. note: 'Useful answer',
  85. ifVersion: null,
  86. })
  87. const version = createdVersion(created)
  88. 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}$/)
  89. await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
  90. await invoke('feedback-conflict', 'messageFeedback/put', {
  91. sessionId: SESSION_ID,
  92. messageId: MESSAGE_ID,
  93. rating: 'negative',
  94. ifVersion: null,
  95. })
  96. await invoke('feedback-delete', 'messageFeedback/delete', {
  97. sessionId: SESSION_ID,
  98. messageId: MESSAGE_ID,
  99. ifVersion: version,
  100. })
  101. await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
  102. expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
  103. await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
  104. await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.v2.jsonl'])
  105. })
  106. })