/** * The breaking/additive classifier behind the request-contract gate * (capability-shape-drift.test.ts Scan C and the regenerate script's guard). * * celilo#1361 shipped because a request type lost a required field and no * version moved. These tests pin the classification that prevents the next * one: member removal, optional → required, and type changes are BREAKING; * an added optional member is additive. */ import { describe, expect, test } from 'bun:test'; import { type RequestTypeShape, classifyRequestChange, requestTypes } from './capability-shape'; function shape( name: string, members: Array<[name: string, type: string, optional?: boolean]>, ): RequestTypeShape { return { name, kind: 'interface', members: members.map(([memberName, type, optional]) => ({ name: memberName, type, optional: optional ?? false, })), }; } describe('classifyRequestChange', () => { test('no members changed → unchanged', () => { const before = [shape('R', [['path', 'string']])]; expect(classifyRequestChange(before, structuredClone(before))).toEqual({ kind: 'unchanged' }); }); test('a removed member is breaking', () => { const before = [ shape('R', [ ['path', 'string'], ['sourceDir', 'string'], ]), ]; const after = [shape('R', [['path', 'string']])]; const result = classifyRequestChange(before, after); expect(result).toEqual({ kind: 'breaking', reasons: ['R.sourceDir was removed'] }); }); test('optional → required is breaking', () => { const before = [shape('R', [['hostname', 'string', true]])]; const after = [shape('R', [['hostname', 'string']])]; expect(classifyRequestChange(before, after)).toEqual({ kind: 'breaking', reasons: ['R.hostname went optional → required'], }); }); test('a type change is breaking (the diff cannot tell widened from narrowed)', () => { const before = [shape('R', [['port', 'number']])]; const after = [shape('R', [['port', 'number | string']])]; expect(classifyRequestChange(before, after).kind).toBe('breaking'); }); test('a new REQUIRED member is breaking', () => { const before = [shape('R', [['path', 'string']])]; const after = [ shape('R', [ ['path', 'string'], ['digest', 'string'], ]), ]; expect(classifyRequestChange(before, after).kind).toBe('breaking'); }); test('a new optional member is additive', () => { const before = [shape('R', [['path', 'string']])]; const after = [ shape('R', [ ['path', 'string'], ['spaFallback', 'boolean', true], ]), ]; const result = classifyRequestChange(before, after); expect(result.kind).toBe('additive'); if (result.kind === 'additive') expect(result.note).toContain('spaFallback'); }); test('required → optional is additive', () => { const before = [shape('R', [['hostname', 'string']])]; const after = [shape('R', [['hostname', 'string', true]])]; expect(classifyRequestChange(before, after).kind).toBe('additive'); }); test('a type leaving the reachable set is an interface change, not a request break', () => { const before = [shape('Old', [['path', 'string']]), shape('New', [['path', 'string']])]; const after = [shape('New', [['path', 'string']])]; expect(classifyRequestChange(before, after)).toEqual({ kind: 'unchanged' }); }); test('public_web resolves its real request types, sourceDir no longer among them', () => { const publish = requestTypes('public_web').find((t) => t.name === 'PublishStaticSiteRequest'); expect(publish).toBeDefined(); const names = publish?.members.map((m) => m.name) ?? []; expect(names).toContain('path'); expect(names).not.toContain('sourceDir'); }); });