diff --git a/CHANGELOG.md b/CHANGELOG.md index 60da2f2..b51efcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.6.0] - 2026-09-18 + +### Added +- `JobKind` gains `photo_mockup_create` and `photo_mockup_render`, the names a + photo-mockup job carries when submitted on `/api/v1/photo-mockups`. A job + submitted through `client.ai` keeps `2d_create` / `2d_render`; both spellings + are typed, and `jobs.list({ kind })` with either returns both. + `Create2DMockupResult.kind` admits both spellings. +- `WebhookEvent` gains `photo_mockup.ready`, `photo_mockup.rejected`, + `photo_mockup.failed`, `photo_mockup_render.succeeded` and + `photo_mockup_render.failed`. An endpoint receives the spelling it is pinned + to; an endpoint created before this release keeps the `2d_mockup.*` / + `2d_render.*` names. +- `WebhookEndpoint.eventNaming` (`'legacy' | 'current'`) reports that pin, and + `webhooks.create` / `webhooks.update` accept `eventNaming` to choose or + change it. Omitted on create, the API pins a new endpoint to `'current'`. + ## [2.5.1] - 2026-09-18 ### Added diff --git a/README.md b/README.md index c5800f1..fce3aa8 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,11 @@ console.log(done.resultUrl) It throws `TimeoutError` only when the job is still running past `timeoutMs`. List and page your jobs (keyset pagination, newest first). Filter by `kind` -(`render` | `video` | `upload` | `2d_create`) and/or `mockupUuid`: +(`render` | `video` | `upload` | `2d_create` | `2d_render` | +`photo_mockup_create` | `photo_mockup_render`) and/or `mockupUuid`. A +photo-mockup job is spelled `photo_mockup_*` when submitted on +`/api/v1/photo-mockups` and `2d_*` when submitted through `client.ai`; filtering +by either spelling returns both: ```typescript const { jobs, nextCursor } = await client.jobs.list({ kind: 'video', limit: 50 }) @@ -246,8 +250,9 @@ console.log(result.printFiles[0].exportFormat) // 'webp' Prefer to render in the background? Pass `isAsync: true` and `render()` resolves with a `Job` of kind `'2d_render'` (`202 Accepted`) you await with -`jobs.waitForJob`. A `2d_render.succeeded` / `2d_render.failed` webhook also -fires. +`jobs.waitForJob`. A `photo_mockup_render.succeeded` / +`photo_mockup_render.failed` webhook also fires (`2d_render.succeeded` / +`2d_render.failed` on an endpoint that keeps the legacy event names). ```typescript const job = await client.ai.render({ @@ -541,6 +546,25 @@ await client.webhooks.replayFailed(endpoint.id) // bulk replay all const events = await client.webhooks.listEvents({ status: 'failed', limit: 100 }) ``` +Photo-mockup events are `photo_mockup.ready` / `photo_mockup.rejected` / +`photo_mockup.failed` and `photo_mockup_render.succeeded` / +`photo_mockup_render.failed`. Each endpoint is pinned to one spelling +(`eventNaming`): a new endpoint receives these names; an endpoint created +before they existed keeps receiving `2d_mockup.*` / `2d_render.*` until you +re-pin it. Subscribe with either spelling and the delivery carries the +endpoint's own. + +```typescript +// A handler that still expects the legacy names: +await client.webhooks.create({ + url: 'https://example.com/hooks/sudomock', + eventTypes: ['2d_render.succeeded'], + eventNaming: 'legacy', +}) +// Move an older endpoint to the current names once its handler is ready: +await client.webhooks.update(endpoint.id, { eventNaming: 'current' }) +``` + #### Verifying signatures Every delivery carries TWO headers -- `X-SudoMock-Signature` (hex HMAC-SHA256 diff --git a/package-lock.json b/package-lock.json index ab35997..d4ba912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudomock", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudomock", - "version": "2.5.1", + "version": "2.6.0", "license": "MIT", "devDependencies": { "@types/node": "^22.19.15", diff --git a/package.json b/package.json index ce4f91b..5369ba5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sudomock", - "version": "2.5.1", + "version": "2.6.0", "description": "Official Node.js/TypeScript SDK for the SudoMock API - mockup generation from PSD files", "type": "module", "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index e9627f3..c899cf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -183,6 +183,7 @@ export type { CreateVideoParams, VideoWebhookOverride, WebhookEvent, + WebhookEventNaming, WebhookEndpoint, CreateWebhookEndpointParams, UpdateWebhookEndpointParams, diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts index 76b52f2..692bf9f 100644 --- a/src/resources/webhooks.ts +++ b/src/resources/webhooks.ts @@ -50,6 +50,7 @@ function toWebhookEndpoint(value: WebhookEndpoint): WebhookEndpoint { secret: value.secret, description: value.description, eventTypes: value.eventTypes, + eventNaming: value.eventNaming, enabled: value.enabled, createdAt: value.createdAt, updatedAt: value.updatedAt, @@ -108,14 +109,15 @@ export class WebhooksResource { url: params.url, eventTypes: params.eventTypes ?? [], description: params.description, + eventNaming: params.eventNaming, }, }) return toWebhookEndpoint(endpoint) } /** - * Update a webhook endpoint (URL, subscribed event types, description, or - * enabled state). + * Update a webhook endpoint (URL, subscribed event types, description, + * enabled state, or the event-name spelling it is pinned to). */ async update( id: string, @@ -129,6 +131,7 @@ export class WebhooksResource { eventTypes: params.eventTypes, description: params.description, enabled: params.enabled, + eventNaming: params.eventNaming, }, }) return toWebhookEndpoint(endpoint) diff --git a/src/types.ts b/src/types.ts index 0ea2cf2..4fe74a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -521,8 +521,9 @@ export interface AIRenderParams { * When `true`, the API enqueues the render and immediately returns a * {@link Job} of kind `'2d_render'` (HTTP 202) instead of blocking until the * render completes. Poll the job with `client.jobs.retrieve(job.jobId)` or - * `client.jobs.waitForJob(job.jobId)`; a `2d_render.succeeded` / - * `2d_render.failed` webhook also fires. + * `client.jobs.waitForJob(job.jobId)`; a `photo_mockup_render.succeeded` / + * `photo_mockup_render.failed` webhook also fires (`2d_render.succeeded` / + * `2d_render.failed` on an endpoint that keeps the legacy event names). * * Default: `false` (synchronous, returns an {@link AIRenderResult}). */ @@ -578,7 +579,8 @@ export type Create2DMockupParams = { /** Accepted 2D-mockup creation job. */ export interface Create2DMockupResult { jobId: string - kind: '2d_create' + /** `'2d_create'` from `client.ai`; `'photo_mockup_create'` when submitted on `/api/v1/photo-mockups`. */ + kind: '2d_create' | 'photo_mockup_create' status: 'queued' statusUrl: string } @@ -760,8 +762,23 @@ export interface UploadResult { // Jobs (async renders / videos / uploads / 2D creation) // --------------------------------------------------------------------------- -/** The kind of work a job performs. */ -export type JobKind = 'render' | 'video' | 'upload' | '2d_create' | '2d_render' +/** + * The kind of work a job performs. + * + * A photo-mockup job has two spellings: `'photo_mockup_create'` / + * `'photo_mockup_render'` when submitted on `/api/v1/photo-mockups`, and + * `'2d_create'` / `'2d_render'` when submitted on `/api/v1/sudoai/2d-mockups` + * (the path `client.ai` posts to). Filtering {@link JobsResource.list} by + * either spelling returns both. + */ +export type JobKind = + | 'render' + | 'video' + | 'upload' + | '2d_create' + | '2d_render' + | 'photo_mockup_create' + | 'photo_mockup_render' /** * Terminal and in-flight statuses for an async job (the API field is `status`). @@ -946,7 +963,15 @@ export interface VideoWebhookOverride { // Webhook endpoints // --------------------------------------------------------------------------- -/** Event types a webhook endpoint can subscribe to. */ +/** + * Event types a webhook endpoint can subscribe to. + * + * Photo-mockup events have two spellings, and an endpoint receives the one it + * is pinned to ({@link WebhookEndpoint.eventNaming}): `photo_mockup.*` / + * `photo_mockup_render.*` on a `'current'` endpoint, `2d_mockup.*` / + * `2d_render.*` on a `'legacy'` one. Subscribe with either spelling; the + * delivery carries the endpoint's own. + */ export type WebhookEvent = | 'render.succeeded' | 'render.failed' @@ -958,9 +983,27 @@ export type WebhookEvent = | '2d_mockup.failed' | '2d_render.succeeded' | '2d_render.failed' + | 'photo_mockup.ready' + | 'photo_mockup.rejected' + | 'photo_mockup.failed' + | 'photo_mockup_render.succeeded' + | 'photo_mockup_render.failed' | 'webhook.test' | (string & {}) +/** + * Which spelling of the photo-mockup events an endpoint receives. + * + * - `'current'` -- `photo_mockup.*` / `photo_mockup_render.*`; the payload's + * `kind` is `photo_mockup_create` / `photo_mockup_render`. + * - `'legacy'` -- `2d_mockup.*` / `2d_render.*`; the payload's `kind` is + * `2d_create` / `2d_render`. + * + * A new endpoint is pinned to `'current'` unless created otherwise; an endpoint + * that predates the current names stays on `'legacy'` until re-pinned. + */ +export type WebhookEventNaming = 'legacy' | 'current' + export interface WebhookEndpoint { /** Endpoint identifier (API field: `id`). */ id: string @@ -975,6 +1018,11 @@ export interface WebhookEndpoint { description?: string | null /** Subscribed event types (empty array = subscribe to all events). */ eventTypes: WebhookEvent[] + /** + * The event-name spelling this endpoint is pinned to (API field: + * `event_naming`). Absent on a deployment that predates the field. + */ + eventNaming?: WebhookEventNaming /** Whether the endpoint is currently active. */ enabled: boolean createdAt?: string @@ -991,6 +1039,12 @@ export interface CreateWebhookEndpointParams { eventTypes?: WebhookEvent[] /** Optional description. */ description?: string + /** + * Which spelling of the photo-mockup events this endpoint receives. Omit to + * take the API default (`'current'`); pass `'legacy'` for a handler that + * still expects `2d_mockup.*` / `2d_render.*`. + */ + eventNaming?: WebhookEventNaming } export interface UpdateWebhookEndpointParams { @@ -998,6 +1052,8 @@ export interface UpdateWebhookEndpointParams { eventTypes?: WebhookEvent[] description?: string enabled?: boolean + /** Re-pin the endpoint once its handler is ready for the other spelling. */ + eventNaming?: WebhookEventNaming } /** A single delivery-attempt log row. */ diff --git a/tests/photo-mockup-names.test.ts b/tests/photo-mockup-names.test.ts new file mode 100644 index 0000000..06ba197 --- /dev/null +++ b/tests/photo-mockup-names.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest' +import { http, HttpResponse } from 'msw' +import SudoMock from '../src/index' +import { + TEST_API_KEY, + TEST_BASE_URL, + server, + MOCK_WEBHOOK_ENDPOINT, +} from './setup' + +function createClient() { + return new SudoMock(TEST_API_KEY, { baseUrl: TEST_BASE_URL }) +} + +const EP_ID = '77777777-7777-7777-7777-777777777777' +const JOB_ID = '99999999-9999-4999-8999-999999999999' + +describe('photo-mockup job kinds', () => { + it('reads a photo_mockup_render job kind as the API names it', async () => { + server.use( + http.get(`${TEST_BASE_URL}/api/v1/jobs/:uuid`, () => + HttpResponse.json({ + success: true, + data: { + job_id: JOB_ID, + kind: 'photo_mockup_render', + status: 'succeeded', + result_url: 'https://cdn.sudomock.com/renders/async/done.webp', + }, + }), + ), + ) + + const job = await createClient().jobs.retrieve(JOB_ID) + expect(job.kind).toBe('photo_mockup_render') + }) + + it('filters the job list by a photo-mockup kind', async () => { + let capturedUrl = '' + server.use( + http.get(`${TEST_BASE_URL}/api/v1/jobs`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + success: true, + data: { jobs: [], next_cursor: null }, + }) + }), + ) + + await createClient().jobs.list({ kind: 'photo_mockup_create' }) + expect(new URL(capturedUrl).searchParams.get('kind')).toBe( + 'photo_mockup_create', + ) + }) + + it('returns the 202 create job under its family kind', async () => { + server.use( + http.post(`${TEST_BASE_URL}/api/v1/sudoai/2d-mockups`, () => + HttpResponse.json( + { + job_id: JOB_ID, + kind: 'photo_mockup_create', + status: 'queued', + status_url: `/api/v1/jobs/${JOB_ID}`, + }, + { status: 202 }, + ), + ), + ) + + const job = await createClient().ai.create({ + sourceUrl: 'https://example.com/product.jpg', + isAsync: true, + }) + expect(job).toEqual({ + jobId: JOB_ID, + kind: 'photo_mockup_create', + status: 'queued', + statusUrl: `/api/v1/jobs/${JOB_ID}`, + }) + }) +}) + +describe('photo-mockup webhook events', () => { + it('subscribes to the current photo-mockup event names', async () => { + let capturedBody: Record = {} + server.use( + http.post( + `${TEST_BASE_URL}/api/v1/webhook-endpoints`, + async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json( + { + ...MOCK_WEBHOOK_ENDPOINT, + event_types: [ + 'photo_mockup.ready', + 'photo_mockup_render.succeeded', + ], + }, + { status: 201 }, + ) + }, + ), + ) + + const ep = await createClient().webhooks.create({ + url: 'https://example.com/hooks/sudomock', + eventTypes: ['photo_mockup.ready', 'photo_mockup_render.succeeded'], + }) + + expect(capturedBody['event_types']).toEqual([ + 'photo_mockup.ready', + 'photo_mockup_render.succeeded', + ]) + expect(ep.eventTypes).toEqual([ + 'photo_mockup.ready', + 'photo_mockup_render.succeeded', + ]) + }) + + it('filters deliveries by a current photo-mockup event name', async () => { + let capturedUrl = '' + server.use( + http.get( + `${TEST_BASE_URL}/api/v1/webhook-endpoints/:id/deliveries`, + ({ request }) => { + capturedUrl = request.url + return HttpResponse.json([]) + }, + ), + ) + + await createClient().webhooks.listDeliveries(EP_ID, { + eventType: 'photo_mockup_render.failed', + }) + expect(new URL(capturedUrl).searchParams.get('event_type')).toBe( + 'photo_mockup_render.failed', + ) + }) +}) + +describe('webhook endpoint event naming', () => { + it('pins a new endpoint to the current names', async () => { + let capturedBody: Record = {} + server.use( + http.post( + `${TEST_BASE_URL}/api/v1/webhook-endpoints`, + async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json( + { ...MOCK_WEBHOOK_ENDPOINT, event_naming: 'current' }, + { status: 201 }, + ) + }, + ), + ) + + const ep = await createClient().webhooks.create({ + url: 'https://example.com/hooks/sudomock', + eventTypes: ['photo_mockup_render.succeeded'], + eventNaming: 'current', + }) + // SDK sends the API field name `event_naming` (snake_cased from eventNaming). + expect(capturedBody['event_naming']).toBe('current') + expect(ep.eventNaming).toBe('current') + }) + + it('leaves the naming to the API default when not given', async () => { + let capturedBody: Record = {} + server.use( + http.post( + `${TEST_BASE_URL}/api/v1/webhook-endpoints`, + async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json( + { ...MOCK_WEBHOOK_ENDPOINT, event_naming: 'current' }, + { status: 201 }, + ) + }, + ), + ) + + await createClient().webhooks.create({ + url: 'https://example.com/hooks/sudomock', + }) + expect(capturedBody).not.toHaveProperty('event_naming') + }) + + it('reads the pinned naming back on list and retrieve', async () => { + server.use( + http.get(`${TEST_BASE_URL}/api/v1/webhook-endpoints`, () => + HttpResponse.json([{ ...MOCK_WEBHOOK_ENDPOINT, event_naming: 'legacy' }]), + ), + http.get(`${TEST_BASE_URL}/api/v1/webhook-endpoints/:id`, () => + HttpResponse.json({ ...MOCK_WEBHOOK_ENDPOINT, event_naming: 'legacy' }), + ), + ) + + const client = createClient() + const [listed] = await client.webhooks.list() + expect(listed!.eventNaming).toBe('legacy') + expect(listed).not.toHaveProperty('privateEndpointState') + const retrieved = await client.webhooks.retrieve(EP_ID) + expect(retrieved.eventNaming).toBe('legacy') + }) + + it('leaves eventNaming undefined on a response that predates it', async () => { + server.use( + http.get(`${TEST_BASE_URL}/api/v1/webhook-endpoints/:id`, () => + HttpResponse.json(MOCK_WEBHOOK_ENDPOINT), + ), + ) + + const ep = await createClient().webhooks.retrieve(EP_ID) + expect(ep.eventNaming).toBeUndefined() + }) + + it('re-pins an endpoint to the current names on update', async () => { + let capturedBody: Record = {} + server.use( + http.patch( + `${TEST_BASE_URL}/api/v1/webhook-endpoints/:id`, + async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json({ + ...MOCK_WEBHOOK_ENDPOINT, + event_naming: 'current', + }) + }, + ), + ) + + const ep = await createClient().webhooks.update(EP_ID, { + eventNaming: 'current', + }) + expect(capturedBody['event_naming']).toBe('current') + expect(ep.eventNaming).toBe('current') + }) +}) diff --git a/tests/photo-mockup-names.typecheck.ts b/tests/photo-mockup-names.typecheck.ts new file mode 100644 index 0000000..1752714 --- /dev/null +++ b/tests/photo-mockup-names.typecheck.ts @@ -0,0 +1,83 @@ +// Compile-time contract for the photo-mockup names (checked by `npm run lint` +// through tsconfig.typecheck.json, never executed). +// +// `WebhookEvent` carries a `(string & {})` escape hatch so unknown event names +// still type-check; the named members are pinned here by stripping that hatch +// (`string extends T` holds for the hatch and for no literal member). +import type { + Create2DMockupResult, + CreateWebhookEndpointParams, + JobKind, + ListJobsParams, + UpdateWebhookEndpointParams, + WebhookEndpoint, + WebhookEvent, + WebhookEventNaming, +} from '../src/index' + +type StripEscapeHatch = T extends unknown + ? string extends T + ? never + : T + : never +type NamedWebhookEvent = StripEscapeHatch + +// Job kinds: the family names next to the legacy spellings. +export const photoMockupCreateKind: JobKind = 'photo_mockup_create' +export const photoMockupRenderKind: JobKind = 'photo_mockup_render' +export const legacyCreateKind: JobKind = '2d_create' +export const legacyRenderKind: JobKind = '2d_render' +export const listPhotoMockupRenders: ListJobsParams = { kind: 'photo_mockup_render' } + +// The 202 body of an async create names the job by either spelling. +export const acceptedCurrent: Create2DMockupResult = { + jobId: '11111111-1111-4111-8111-111111111111', + kind: 'photo_mockup_create', + status: 'queued', + statusUrl: '/api/v1/jobs/11111111-1111-4111-8111-111111111111', +} +export const acceptedLegacy: Create2DMockupResult = { + jobId: '11111111-1111-4111-8111-111111111111', + kind: '2d_create', + status: 'queued', + statusUrl: '/api/v1/jobs/11111111-1111-4111-8111-111111111111', +} + +// Webhook events: the five current names and the five legacy names are both +// members of the union, not merely tolerated strings. +export const currentEvents: NamedWebhookEvent[] = [ + 'photo_mockup.ready', + 'photo_mockup.rejected', + 'photo_mockup.failed', + 'photo_mockup_render.succeeded', + 'photo_mockup_render.failed', +] +export const legacyEvents: NamedWebhookEvent[] = [ + '2d_mockup.ready', + '2d_mockup.rejected', + '2d_mockup.failed', + '2d_render.succeeded', + '2d_render.failed', +] + +// An endpoint pins the spelling it was built against. +export const pinnedCurrent: WebhookEventNaming = 'current' +export const pinnedLegacy: WebhookEventNaming = 'legacy' +export const createPinnedCurrent: CreateWebhookEndpointParams = { + url: 'https://example.com/hooks/sudomock', + eventTypes: ['photo_mockup_render.succeeded'], + eventNaming: 'current', +} +export const createPinnedLegacy: CreateWebhookEndpointParams = { + url: 'https://example.com/hooks/sudomock', + eventTypes: ['2d_render.succeeded'], + eventNaming: 'legacy', +} +export const repin: UpdateWebhookEndpointParams = { eventNaming: 'current' } +export const endpoint: WebhookEndpoint = { + id: '77777777-7777-7777-7777-777777777777', + url: 'https://example.com/hooks/sudomock', + eventTypes: [], + eventNaming: 'legacy', + enabled: true, +} diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..3cba8bc --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { SDK_VERSION } from '../src/client' + +// The version this SDK announces on the wire (X-SudoMock-Client) is the one a +// reader looks up in CHANGELOG.md. These checks keep the three places that +// carry it (package.json, package-lock.json, CHANGELOG.md) from drifting apart, +// and refuse a version whose release notes still hold a shipped feature under +// [Unreleased]. + +const read = (name: string): string => + readFileSync(new URL(`../${name}`, import.meta.url), 'utf8') + +const packageJson = JSON.parse(read('package.json')) as { version: string } +const packageLock = JSON.parse(read('package-lock.json')) as { + version: string + packages: Record +} +const changelog = read('CHANGELOG.md') + +interface Release { + version: string + date: string + body: string +} + +// `## [2.6.0] - 2026-09-18` opens a released section; any other `## ` line +// (for example `## [Unreleased]`) closes the previous one without opening a +// released one, so its bullets belong to no release. +const RELEASE_HEADER = /^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$/ + +function parseReleases(text: string): Release[] { + const releases: Release[] = [] + let current: Release | undefined + for (const line of text.split('\n')) { + if (line.startsWith('## ')) { + const match = RELEASE_HEADER.exec(line) + current = match + ? { version: match[1] as string, date: match[2] as string, body: '' } + : undefined + if (current) releases.push(current) + continue + } + if (current) current.body += `${line}\n` + } + return releases +} + +const releases = parseReleases(changelog) + +const tuple = (v: string): number[] => v.split('.').map(Number) + +function notAfter(a: string, b: string): boolean { + const [x, y] = [tuple(a), tuple(b)] + for (let i = 0; i < 3; i += 1) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0) + } + return true +} + +// Names a caller can type against in this SDK today. Each must be documented +// under a release at or below the version on the wire, not only under +// [Unreleased]; otherwise the published package ships a feature its release +// notes say has not shipped. +const SHIPPED_ON_THE_WIRE = [ + 'eventNaming', + 'photo_mockup_create', + 'photo_mockup.ready', +] as const + +describe('release consistency', () => { + it('package-lock.json carries the package.json version at both roots', () => { + expect(packageLock.version).toBe(packageJson.version) + expect(packageLock.packages['']?.version).toBe(packageJson.version) + }) + + it('the version on the wire has a dated CHANGELOG entry', () => { + expect(SDK_VERSION).toBe(packageJson.version) + const entry = releases.find((r) => r.version === SDK_VERSION) + expect( + entry, + `CHANGELOG.md has no "## [${SDK_VERSION}] - YYYY-MM-DD" section`, + ).toBeDefined() + expect(Number.isNaN(Date.parse(entry?.date ?? ''))).toBe(false) + }) + + it.each(SHIPPED_ON_THE_WIRE)( + '%s is documented under a release at or below the version on the wire', + (feature) => { + const documentedIn = releases + .filter((r) => notAfter(r.version, SDK_VERSION) && r.body.includes(feature)) + .map((r) => r.version) + expect( + documentedIn, + `${feature} ships in ${SDK_VERSION} but no release at or below it mentions it`, + ).not.toHaveLength(0) + }, + ) +})