/** * Anti-regression: PLUGIN.md must carry the "Time presentation" doctrine * paragraph and enumerate every Loop tool whose response contains a * datetime field. The doctrine is the agent's only signal that Loop * returns bare UTC strings without a zone marker — silent drop of this * section recurs the off-by-one-hour viewing-time failure (Task 126). * * The expected tool list is derived live from the vendored swagger * snapshot, so if Loop adds a new datetime-bearing endpoint and we miss * mapping it in PLUGIN.md, the test fails and names the orphan. */ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_MD = resolve(__dirname, '../../../PLUGIN.md'); const SWAGGER_PATH = resolve(__dirname, 'loop-swagger.snapshot.json'); interface SwaggerDoc { paths: Record }> }>>; components?: { schemas?: Record }; } const swagger = JSON.parse(readFileSync(SWAGGER_PATH, 'utf-8')) as SwaggerDoc; const plugin = readFileSync(PLUGIN_MD, 'utf-8'); // Swagger path → tool name. Every response-bearing GET path that surfaces // a datetime field must map to exactly one tool here. Add a row when Loop // publishes a new datetime endpoint that we expose. const PATH_TO_TOOL: Record = { '/residential/sales/viewings': 'loop-viewing-search', '/residential/lettings/viewings': 'loop-viewing-search', '/residential/sales/viewings/{id}': 'loop-viewing-detail', '/residential/lettings/viewings/{id}': 'loop-viewing-detail', '/feedback/residential/sales/viewings/{id}': 'loop-feedback-get', '/feedback/residential/lettings/viewings/{id}': 'loop-feedback-get', '/people/renters': 'loop-people-search', '/people/{id}': 'loop-people-detail', '/people/buyers/{id}': 'loop-people-detail', '/people/sellers/{id}': 'loop-people-detail', '/people/landlords/{id}': 'loop-people-detail', '/people/renters/{id}': 'loop-people-detail', '/property/residential/sales/{id}': 'loop-property-detail', '/property/residential/lettings/{id}': 'loop-property-detail', '/property/residential/sales': 'loop-property-search', '/property/residential/lettings': 'loop-property-search', '/property/residential/sales/listed/{channel}': 'loop-property-listed', '/property/residential/lettings/listed/{channel}': 'loop-property-listed', '/property/residential/sales/{id}/preview/{previewHash}': 'loop-property-detail', '/property/residential/lettings/{id}/preview/{previewHash}': 'loop-property-detail', '/property/residential/sold/{channel}': 'loop-property-sold', '/marketing/matching/{id}': 'loop-marketing-match', '/marketing/rentals/matching/{id}': 'loop-marketing-match', '/marketing/enquiries/auto-responder/{id}/{key}': 'loop-auto-responder', '/team/{agentId}/availability/{searchDate}': 'loop-team-availability', '/supplier/board-contractor/{code}/{jobid}/job-list': 'loop-supplier', '/supplier/maintenance/{quoteCode}/{jobid}/job-list': 'loop-supplier', '/supplier/maintenance/{quoteCode}/{jobId}/quote-list': 'loop-supplier', }; function resolveRef(ref: string): unknown { const parts = ref.replace('#/', '').split('/'); let cur: unknown = swagger; for (const p of parts) cur = cur && typeof cur === 'object' ? (cur as Record)[p] : null; return cur; } function hasDateField(node: unknown, depth = 0, visited = new WeakSet()): boolean { if (!node || typeof node !== 'object' || depth > 12) return false; if (visited.has(node as object)) return false; visited.add(node as object); const n = node as Record; if (typeof n.$ref === 'string') return hasDateField(resolveRef(n.$ref), depth + 1, visited); if (n.format === 'date-time' || n.format === 'date') return true; if (n.properties && typeof n.properties === 'object') { for (const v of Object.values(n.properties as Record)) { if (hasDateField(v, depth + 1, visited)) return true; } } if (n.items && hasDateField(n.items, depth + 1, visited)) return true; if (Array.isArray(n.allOf)) { for (const v of n.allOf) if (hasDateField(v, depth + 1, visited)) return true; } return false; } function toolsRequiringDoctrine(): Set { const tools = new Set(); for (const [path, methods] of Object.entries(swagger.paths)) { const op = methods.get; if (!op || !op.responses) continue; const anyDate = Object.values(op.responses).some(r => Object.values(r.content || {}).some(sw => hasDateField(sw.schema)), ); if (!anyDate) continue; const tool = PATH_TO_TOOL[path]; if (tool) tools.add(tool); else throw new Error(`Swagger GET ${path} returns a datetime but has no PATH_TO_TOOL mapping — add it and list the tool in PLUGIN.md "Time presentation".`); } return tools; } describe('PLUGIN.md time-presentation doctrine', () => { it('contains the Time presentation section', () => { expect(plugin).toMatch(/^## Time presentation$/m); }); it('names UTC, Person.timezone, and the Europe/London default', () => { expect(plugin).toMatch(/UTC/); expect(plugin).toMatch(/Person\.timezone/); expect(plugin).toMatch(/Europe\/London/); }); it('forbids raw UTC presentation', () => { // Must state conversion is required before any datetime appears in output. expect(plugin).toMatch(/Convert before any datetime appears in agent output/); }); it('enumerates every tool whose Loop response carries a datetime field', () => { const required = toolsRequiringDoctrine(); const missing: string[] = []; for (const tool of required) { // Each tool must appear in the doctrine section as a bullet item. const re = new RegExp(`^- \`${tool.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\``, 'm'); if (!re.test(plugin)) missing.push(tool); } expect(missing, `PLUGIN.md "Time presentation" must list: ${missing.join(', ')}`).toEqual([]); }); });