diff --git a/frontend/src/test/shadow.test.ts b/frontend/src/test/shadow.test.ts new file mode 100644 index 00000000..b76dfe46 --- /dev/null +++ b/frontend/src/test/shadow.test.ts @@ -0,0 +1,78 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { Inbound } from '@/models/inbound'; +import { InboundSettingsSchema } from '@/schemas/protocols'; + +// Walks every inbound golden fixture through both pipelines: +// OLD: Inbound.Settings.fromJson(protocol, raw.settings).toJson() +// NEW: InboundSettingsSchema.parse(raw).settings +// Then canonicalizes (deep key-sort, undefined-strip via JSON round-trip) +// and asserts byte-equality. This is the safety net for Step 3d — once we +// start extracting class methods into lib/xray/* pure functions, any +// normalization drift trips a snapshot diff here. + +const fixtures = import.meta.glob( + './golden/fixtures/inbound/*.json', + { eager: true, import: 'default' }, +); + +type FixtureShape = { protocol: string; settings: unknown }; + +// The OLD panel class collapses hysteria + hysteria2 onto a single +// HysteriaSettings (distinguished only by `version`), so when a fixture +// carries the wire-level hysteria2 protocol literal we dispatch to the +// HYSTERIA branch on the legacy side. +function legacyProtocolFor(protocol: string): string { + if (protocol === 'hysteria2') return 'hysteria'; + return protocol; +} + +// Drops empty arrays and undefined/null fields, then sorts keys. The legacy +// class's toJson() omits optional fields whose value is the empty array +// (e.g. fallbacks: []); the Zod schema includes them because of .default([]). +// Both represent the same wire state, so we treat them as equivalent here. +function canonicalize(value: unknown): string { + function normalize(v: unknown): unknown { + if (Array.isArray(v)) { + const items = v.map(normalize).filter((x) => x !== undefined); + return items.length === 0 ? undefined : items; + } + if (v && typeof v === 'object') { + const entries = Object.entries(v as Record) + .map(([k, val]) => [k, normalize(val)] as const) + .filter(([, val]) => val !== undefined && val !== null) + .sort(([a], [b]) => a.localeCompare(b)); + return entries.length === 0 ? undefined : Object.fromEntries(entries); + } + return v; + } + return JSON.stringify(normalize(value) ?? null); +} + +function fixtureName(path: string): string { + const file = path.split('/').pop() ?? path; + return file.replace(/\.json$/, ''); +} + +describe('shadow parse: legacy class vs Zod schema', () => { + const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)); + + for (const [path, raw] of entries) { + const fixture = raw as FixtureShape; + const name = fixtureName(path); + + it(`${name}: legacy toJson() and Zod parse converge`, () => { + const legacyInstance = Inbound.Settings.fromJson( + legacyProtocolFor(fixture.protocol), + fixture.settings, + ); + expect(legacyInstance, `legacy dispatch returned null for ${fixture.protocol}`).not.toBeNull(); + const legacyJson = legacyInstance.toJson(); + + const zodParsed = InboundSettingsSchema.parse(fixture); + + expect(canonicalize(zodParsed.settings)).toBe(canonicalize(legacyJson)); + }); + } +});