From a7a8041b139f6da1c98bf93026751a082e66ce89 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 23:32:27 +0200 Subject: [PATCH] test(frontend): shadow-parse harness asserting legacy class and Zod converge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Step 3c's safety net: for every inbound golden fixture, run the raw payload through both pipelines — legacy: Inbound.Settings.fromJson(protocol, raw.settings).toJson() zod: InboundSettingsSchema.parse(raw).settings — canonicalize each (recursively sort keys, drop empty arrays / null / undefined), and assert byte-equality. This locks the wire shape across the upcoming class-to-pure-function extraction in Step 3d. Any normalization drift introduced by the rewrite trips an assertion here before it can reach users. Two ergonomic wrinkles handled inline: - The legacy class lumps hysteria + hysteria2 onto a single HysteriaSettings (no hysteria2 case in the dispatch table); the test routes hysteria2 fixtures through the HYSTERIA branch. - Empty arrays in Zod's output (e.g. fallbacks: [] from a .default([])) are treated as equivalent to the legacy class's omit-when-empty behavior. Same wire state, different syntactic surface. All 26 tests across 4 test files pass on first run. --- frontend/src/test/shadow.test.ts | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 frontend/src/test/shadow.test.ts 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)); + }); + } +});