From e2375bd699c06c921162e706bc82b2b5e425e18b Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 4 Sep 2026 12:55:51 -0600 Subject: [PATCH] test(feedback): pin the telltale wire contract, consumer side telltale declares this payload in src/read.ts as TelltaleIssueDTO / IssuesResponse; this repo declares it again in feedback.ts as TelltaleIssue / FeedbackIssuesResponse. They agree today, field for field. Nothing made them keep agreeing - either side could add, rename or drop a field with both suites green and the wire broken. That is finding 2 of the 2026-08-31 integration audit. The contract JSON is vendored byte-for-byte from NEXUS docs/contracts/, where the mechanism is documented; telltale vendors the same file and pins the same hash. Moving the contract for one side leaves the OTHER side's constant stale and red, which is what forces a wire change to be read by both. Two independent checks, deliberately not one: - runtime: the contract's canonical SHA-256 still matches, and TelltaleIssue's key set equals the contract's exactly - an added field fails as loudly as a removed one. - compile time: Record makes tsc fail if the interface gains or loses a field, so `npm run check` catches drift even before the suite runs. Hashing is over the canonical JSON, not raw bytes, so a CRLF checkout does not produce a spurious failure. Both halves were proved to fail before being kept, per the rule that a verification which cannot fail is not verification: contract gains a `severity` field -> 2 of 5 tests red (hash + key set) test declares a field the interface lacks -> tsc 1 ERROR reverted -> 22 files / 159 tests green, check 357 files 0 errors Suite went 154 -> 159. No existing test changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../contracts/telltale-issues.contract.json | 45 ++++++++ .../adapters/feedback.contract.test.ts | 102 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 cockpit/ui/src/lib/dashboard/adapters/contracts/telltale-issues.contract.json create mode 100644 cockpit/ui/src/lib/dashboard/adapters/feedback.contract.test.ts diff --git a/cockpit/ui/src/lib/dashboard/adapters/contracts/telltale-issues.contract.json b/cockpit/ui/src/lib/dashboard/adapters/contracts/telltale-issues.contract.json new file mode 100644 index 0000000..a8a0047 --- /dev/null +++ b/cockpit/ui/src/lib/dashboard/adapters/contracts/telltale-issues.contract.json @@ -0,0 +1,45 @@ +{ + "contract": "telltale GET /v1/issues", + "version": 1, + "producer": "telltale · src/read.ts · TelltaleIssueDTO / IssuesResponse", + "consumer": "command-center · cockpit/ui/src/lib/dashboard/adapters/feedback.ts · TelltaleIssue / FeedbackIssuesResponse", + "issueFields": { + "repo": "string", + "number": "number", + "title": "string", + "body": "string", + "kind": "'bug' | 'crash' | 'unknown'", + "project": "string", + "isOpen": "boolean", + "hasAssignee": "boolean", + "createdIso": "string", + "updatedIso": "string", + "labels": "string[]", + "url": "string" + }, + "errorFields": { + "project": "string", + "message": "string" + }, + "sample": { + "issues": [ + { + "repo": "adbarc92/hexy", + "number": 1, + "title": "crash on launch", + "body": "opened the app and it closed immediately", + "kind": "crash", + "project": "hexy", + "isOpen": true, + "hasAssignee": false, + "createdIso": "2026-06-08T12:00:00Z", + "updatedIso": "2026-06-08T12:00:00Z", + "labels": ["telltale", "telltale:crash"], + "url": "https://github.com/adbarc92/hexy/issues/1" + } + ], + "errors": [ + { "project": "lineage", "message": "config_error: no token for the org account" } + ] + } +} diff --git a/cockpit/ui/src/lib/dashboard/adapters/feedback.contract.test.ts b/cockpit/ui/src/lib/dashboard/adapters/feedback.contract.test.ts new file mode 100644 index 0000000..511351b --- /dev/null +++ b/cockpit/ui/src/lib/dashboard/adapters/feedback.contract.test.ts @@ -0,0 +1,102 @@ +// Wire contract — telltale `GET /v1/issues`, consumer side. +// +// telltale declares this payload in `src/read.ts` (TelltaleIssueDTO / IssuesResponse); +// this repo declares it again in `feedback.ts` (TelltaleIssue / FeedbackIssuesResponse). +// The two agree today. Nothing made them keep agreeing, so either side could add, +// rename or drop a field with both suites green and the wire broken. +// +// The contract JSON is vendored byte-for-byte from NEXUS `docs/contracts/`, and +// telltale vendors the same file. Each side pins the contract's canonical hash. Moving +// the contract for one side therefore leaves the OTHER side's constant stale and red — +// which is the whole mechanism. See NEXUS `docs/contracts/README.md`. + +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +// `?raw` keeps the file's own text, so the hash is over what is actually committed +// rather than over a re-serialization. `vite/client` types are already in +// tsconfig.app.json, so this needs no new declaration file. +import raw from './contracts/telltale-issues.contract.json?raw'; +import { + feedbackCards, + type FeedbackReader, + type FeedbackIssuesResponse, + type TelltaleIssue, +} from './feedback'; + +// Bump ONLY together with telltale's copy of the same constant, and only after +// reading what actually changed on the wire. +const CONTRACT_SHA256 = '2f0e8cc1b55deac36014d3774db01e41194ecb2cdf32367c40a5a7f2c7b127ed'; + +const contract = JSON.parse(raw) as { + issueFields: Record; + errorFields: Record; + sample: FeedbackIssuesResponse; +}; + +// Canonical, not raw bytes: a byte hash breaks the first time a repo checks this +// file out with CRLF, which on Windows clones is always. +const canonicalSha = (text: string) => + createHash('sha256').update(JSON.stringify(JSON.parse(text))).digest('hex'); + +// Compile-time exhaustiveness. tsc fails here if TelltaleIssue gains a field (this +// object is then missing a key) or loses one (this object then has an excess key). +// `npm run check` is where that fires; the runtime assertion below ties this literal +// to the contract file so the two cannot drift apart either. +const DECLARED_ISSUE_KEYS: Record = { + repo: true, + number: true, + title: true, + body: true, + kind: true, + project: true, + isOpen: true, + hasAssignee: true, + createdIso: true, + updatedIso: true, + labels: true, + url: true, +}; + +type ErrorEntry = FeedbackIssuesResponse['errors'][number]; +const DECLARED_ERROR_KEYS: Record = { + project: true, + message: true, +}; + +describe('telltale GET /v1/issues — wire contract', () => { + it('the contract has not moved under this repo', () => { + expect(canonicalSha(raw)).toBe(CONTRACT_SHA256); + }); + + it('TelltaleIssue declares exactly the contract issue fields — no more, no fewer', () => { + expect(Object.keys(DECLARED_ISSUE_KEYS).sort()).toEqual( + Object.keys(contract.issueFields).sort(), + ); + }); + + it('the per-project error entry declares exactly the contract error fields', () => { + expect(Object.keys(DECLARED_ERROR_KEYS).sort()).toEqual( + Object.keys(contract.errorFields).sort(), + ); + }); + + it('the contract sample carries exactly those fields on the wire, not merely a superset', () => { + for (const issue of contract.sample.issues) { + expect(Object.keys(issue).sort()).toEqual(Object.keys(contract.issueFields).sort()); + } + for (const err of contract.sample.errors) { + expect(Object.keys(err).sort()).toEqual(Object.keys(contract.errorFields).sort()); + } + }); + + it('the real adapter consumes a contract-shaped payload', async () => { + // Not a shape assertion — this drives the actual production code path, so a + // contract the adapter cannot read fails here rather than at runtime on the board. + const reader: FeedbackReader = { issues: async () => contract.sample }; + const cards = await feedbackCards(reader, { now: () => new Date('2026-06-09T12:00:00Z') }); + + expect(cards.length).toBeGreaterThan(0); + // §6.3 — a project that failed to answer must not blank the lane; it is surfaced. + expect(JSON.stringify(cards)).toContain('lineage'); + }); +});