299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
import { test, expect } from 'bun:test';
|
|
|
|
import { decodeRawFlightData, getRawFlightData, parseDecodedRawFlightData } from '../parser/flight_data.js';
|
|
import { resolveType, RSCPayload, DataContainer, DataParent, Text, Data, HTMLElement, SpecialData, EmptyData } from '../parser/types.js';
|
|
import { getApiPath, listApiPaths } from '../api.js';
|
|
import { DOMParser } from './setup.js';
|
|
import { getNextData } from '../parser/next_data.js';
|
|
import { parseBuildManifest, getBuildManifestPath } from '../parser/manifests.js';
|
|
import { getNextStaticUrls, getBasePath, _NS } from '../parser/urls.js';
|
|
import { findInFlightData, findallInFlightData, finditerInFlightData, findBuildId, BeautifulFD } from '../tools.js';
|
|
|
|
// Lightweight property-style tests (fast loops, no extra deps)
|
|
|
|
function loadFixture(filename) {
|
|
const path = join(process.cwd(), '..', 'test', 'src', filename);
|
|
return readFileSync(path, 'utf-8');
|
|
}
|
|
|
|
function randInt(min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
function randAscii(len) {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.';
|
|
let out = '';
|
|
for (let i = 0; i < len; i++) out += chars[randInt(0, chars.length - 1)];
|
|
return out;
|
|
}
|
|
|
|
function randPathSegment(len) {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.';
|
|
let out = '';
|
|
for (let i = 0; i < len; i++) out += chars[randInt(0, chars.length - 1)];
|
|
return out;
|
|
}
|
|
|
|
function randPathLike(minSegments = 1, maxSegments = 3) {
|
|
const n = randInt(minSegments, maxSegments);
|
|
const segments = [];
|
|
for (let i = 0; i < n; i++) segments.push(randPathSegment(randInt(1, 10)));
|
|
return `/${segments.join('/')}`;
|
|
}
|
|
|
|
function randBool() {
|
|
return Math.random() < 0.5;
|
|
}
|
|
|
|
test('Property: decodeRawFlightData binary segment round-trip', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const payload = randAscii(randInt(0, 200));
|
|
const raw = [[0], [3, btoa(payload)]];
|
|
const decoded = decodeRawFlightData(raw);
|
|
expect(decoded).toEqual([payload]);
|
|
}
|
|
});
|
|
|
|
test('Property: decodeRawFlightData rejects unknown segment type', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const badType = randInt(4, 100);
|
|
expect(() => decodeRawFlightData([[0], [badType, 'x']])).toThrow('Unknown segment type');
|
|
}
|
|
});
|
|
|
|
test('Property: decodeRawFlightData enforces bootstrap ordering', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
expect(() => decodeRawFlightData([[1, 'x']])).toThrow('initialServerDataBuffer');
|
|
}
|
|
});
|
|
|
|
test('Property: parseDecodedRawFlightData text element length accuracy', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const text = randAscii(randInt(0, 50));
|
|
const lenHex = text.length.toString(16);
|
|
const decoded = [`1:T${lenHex},${text}`];
|
|
const parsed = parseDecodedRawFlightData(decoded);
|
|
expect(parsed[1]).toBeInstanceOf(Text);
|
|
expect(parsed[1].text).toBe(text);
|
|
expect(parsed[1].text.length).toBe(text.length);
|
|
}
|
|
});
|
|
|
|
test('Property: Flight data extraction completeness (all push() captured)', () => {
|
|
// Synthetic HTML with N pushes; ensure getRawFlightData returns N arrays
|
|
for (let i = 0; i < 50; i++) {
|
|
const n = randInt(1, 10);
|
|
const init = [0];
|
|
const scripts = [];
|
|
scripts.push(`(self.__next_f=self.__next_f||[]).push(${JSON.stringify(init)})`);
|
|
for (let k = 0; k < n; k++) {
|
|
scripts.push(`self.__next_f.push(${JSON.stringify([1, `chunk-${i}-${k}`])})`);
|
|
}
|
|
const html = `<html><body>${scripts.map(s => `<script>${s}</script>`).join('')}</body></html>`;
|
|
const raw = getRawFlightData(html, DOMParser);
|
|
expect(raw).not.toBeNull();
|
|
expect(raw.length).toBe(1 + n);
|
|
}
|
|
});
|
|
|
|
test('Property: Next data round-trip', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const obj = {
|
|
buildId: randAscii(randInt(1, 20)),
|
|
props: { pageProps: { n: i, s: randAscii(randInt(0, 10)) } }
|
|
};
|
|
const html = `<html><body><script id="__NEXT_DATA__">${JSON.stringify(obj)}</script></body></html>`;
|
|
const parsed = getNextData(html, DOMParser);
|
|
expect(parsed).toEqual(obj);
|
|
}
|
|
});
|
|
|
|
test('Property: parseBuildManifest round-trip through eval wrapper', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const data = { sortedPages: ['/', `/p${i}`], __rand: randAscii(6) };
|
|
const script = `self.__BUILD_MANIFEST=${JSON.stringify(data)};`;
|
|
const parsed = parseBuildManifest(script);
|
|
expect(parsed).toEqual(data);
|
|
}
|
|
});
|
|
|
|
test('Property: getBuildManifestPath format', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const buildId = randPathSegment(randInt(1, 16));
|
|
const basePath = randBool() ? '' : randPathLike(1, 2);
|
|
const p = getBuildManifestPath(buildId, basePath);
|
|
|
|
expect(p.startsWith('/')).toBe(true);
|
|
expect(p.endsWith(`/_next/static/${buildId}/_buildManifest.js`)).toBe(true);
|
|
|
|
if (basePath) {
|
|
expect(p.startsWith(`${basePath}/`)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Property: URL discovery completeness (every URL contains /_next/static/)', () => {
|
|
const html = loadFixture('nextjs.org.html');
|
|
const urls = getNextStaticUrls(html, DOMParser);
|
|
expect(urls).not.toBeNull();
|
|
for (const u of urls) expect(u.includes(_NS)).toBe(true);
|
|
});
|
|
|
|
test('Property: base path extraction consistency', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const prefix = randBool() ? '' : `/${randAscii(randInt(1, 6))}`;
|
|
const build = randAscii(8);
|
|
const urls = [
|
|
`${prefix}${_NS}${build}/a.js`,
|
|
`${prefix}${_NS}${build}/b.js`
|
|
];
|
|
expect(getBasePath(urls)).toBe(prefix);
|
|
}
|
|
});
|
|
|
|
test('Property: domain removal correctness', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const prefix = `/${randAscii(randInt(1, 6))}`;
|
|
const build = randAscii(8);
|
|
const urls = [
|
|
`https://example.com${prefix}${_NS}${build}/a.js`,
|
|
`https://example.com${prefix}${_NS}${build}/b.js`
|
|
];
|
|
expect(getBasePath(urls, null, true)).toBe(prefix);
|
|
}
|
|
});
|
|
|
|
test('Property: inconsistent prefixes are rejected', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const build = randAscii(8);
|
|
const urls = [
|
|
`/a${_NS}${build}/a.js`,
|
|
`/b${_NS}${build}/b.js`
|
|
];
|
|
expect(() => getBasePath(urls)).toThrow();
|
|
}
|
|
});
|
|
|
|
test('Property: Element properties are accessible', () => {
|
|
const cases = [
|
|
resolveType(["$", "$L1", null, null], null, 10),
|
|
resolveType(["$", "div", null, { id: "x" }], null, 11),
|
|
resolveType('$Sreact.suspense', null, 12),
|
|
resolveType(null, null, 13)
|
|
];
|
|
|
|
for (const el of cases) {
|
|
expect(el).toBeTruthy();
|
|
expect('value' in el).toBe(true);
|
|
expect('value_class' in el).toBe(true);
|
|
expect('index' in el).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('Property: Recursive resolution completeness (DataContainer / DataParent)', () => {
|
|
const container = resolveType([null, '$S', ["$", "div", null, { id: 'x' }]], null, 22);
|
|
expect(container).toBeInstanceOf(DataContainer);
|
|
expect(container.value.every(v => typeof v === 'object')).toBe(true);
|
|
|
|
const parent = resolveType(["$", "$L1", null, { children: ["$", "div", null, { id: 'x' }] }], null, 23);
|
|
expect(parent).toBeInstanceOf(DataParent);
|
|
expect(parent.children).toBeTruthy();
|
|
});
|
|
|
|
test('Property: RSCPayload build ID extraction (old + new formats)', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const buildId = randAscii(randInt(1, 20));
|
|
|
|
const oldVal = ["$", "$L1", null, { buildId }];
|
|
const old = resolveType(oldVal, null, 0);
|
|
expect(old).toBeInstanceOf(RSCPayload);
|
|
expect(old.build_id).toBe(buildId);
|
|
|
|
const newer = resolveType({ b: buildId }, null, 0);
|
|
expect(newer).toBeInstanceOf(RSCPayload);
|
|
expect(newer.build_id).toBe(buildId);
|
|
}
|
|
});
|
|
|
|
test('Property: find/filter semantics (class filtering, callback filtering, recursive, non-recursive, first match)', () => {
|
|
const flightData = {
|
|
0: resolveType({ b: 'build' }, null, 0),
|
|
1: resolveType('hello', 'T', 1),
|
|
2: resolveType(["$", "div", null, { id: 'x' }], null, 2),
|
|
3: resolveType([resolveType('nested', 'T', null)], null, 3)
|
|
};
|
|
|
|
// class filtering
|
|
const texts = findallInFlightData(flightData, [Text]);
|
|
expect(texts.every(t => t instanceof Text)).toBe(true);
|
|
|
|
// callback filtering
|
|
const onlyHello = findallInFlightData(flightData, null, (el) => el instanceof Text && el.text === 'hello');
|
|
expect(onlyHello.length).toBe(1);
|
|
|
|
// recursive vs non-recursive
|
|
const rec = findallInFlightData(flightData, [Text], null, true);
|
|
const nonRec = findallInFlightData(flightData, [Text], null, false);
|
|
expect(rec.length).toBeGreaterThanOrEqual(nonRec.length);
|
|
|
|
// first match
|
|
const first = findInFlightData(flightData, [Text]);
|
|
expect(first).toBeTruthy();
|
|
expect(first).toBeInstanceOf(Text);
|
|
});
|
|
|
|
test('Property: buildId discovery works and priority is staticUrls > nextData > flightData', () => {
|
|
const flightBuild = 'flightBuild';
|
|
const nextBuild = 'nextBuild';
|
|
const staticBuild = 'staticBuild';
|
|
|
|
const html = `<!doctype html><html><head>
|
|
<script id="__NEXT_DATA__">${JSON.stringify({ buildId: nextBuild })}</script>
|
|
<link rel="preload" href="${_NS}${staticBuild}/_buildManifest.js" />
|
|
<script>(self.__next_f=self.__next_f||[]).push([0])</script>
|
|
<script>self.__next_f.push([1, ${JSON.stringify(`0:{\"b\":\"${flightBuild}\"}`)}])</script>
|
|
</head><body></body></html>`;
|
|
|
|
const found = findBuildId(html, DOMParser);
|
|
expect(found).toBe(staticBuild);
|
|
});
|
|
|
|
test('Property: BeautifulFD iteration completeness + length accuracy', () => {
|
|
const el0 = resolveType({ b: 'b' }, null, 0);
|
|
const el1 = resolveType('hello', 'T', 1);
|
|
const fd = new BeautifulFD({ 0: el0, 1: el1 });
|
|
|
|
const items = Array.from(fd);
|
|
expect(items.length).toBe(fd.length);
|
|
expect(fd.length).toBe(2);
|
|
|
|
// Ensure key-value pairs
|
|
expect(items[0].length).toBe(2);
|
|
});
|
|
|
|
test('Property: getApiPath path format correctness + excluded paths + list completeness', () => {
|
|
const buildId = 'build123';
|
|
|
|
// format correctness
|
|
for (let i = 0; i < 50; i++) {
|
|
const basePath = randBool() ? '' : randPathLike(1, 2);
|
|
const p = randBool() ? randPathLike(1, 2) : randPathSegment(5);
|
|
const api = getApiPath(buildId, basePath, p);
|
|
if (api === null) continue;
|
|
expect(api.includes(`/_next/data/${buildId}`)).toBe(true);
|
|
expect(api.endsWith('.json')).toBe(true);
|
|
}
|
|
|
|
// excluded paths
|
|
for (const ex of ['/404', '/_app', '/_error', '/sitemap.xml', '/_middleware']) {
|
|
expect(getApiPath(buildId, '', ex)).toBeNull();
|
|
}
|
|
|
|
// list completeness
|
|
const pages = ['/', '/a', '/b', '/_app'];
|
|
const paths = listApiPaths(pages, buildId, '', true);
|
|
expect(paths.every(p => p.includes(`/_next/data/${buildId}`))).toBe(true);
|
|
});
|