njsparser 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +390 -40
- package/api.js +76 -50
- package/bun.lock +2 -48
- package/mod.js +148 -0
- package/package.json +11 -16
- package/parser/flight_data.js +189 -306
- package/parser/manifests.js +37 -37
- package/parser/next_data.js +29 -26
- package/parser/types.js +408 -296
- package/parser/urls.js +86 -56
- package/tests/api.test.js +96 -0
- package/tests/integration.test.js +68 -0
- package/tests/parser/flight_data.test.js +105 -0
- package/tests/parser/manifests.test.js +50 -0
- package/tests/parser/next_data.test.js +53 -0
- package/tests/parser/types.test.js +243 -0
- package/tests/parser/urls.test.js +84 -0
- package/tests/property.test.js +299 -0
- package/tests/setup.js +21 -0
- package/tests/utils.test.js +32 -0
- package/tools.js +263 -185
- package/utils.js +29 -24
- package/_.js +0 -10
- package/_.json +0 -12837
- package/api.test.js +0 -41
- package/index.js +0 -8
- package/package-lock.json +0 -291
- package/parser/flight_data.test.js +0 -59
- package/parser/manifests.test.js +0 -36
- package/parser/next_data.test.js +0 -15
- package/parser/types.test.js +0 -261
- package/parser/urls.test.js +0 -26
- package/test/src/index.js +0 -16
- package/tools.test.js +0 -153
- package/utils.test.js +0 -38
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Element,
|
|
4
|
+
HintPreload,
|
|
5
|
+
Module,
|
|
6
|
+
Text,
|
|
7
|
+
Data,
|
|
8
|
+
EmptyData,
|
|
9
|
+
SpecialData,
|
|
10
|
+
HTMLElement,
|
|
11
|
+
DataContainer,
|
|
12
|
+
DataParent,
|
|
13
|
+
URLQuery,
|
|
14
|
+
RSCPayload,
|
|
15
|
+
Error as FlightError,
|
|
16
|
+
isFlightDataObj,
|
|
17
|
+
resolveType,
|
|
18
|
+
T
|
|
19
|
+
} from '../../parser/types.js';
|
|
20
|
+
|
|
21
|
+
test('Element constructor', () => {
|
|
22
|
+
const el = new Element('test', 'TestClass', 5);
|
|
23
|
+
expect(el.value).toBe('test');
|
|
24
|
+
expect(el.value_class).toBe('TestClass');
|
|
25
|
+
expect(el.index).toBe(5);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('HintPreload with font', () => {
|
|
29
|
+
const hl = new HintPreload(
|
|
30
|
+
['/_next/static/media/font.woff2', 'font', { crossOrigin: '', type: 'font/woff2' }],
|
|
31
|
+
'HL',
|
|
32
|
+
1
|
|
33
|
+
);
|
|
34
|
+
expect(hl.href).toBe('/_next/static/media/font.woff2');
|
|
35
|
+
expect(hl.type_name).toBe('font');
|
|
36
|
+
expect(hl.attrs).toEqual({ crossOrigin: '', type: 'font/woff2' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('HintPreload with style (no attrs)', () => {
|
|
40
|
+
const hl = new HintPreload(
|
|
41
|
+
['/_next/static/css/style.css', 'style'],
|
|
42
|
+
'HL',
|
|
43
|
+
2
|
|
44
|
+
);
|
|
45
|
+
expect(hl.href).toBe('/_next/static/css/style.css');
|
|
46
|
+
expect(hl.type_name).toBe('style');
|
|
47
|
+
expect(hl.attrs).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('Module with array format', () => {
|
|
51
|
+
const mod = new Module(
|
|
52
|
+
[30777, ['71523', 'static/chunks/chunk1.js', '10411', 'static/chunks/chunk2.js'], 'default'],
|
|
53
|
+
'I',
|
|
54
|
+
3
|
|
55
|
+
);
|
|
56
|
+
expect(mod.module_id).toBe(30777);
|
|
57
|
+
expect(mod.module_name).toBe('default');
|
|
58
|
+
expect(mod.module_chunks_raw()).toEqual({
|
|
59
|
+
'71523': 'static/chunks/chunk1.js',
|
|
60
|
+
'10411': 'static/chunks/chunk2.js'
|
|
61
|
+
});
|
|
62
|
+
expect(mod.module_chunks['71523']).toBe('/_next/static/chunks/chunk1.js');
|
|
63
|
+
expect(mod.is_async).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('Module with object format', () => {
|
|
67
|
+
const mod = new Module(
|
|
68
|
+
{ id: '123', chunks: ['71523:static/chunks/chunk1.js'], name: 'test', async: true },
|
|
69
|
+
'I',
|
|
70
|
+
4
|
|
71
|
+
);
|
|
72
|
+
expect(mod.module_id).toBe(123);
|
|
73
|
+
expect(mod.module_name).toBe('test');
|
|
74
|
+
expect(mod.is_async).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('Text element', () => {
|
|
78
|
+
const txt = new Text('hello world', 'T', 5);
|
|
79
|
+
expect(txt.text).toBe('hello world');
|
|
80
|
+
expect(txt.value).toBe('hello world');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('Data with content', () => {
|
|
84
|
+
const data = new Data(['$', '$L1', null, { user: 'test' }], null, 6);
|
|
85
|
+
expect(data.content).toEqual({ user: 'test' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('Data with null content', () => {
|
|
89
|
+
const data = new Data(['$', '$L1', null, null], null, 7);
|
|
90
|
+
expect(data.content).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('EmptyData', () => {
|
|
94
|
+
const empty = new EmptyData(null, null, 8);
|
|
95
|
+
expect(empty.value).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('SpecialData', () => {
|
|
99
|
+
const special = new SpecialData('$Sreact.suspense', null, 9);
|
|
100
|
+
expect(special.value).toBe('$Sreact.suspense');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('HTMLElement', () => {
|
|
104
|
+
const html = new HTMLElement(
|
|
105
|
+
['$', 'link', 'https://example.com', { rel: 'dns-prefetch', href: 'https://example.com' }],
|
|
106
|
+
null,
|
|
107
|
+
10
|
|
108
|
+
);
|
|
109
|
+
expect(html.tag).toBe('link');
|
|
110
|
+
expect(html.href).toBe('https://example.com');
|
|
111
|
+
expect(html.attrs).toEqual({ rel: 'dns-prefetch', href: 'https://example.com' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('DataContainer resolves children', () => {
|
|
115
|
+
const container = new DataContainer(
|
|
116
|
+
[
|
|
117
|
+
['$', 'div', null, {}],
|
|
118
|
+
['$', 'span', null, { class: 'test' }]
|
|
119
|
+
],
|
|
120
|
+
null,
|
|
121
|
+
11
|
|
122
|
+
);
|
|
123
|
+
expect(container.value).toHaveLength(2);
|
|
124
|
+
expect(container.value[0]).toBeInstanceOf(HTMLElement);
|
|
125
|
+
expect(container.value[1]).toBeInstanceOf(HTMLElement);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('DataParent resolves children', () => {
|
|
129
|
+
const parent = new DataParent(
|
|
130
|
+
['$', '$L16', null, { children: ['$', '$L17', null, { profile: {} }] }],
|
|
131
|
+
null,
|
|
132
|
+
12
|
|
133
|
+
);
|
|
134
|
+
expect(parent.children).toBeInstanceOf(Data);
|
|
135
|
+
expect(parent.children.content).toEqual({ profile: {} });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('URLQuery', () => {
|
|
139
|
+
const query = new URLQuery(['userId', '624dc255c12744f2fdaf90c8', 'd'], null, 13);
|
|
140
|
+
expect(query.key).toBe('userId');
|
|
141
|
+
expect(query.val).toBe('624dc255c12744f2fdaf90c8');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('RSCPayload old format', () => {
|
|
145
|
+
const payload = new RSCPayload(
|
|
146
|
+
['$', '$L1', null, { buildId: 'abc123' }],
|
|
147
|
+
null,
|
|
148
|
+
0
|
|
149
|
+
);
|
|
150
|
+
expect(payload.build_id).toBe('abc123');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('RSCPayload new format', () => {
|
|
154
|
+
const payload = new RSCPayload({ b: 'xyz789' }, null, 0);
|
|
155
|
+
expect(payload.build_id).toBe('xyz789');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('Error element', () => {
|
|
159
|
+
const err = new FlightError({ digest: 'NEXT_NOT_FOUND' }, 'E', 14);
|
|
160
|
+
expect(err.digest).toBe('NEXT_NOT_FOUND');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('isFlightDataObj returns true for valid objects', () => {
|
|
164
|
+
expect(isFlightDataObj(['$', '$L1', null, {}])).toBe(true);
|
|
165
|
+
expect(isFlightDataObj(['$', 'div', 'href', { class: 'test' }])).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('isFlightDataObj returns false for invalid objects', () => {
|
|
169
|
+
expect(isFlightDataObj([])).toBe(false);
|
|
170
|
+
expect(isFlightDataObj(['$', '$L1'])).toBe(false);
|
|
171
|
+
expect(isFlightDataObj(['$', '$L1', null])).toBe(false);
|
|
172
|
+
expect(isFlightDataObj([1, '$L1', null, {}])).toBe(false);
|
|
173
|
+
expect(isFlightDataObj(['$', 123, null, {}])).toBe(false);
|
|
174
|
+
expect(isFlightDataObj(null)).toBe(false);
|
|
175
|
+
expect(isFlightDataObj('string')).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('resolveType with explicit class', () => {
|
|
179
|
+
const el = resolveType('test', 'T', 1, Text);
|
|
180
|
+
expect(el).toBeInstanceOf(Text);
|
|
181
|
+
expect(el.text).toBe('test');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('resolveType with value_class', () => {
|
|
185
|
+
const el = resolveType('test', 'T', 1);
|
|
186
|
+
expect(el).toBeInstanceOf(Text);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('resolveType for Data with buildId', () => {
|
|
190
|
+
const el = resolveType(['$', '$L1', null, { buildId: 'test' }], null, 0);
|
|
191
|
+
expect(el).toBeInstanceOf(RSCPayload);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('resolveType for Data with children', () => {
|
|
195
|
+
const el = resolveType(['$', '$L1', null, { children: ['$', '$L2', null, {}] }], null, 1);
|
|
196
|
+
expect(el).toBeInstanceOf(DataParent);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('resolveType for HTMLElement', () => {
|
|
200
|
+
const el = resolveType(['$', 'div', null, {}], null, 1);
|
|
201
|
+
expect(el).toBeInstanceOf(HTMLElement);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('resolveType for URLQuery', () => {
|
|
205
|
+
const el = resolveType(['key', 'value', 'd'], null, 1);
|
|
206
|
+
expect(el).toBeInstanceOf(URLQuery);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('resolveType for DataContainer', () => {
|
|
210
|
+
const el = resolveType([['$', 'div', null, {}]], null, 1);
|
|
211
|
+
expect(el).toBeInstanceOf(DataContainer);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('resolveType for EmptyData', () => {
|
|
215
|
+
const el = resolveType(null, null, 1);
|
|
216
|
+
expect(el).toBeInstanceOf(EmptyData);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('resolveType for SpecialData', () => {
|
|
220
|
+
const el = resolveType('$Sreact.suspense', null, 1);
|
|
221
|
+
expect(el).toBeInstanceOf(SpecialData);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('resolveType for RSCPayload at index 0', () => {
|
|
225
|
+
const el = resolveType({ b: 'test' }, null, 0);
|
|
226
|
+
expect(el).toBeInstanceOf(RSCPayload);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('T object contains all types', () => {
|
|
230
|
+
expect(T.Element).toBe(Element);
|
|
231
|
+
expect(T.HintPreload).toBe(HintPreload);
|
|
232
|
+
expect(T.Module).toBe(Module);
|
|
233
|
+
expect(T.Text).toBe(Text);
|
|
234
|
+
expect(T.Data).toBe(Data);
|
|
235
|
+
expect(T.EmptyData).toBe(EmptyData);
|
|
236
|
+
expect(T.SpecialData).toBe(SpecialData);
|
|
237
|
+
expect(T.HTMLElement).toBe(HTMLElement);
|
|
238
|
+
expect(T.DataContainer).toBe(DataContainer);
|
|
239
|
+
expect(T.DataParent).toBe(DataParent);
|
|
240
|
+
expect(T.URLQuery).toBe(URLQuery);
|
|
241
|
+
expect(T.RSCPayload).toBe(RSCPayload);
|
|
242
|
+
expect(T.Error).toBe(FlightError);
|
|
243
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { DOMParser } from '../setup.js';
|
|
5
|
+
import { getNextStaticUrls, getBasePath, _NS } from '../../parser/urls.js';
|
|
6
|
+
|
|
7
|
+
function loadTestHTML(filename) {
|
|
8
|
+
const path = join(process.cwd(), '..', 'test', 'src', filename);
|
|
9
|
+
return readFileSync(path, 'utf-8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('getNextStaticUrls finds URLs in nextjs.org.html', () => {
|
|
13
|
+
const html = loadTestHTML('nextjs.org.html');
|
|
14
|
+
const urls = getNextStaticUrls(html, DOMParser);
|
|
15
|
+
expect(urls).not.toBeNull();
|
|
16
|
+
expect(Array.isArray(urls)).toBe(true);
|
|
17
|
+
expect(urls.length).toBeGreaterThan(0);
|
|
18
|
+
expect(urls.every(url => url.includes(_NS))).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('getNextStaticUrls returns null for no static URLs', () => {
|
|
22
|
+
const html = '<html><body>No static URLs</body></html>';
|
|
23
|
+
const urls = getNextStaticUrls(html, DOMParser);
|
|
24
|
+
expect(urls).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('getNextStaticUrls finds URLs in swag.live.html', () => {
|
|
28
|
+
const html = loadTestHTML('swag.live.html');
|
|
29
|
+
const urls = getNextStaticUrls(html, DOMParser);
|
|
30
|
+
expect(urls).not.toBeNull();
|
|
31
|
+
expect(Array.isArray(urls)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('getBasePath extracts base path from URLs', () => {
|
|
35
|
+
const urls = [
|
|
36
|
+
'/_next/static/abc/chunk1.js',
|
|
37
|
+
'/_next/static/abc/chunk2.js'
|
|
38
|
+
];
|
|
39
|
+
const basePath = getBasePath(urls);
|
|
40
|
+
expect(basePath).toBe('');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('getBasePath extracts base path with prefix', () => {
|
|
44
|
+
const urls = [
|
|
45
|
+
'/app/_next/static/abc/chunk1.js',
|
|
46
|
+
'/app/_next/static/abc/chunk2.js'
|
|
47
|
+
];
|
|
48
|
+
const basePath = getBasePath(urls);
|
|
49
|
+
expect(basePath).toBe('/app');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('getBasePath throws on inconsistent prefixes', () => {
|
|
53
|
+
const urls = [
|
|
54
|
+
'/app1/_next/static/abc/chunk1.js',
|
|
55
|
+
'/app2/_next/static/abc/chunk2.js'
|
|
56
|
+
];
|
|
57
|
+
expect(() => getBasePath(urls)).toThrow();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('getBasePath throws on missing _NS', () => {
|
|
61
|
+
const urls = ['/invalid/path.js'];
|
|
62
|
+
expect(() => getBasePath(urls)).toThrow("can't find");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('getBasePath removes domain when requested', () => {
|
|
66
|
+
const urls = [
|
|
67
|
+
'https://example.com/app/_next/static/abc/chunk1.js',
|
|
68
|
+
'https://example.com/app/_next/static/abc/chunk2.js'
|
|
69
|
+
];
|
|
70
|
+
const basePath = getBasePath(urls, null, true);
|
|
71
|
+
expect(basePath).toBe('/app');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('getBasePath from HTML', () => {
|
|
75
|
+
const html = loadTestHTML('nextjs.org.html');
|
|
76
|
+
const basePath = getBasePath(html, DOMParser);
|
|
77
|
+
expect(typeof basePath).toBe('string');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('getBasePath returns null for HTML with no static URLs', () => {
|
|
81
|
+
const html = '<html><body>No static URLs</body></html>';
|
|
82
|
+
const basePath = getBasePath(html, DOMParser);
|
|
83
|
+
expect(basePath).toBeNull();
|
|
84
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
import { test, expect } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { decodeRawFlightData, getRawFlightData, parseDecodedRawFlightData } from '../parser/flight_data.js';
|
|
7
|
+
import { resolveType, RSCPayload, DataContainer, DataParent, Text, Data, HTMLElement, SpecialData, EmptyData } from '../parser/types.js';
|
|
8
|
+
import { getApiPath, listApiPaths } from '../api.js';
|
|
9
|
+
import { DOMParser } from './setup.js';
|
|
10
|
+
import { getNextData } from '../parser/next_data.js';
|
|
11
|
+
import { parseBuildManifest, getBuildManifestPath } from '../parser/manifests.js';
|
|
12
|
+
import { getNextStaticUrls, getBasePath, _NS } from '../parser/urls.js';
|
|
13
|
+
import { findInFlightData, findallInFlightData, finditerInFlightData, findBuildId, BeautifulFD } from '../tools.js';
|
|
14
|
+
|
|
15
|
+
// Lightweight property-style tests (fast loops, no extra deps)
|
|
16
|
+
|
|
17
|
+
function loadFixture(filename) {
|
|
18
|
+
const path = join(process.cwd(), '..', 'test', 'src', filename);
|
|
19
|
+
return readFileSync(path, 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function randInt(min, max) {
|
|
23
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function randAscii(len) {
|
|
27
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.';
|
|
28
|
+
let out = '';
|
|
29
|
+
for (let i = 0; i < len; i++) out += chars[randInt(0, chars.length - 1)];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function randPathSegment(len) {
|
|
34
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.';
|
|
35
|
+
let out = '';
|
|
36
|
+
for (let i = 0; i < len; i++) out += chars[randInt(0, chars.length - 1)];
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function randPathLike(minSegments = 1, maxSegments = 3) {
|
|
41
|
+
const n = randInt(minSegments, maxSegments);
|
|
42
|
+
const segments = [];
|
|
43
|
+
for (let i = 0; i < n; i++) segments.push(randPathSegment(randInt(1, 10)));
|
|
44
|
+
return `/${segments.join('/')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function randBool() {
|
|
48
|
+
return Math.random() < 0.5;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('Property: decodeRawFlightData binary segment round-trip', () => {
|
|
52
|
+
for (let i = 0; i < 100; i++) {
|
|
53
|
+
const payload = randAscii(randInt(0, 200));
|
|
54
|
+
const raw = [[0], [3, btoa(payload)]];
|
|
55
|
+
const decoded = decodeRawFlightData(raw);
|
|
56
|
+
expect(decoded).toEqual([payload]);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Property: decodeRawFlightData rejects unknown segment type', () => {
|
|
61
|
+
for (let i = 0; i < 50; i++) {
|
|
62
|
+
const badType = randInt(4, 100);
|
|
63
|
+
expect(() => decodeRawFlightData([[0], [badType, 'x']])).toThrow('Unknown segment type');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Property: decodeRawFlightData enforces bootstrap ordering', () => {
|
|
68
|
+
for (let i = 0; i < 50; i++) {
|
|
69
|
+
expect(() => decodeRawFlightData([[1, 'x']])).toThrow('initialServerDataBuffer');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('Property: parseDecodedRawFlightData text element length accuracy', () => {
|
|
74
|
+
for (let i = 0; i < 100; i++) {
|
|
75
|
+
const text = randAscii(randInt(0, 50));
|
|
76
|
+
const lenHex = text.length.toString(16);
|
|
77
|
+
const decoded = [`1:T${lenHex},${text}`];
|
|
78
|
+
const parsed = parseDecodedRawFlightData(decoded);
|
|
79
|
+
expect(parsed[1]).toBeInstanceOf(Text);
|
|
80
|
+
expect(parsed[1].text).toBe(text);
|
|
81
|
+
expect(parsed[1].text.length).toBe(text.length);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('Property: Flight data extraction completeness (all push() captured)', () => {
|
|
86
|
+
// Synthetic HTML with N pushes; ensure getRawFlightData returns N arrays
|
|
87
|
+
for (let i = 0; i < 50; i++) {
|
|
88
|
+
const n = randInt(1, 10);
|
|
89
|
+
const init = [0];
|
|
90
|
+
const scripts = [];
|
|
91
|
+
scripts.push(`(self.__next_f=self.__next_f||[]).push(${JSON.stringify(init)})`);
|
|
92
|
+
for (let k = 0; k < n; k++) {
|
|
93
|
+
scripts.push(`self.__next_f.push(${JSON.stringify([1, `chunk-${i}-${k}`])})`);
|
|
94
|
+
}
|
|
95
|
+
const html = `<html><body>${scripts.map(s => `<script>${s}</script>`).join('')}</body></html>`;
|
|
96
|
+
const raw = getRawFlightData(html, DOMParser);
|
|
97
|
+
expect(raw).not.toBeNull();
|
|
98
|
+
expect(raw.length).toBe(1 + n);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('Property: Next data round-trip', () => {
|
|
103
|
+
for (let i = 0; i < 100; i++) {
|
|
104
|
+
const obj = {
|
|
105
|
+
buildId: randAscii(randInt(1, 20)),
|
|
106
|
+
props: { pageProps: { n: i, s: randAscii(randInt(0, 10)) } }
|
|
107
|
+
};
|
|
108
|
+
const html = `<html><body><script id="__NEXT_DATA__">${JSON.stringify(obj)}</script></body></html>`;
|
|
109
|
+
const parsed = getNextData(html, DOMParser);
|
|
110
|
+
expect(parsed).toEqual(obj);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('Property: parseBuildManifest round-trip through eval wrapper', () => {
|
|
115
|
+
for (let i = 0; i < 50; i++) {
|
|
116
|
+
const data = { sortedPages: ['/', `/p${i}`], __rand: randAscii(6) };
|
|
117
|
+
const script = `self.__BUILD_MANIFEST=${JSON.stringify(data)};`;
|
|
118
|
+
const parsed = parseBuildManifest(script);
|
|
119
|
+
expect(parsed).toEqual(data);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('Property: getBuildManifestPath format', () => {
|
|
124
|
+
for (let i = 0; i < 50; i++) {
|
|
125
|
+
const buildId = randPathSegment(randInt(1, 16));
|
|
126
|
+
const basePath = randBool() ? '' : randPathLike(1, 2);
|
|
127
|
+
const p = getBuildManifestPath(buildId, basePath);
|
|
128
|
+
|
|
129
|
+
expect(p.startsWith('/')).toBe(true);
|
|
130
|
+
expect(p.endsWith(`/_next/static/${buildId}/_buildManifest.js`)).toBe(true);
|
|
131
|
+
|
|
132
|
+
if (basePath) {
|
|
133
|
+
expect(p.startsWith(`${basePath}/`)).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('Property: URL discovery completeness (every URL contains /_next/static/)', () => {
|
|
139
|
+
const html = loadFixture('nextjs.org.html');
|
|
140
|
+
const urls = getNextStaticUrls(html, DOMParser);
|
|
141
|
+
expect(urls).not.toBeNull();
|
|
142
|
+
for (const u of urls) expect(u.includes(_NS)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('Property: base path extraction consistency', () => {
|
|
146
|
+
for (let i = 0; i < 50; i++) {
|
|
147
|
+
const prefix = randBool() ? '' : `/${randAscii(randInt(1, 6))}`;
|
|
148
|
+
const build = randAscii(8);
|
|
149
|
+
const urls = [
|
|
150
|
+
`${prefix}${_NS}${build}/a.js`,
|
|
151
|
+
`${prefix}${_NS}${build}/b.js`
|
|
152
|
+
];
|
|
153
|
+
expect(getBasePath(urls)).toBe(prefix);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('Property: domain removal correctness', () => {
|
|
158
|
+
for (let i = 0; i < 50; i++) {
|
|
159
|
+
const prefix = `/${randAscii(randInt(1, 6))}`;
|
|
160
|
+
const build = randAscii(8);
|
|
161
|
+
const urls = [
|
|
162
|
+
`https://example.com${prefix}${_NS}${build}/a.js`,
|
|
163
|
+
`https://example.com${prefix}${_NS}${build}/b.js`
|
|
164
|
+
];
|
|
165
|
+
expect(getBasePath(urls, null, true)).toBe(prefix);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('Property: inconsistent prefixes are rejected', () => {
|
|
170
|
+
for (let i = 0; i < 50; i++) {
|
|
171
|
+
const build = randAscii(8);
|
|
172
|
+
const urls = [
|
|
173
|
+
`/a${_NS}${build}/a.js`,
|
|
174
|
+
`/b${_NS}${build}/b.js`
|
|
175
|
+
];
|
|
176
|
+
expect(() => getBasePath(urls)).toThrow();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('Property: Element properties are accessible', () => {
|
|
181
|
+
const cases = [
|
|
182
|
+
resolveType(["$", "$L1", null, null], null, 10),
|
|
183
|
+
resolveType(["$", "div", null, { id: "x" }], null, 11),
|
|
184
|
+
resolveType('$Sreact.suspense', null, 12),
|
|
185
|
+
resolveType(null, null, 13)
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const el of cases) {
|
|
189
|
+
expect(el).toBeTruthy();
|
|
190
|
+
expect('value' in el).toBe(true);
|
|
191
|
+
expect('value_class' in el).toBe(true);
|
|
192
|
+
expect('index' in el).toBe(true);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('Property: Recursive resolution completeness (DataContainer / DataParent)', () => {
|
|
197
|
+
const container = resolveType([null, '$S', ["$", "div", null, { id: 'x' }]], null, 22);
|
|
198
|
+
expect(container).toBeInstanceOf(DataContainer);
|
|
199
|
+
expect(container.value.every(v => typeof v === 'object')).toBe(true);
|
|
200
|
+
|
|
201
|
+
const parent = resolveType(["$", "$L1", null, { children: ["$", "div", null, { id: 'x' }] }], null, 23);
|
|
202
|
+
expect(parent).toBeInstanceOf(DataParent);
|
|
203
|
+
expect(parent.children).toBeTruthy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('Property: RSCPayload build ID extraction (old + new formats)', () => {
|
|
207
|
+
for (let i = 0; i < 50; i++) {
|
|
208
|
+
const buildId = randAscii(randInt(1, 20));
|
|
209
|
+
|
|
210
|
+
const oldVal = ["$", "$L1", null, { buildId }];
|
|
211
|
+
const old = resolveType(oldVal, null, 0);
|
|
212
|
+
expect(old).toBeInstanceOf(RSCPayload);
|
|
213
|
+
expect(old.build_id).toBe(buildId);
|
|
214
|
+
|
|
215
|
+
const newer = resolveType({ b: buildId }, null, 0);
|
|
216
|
+
expect(newer).toBeInstanceOf(RSCPayload);
|
|
217
|
+
expect(newer.build_id).toBe(buildId);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('Property: find/filter semantics (class filtering, callback filtering, recursive, non-recursive, first match)', () => {
|
|
222
|
+
const flightData = {
|
|
223
|
+
0: resolveType({ b: 'build' }, null, 0),
|
|
224
|
+
1: resolveType('hello', 'T', 1),
|
|
225
|
+
2: resolveType(["$", "div", null, { id: 'x' }], null, 2),
|
|
226
|
+
3: resolveType([resolveType('nested', 'T', null)], null, 3)
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// class filtering
|
|
230
|
+
const texts = findallInFlightData(flightData, [Text]);
|
|
231
|
+
expect(texts.every(t => t instanceof Text)).toBe(true);
|
|
232
|
+
|
|
233
|
+
// callback filtering
|
|
234
|
+
const onlyHello = findallInFlightData(flightData, null, (el) => el instanceof Text && el.text === 'hello');
|
|
235
|
+
expect(onlyHello.length).toBe(1);
|
|
236
|
+
|
|
237
|
+
// recursive vs non-recursive
|
|
238
|
+
const rec = findallInFlightData(flightData, [Text], null, true);
|
|
239
|
+
const nonRec = findallInFlightData(flightData, [Text], null, false);
|
|
240
|
+
expect(rec.length).toBeGreaterThanOrEqual(nonRec.length);
|
|
241
|
+
|
|
242
|
+
// first match
|
|
243
|
+
const first = findInFlightData(flightData, [Text]);
|
|
244
|
+
expect(first).toBeTruthy();
|
|
245
|
+
expect(first).toBeInstanceOf(Text);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('Property: buildId discovery works and priority is staticUrls > nextData > flightData', () => {
|
|
249
|
+
const flightBuild = 'flightBuild';
|
|
250
|
+
const nextBuild = 'nextBuild';
|
|
251
|
+
const staticBuild = 'staticBuild';
|
|
252
|
+
|
|
253
|
+
const html = `<!doctype html><html><head>
|
|
254
|
+
<script id="__NEXT_DATA__">${JSON.stringify({ buildId: nextBuild })}</script>
|
|
255
|
+
<link rel="preload" href="${_NS}${staticBuild}/_buildManifest.js" />
|
|
256
|
+
<script>(self.__next_f=self.__next_f||[]).push([0])</script>
|
|
257
|
+
<script>self.__next_f.push([1, ${JSON.stringify(`0:{\"b\":\"${flightBuild}\"}`)}])</script>
|
|
258
|
+
</head><body></body></html>`;
|
|
259
|
+
|
|
260
|
+
const found = findBuildId(html, DOMParser);
|
|
261
|
+
expect(found).toBe(staticBuild);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('Property: BeautifulFD iteration completeness + length accuracy', () => {
|
|
265
|
+
const el0 = resolveType({ b: 'b' }, null, 0);
|
|
266
|
+
const el1 = resolveType('hello', 'T', 1);
|
|
267
|
+
const fd = new BeautifulFD({ 0: el0, 1: el1 });
|
|
268
|
+
|
|
269
|
+
const items = Array.from(fd);
|
|
270
|
+
expect(items.length).toBe(fd.length);
|
|
271
|
+
expect(fd.length).toBe(2);
|
|
272
|
+
|
|
273
|
+
// Ensure key-value pairs
|
|
274
|
+
expect(items[0].length).toBe(2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('Property: getApiPath path format correctness + excluded paths + list completeness', () => {
|
|
278
|
+
const buildId = 'build123';
|
|
279
|
+
|
|
280
|
+
// format correctness
|
|
281
|
+
for (let i = 0; i < 50; i++) {
|
|
282
|
+
const basePath = randBool() ? '' : randPathLike(1, 2);
|
|
283
|
+
const p = randBool() ? randPathLike(1, 2) : randPathSegment(5);
|
|
284
|
+
const api = getApiPath(buildId, basePath, p);
|
|
285
|
+
if (api === null) continue;
|
|
286
|
+
expect(api.includes(`/_next/data/${buildId}`)).toBe(true);
|
|
287
|
+
expect(api.endsWith('.json')).toBe(true);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// excluded paths
|
|
291
|
+
for (const ex of ['/404', '/_app', '/_error', '/sitemap.xml', '/_middleware']) {
|
|
292
|
+
expect(getApiPath(buildId, '', ex)).toBeNull();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// list completeness
|
|
296
|
+
const pages = ['/', '/a', '/b', '/_app'];
|
|
297
|
+
const paths = listApiPaths(pages, buildId, '', true);
|
|
298
|
+
expect(paths.every(p => p.includes(`/_next/data/${buildId}`))).toBe(true);
|
|
299
|
+
});
|
package/tests/setup.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test setup and utilities
|
|
3
|
+
*/
|
|
4
|
+
// deno-dom's repo layout doesn't expose a Node-style package entry by default.
|
|
5
|
+
// For Bun tests we import the WASM build directly.
|
|
6
|
+
import { DOMParser } from 'deno-dom/deno-dom-wasm.ts';
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
// Export DOMParser for tests
|
|
11
|
+
export { DOMParser };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load test HTML file
|
|
15
|
+
* @param {string} filename - Name of the HTML file in test/src/
|
|
16
|
+
* @returns {string} HTML content
|
|
17
|
+
*/
|
|
18
|
+
export function loadTestHTML(filename) {
|
|
19
|
+
const path = join(process.cwd(), 'test', 'src', filename);
|
|
20
|
+
return readFileSync(path, 'utf-8');
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import { DOMParser } from './setup.js';
|
|
3
|
+
import { makeTree, join } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
test('makeTree parses HTML correctly', () => {
|
|
6
|
+
const html = '<html><body><div id="test">Hello</div></body></html>';
|
|
7
|
+
const doc = makeTree(html, DOMParser);
|
|
8
|
+
const div = doc.querySelector('#test');
|
|
9
|
+
expect(div).toBeTruthy();
|
|
10
|
+
expect(div.textContent).toBe('Hello');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('makeTree throws on non-string input', () => {
|
|
14
|
+
expect(() => makeTree(123, DOMParser)).toThrow(TypeError);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('join combines URL parts correctly', () => {
|
|
18
|
+
expect(join('a', 'b', 'c')).toBe('/a/b/c');
|
|
19
|
+
expect(join('/a/', '/b/', '/c/')).toBe('/a/b/c');
|
|
20
|
+
expect(join('', 'a', '', 'b')).toBe('/a/b');
|
|
21
|
+
expect(join()).toBe('/');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('join handles empty strings', () => {
|
|
25
|
+
expect(join('', '', '')).toBe('/');
|
|
26
|
+
expect(join('a', '', 'b')).toBe('/a/b');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('join handles leading/trailing slashes', () => {
|
|
30
|
+
expect(join('/a', 'b/', '/c')).toBe('/a/b/c');
|
|
31
|
+
expect(join('//a//', '//b//', '//c//')).toBe('/a/b/c');
|
|
32
|
+
});
|