mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-28 16:09:36 +00:00
test(frontend): shadow-parse harness asserting legacy class and Zod converge
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.
This commit is contained in:
78
frontend/src/test/shadow.test.ts
Normal file
78
frontend/src/test/shadow.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/// <reference types="vite/client" />
|
||||
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<unknown>(
|
||||
'./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<string, unknown>)
|
||||
.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));
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user