Skip to content

Commit 8b35dfa

Browse files
committed
add early-hints component integration test
1 parent cd3aadc commit 8b35dfa

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* early-hints component integration test.
3+
*
4+
* Deploys early-hints and verifies hint lookup, versioning,
5+
* Safari mode, CRUD on SiteImages, multiple hints, same-origin URL
6+
* conversion, empty hints handling, and response length limits.
7+
*/
8+
import { suite, test, before, after } from 'node:test';
9+
import { strictEqual, ok, match, deepStrictEqual } from 'node:assert/strict';
10+
11+
import { startHarper, teardownHarper, sendOperation, type ContextWithHarper } from '../utils/harperLifecycle.ts';
12+
13+
const q = (url: string) => encodeURIComponent(url);
14+
15+
suite('Component: early-hints', (ctx: ContextWithHarper) => {
16+
before(async () => {
17+
await startHarper(ctx);
18+
19+
const deployBody = await sendOperation(ctx.harper, {
20+
operation: 'deploy_component',
21+
project: 'early-hints',
22+
package: 'https://github.com/ldt1996/template-early-hints',
23+
restart: true,
24+
});
25+
deepStrictEqual(deployBody, { message: 'Successfully deployed: early-hints, restarting Harper' });
26+
27+
// poll until /hints endpoint is registered and seed data is loaded
28+
const seedDeadline = Date.now() + 60_000;
29+
while (true) {
30+
try {
31+
const check = await fetch(`${ctx.harper.httpURL}/site-images/`);
32+
if (check.status === 200) {
33+
const data = await check.json();
34+
console.log(`[poll] status=200 isArray=${Array.isArray(data)} length=${Array.isArray(data) ? data.length : 'n/a'}`);
35+
if (Array.isArray(data) && data.length >= 3) break;
36+
}
37+
} catch {
38+
// server not yet accepting connections
39+
}
40+
if (Date.now() > seedDeadline) throw new Error('Timed out waiting for early-hints seed data');
41+
await new Promise((resolve) => setTimeout(resolve, 500));
42+
}
43+
await new Promise((resolve) => setTimeout(resolve, 2000));
44+
45+
const readyDeadline = Date.now() + 10_000;
46+
while (true) {
47+
try {
48+
const check = await fetch(`${ctx.harper.httpURL}/site-images/`);
49+
if (check.status === 200) break;
50+
} catch {
51+
// worker still restarting
52+
}
53+
if (Date.now() > readyDeadline) throw new Error('Timed out waiting for Harper to be ready after restart');
54+
await new Promise((resolve) => setTimeout(resolve, 200));
55+
}
56+
});
57+
58+
after(async () => {
59+
await teardownHarper(ctx);
60+
});
61+
62+
test('missing q param returns 400', async () => {
63+
const res = await fetch(`${ctx.harper.httpURL}/hints`);
64+
strictEqual(res.status, 400);
65+
const body = await res.json();
66+
ok(body.error.includes('Missing URL'), `expected missing URL error, got: ${body.error}`);
67+
});
68+
69+
test('unknown URL returns 404', async () => {
70+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.doesnotexist.com/')}`);
71+
strictEqual(res.status, 404);
72+
const body = await res.json();
73+
ok(body.error.includes('No early hints'), `expected no hints error, got: ${body.error}`);
74+
});
75+
76+
test('valid URL returns 200 with link header format', async () => {
77+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/')}`);
78+
strictEqual(res.status, 200);
79+
const body = await res.json();
80+
ok(typeof body === 'string', `expected string, got ${typeof body}`);
81+
match(body, /^<.*rel=preload;as=image;crossorigin>$/);
82+
});
83+
84+
test('explicit v=1 returns same result as default', async () => {
85+
const defaultRes = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/')}`);
86+
const defaultBody = await defaultRes.json();
87+
88+
const v1Res = await fetch(`${ctx.harper.httpURL}/hints?v=1&q=${q('https://www.harper.fast/')}`);
89+
strictEqual(v1Res.status, 200);
90+
const v1Body = await v1Res.json();
91+
92+
strictEqual(v1Body, defaultBody);
93+
});
94+
95+
test('v=2 with no data returns 404', async () => {
96+
const res = await fetch(`${ctx.harper.httpURL}/hints?v=2&q=${q('https://www.harper.fast/')}`);
97+
strictEqual(res.status, 404);
98+
});
99+
100+
test('safari mode s=1 returns preconnect hints', async () => {
101+
const res = await fetch(`${ctx.harper.httpURL}/hints?s=1&q=${q('https://www.harper.fast/')}`);
102+
strictEqual(res.status, 200);
103+
const body = await res.json();
104+
ok(typeof body === 'string', `expected string, got ${typeof body}`);
105+
match(body, /rel=preconnect/);
106+
ok(!body.includes('rel=preload'), 'safari mode should return preconnect, not preload');
107+
});
108+
109+
test('different pages return different hints', async () => {
110+
const homeRes = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/')}`);
111+
const homeBody = await homeRes.json();
112+
113+
const companyRes = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/company')}`);
114+
const companyBody = await companyRes.json();
115+
116+
ok(homeBody !== companyBody, 'expected different hints for different pages');
117+
});
118+
119+
test('SiteImages CRUD', async () => {
120+
// create
121+
const createRes = await fetch(`${ctx.harper.httpURL}/site-images/`, {
122+
method: 'POST',
123+
headers: { 'Content-Type': 'application/json' },
124+
body: JSON.stringify({
125+
cacheKey: '1|https://www.harper.fast/test-page',
126+
hintsVersion: 1,
127+
pageUrl: 'https://www.harper.fast/test-page',
128+
hints: ['https://cdn.example.com/test-hero.png'],
129+
}),
130+
});
131+
ok(createRes.status < 300, `create failed: ${createRes.status}`);
132+
133+
// read via /hints
134+
const hintsRes = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/test-page')}`);
135+
strictEqual(hintsRes.status, 200);
136+
const hintsBody = await hintsRes.json();
137+
ok(hintsBody.includes('test-hero.png'), `expected test-hero.png in response, got: ${hintsBody}`);
138+
139+
// delete
140+
const deleteRes = await fetch(`${ctx.harper.httpURL}/site-images/${q('1|https://www.harper.fast/test-page')}`, {
141+
method: 'DELETE',
142+
});
143+
strictEqual(deleteRes.status, 200);
144+
145+
// confirm deleted
146+
const deletedRes = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/test-page')}`);
147+
strictEqual(deletedRes.status, 404);
148+
});
149+
150+
test('multiple hints returned comma-joined', async () => {
151+
await fetch(`${ctx.harper.httpURL}/site-images/`, {
152+
method: 'POST',
153+
headers: { 'Content-Type': 'application/json' },
154+
body: JSON.stringify({
155+
cacheKey: '1|https://www.harper.fast/multi',
156+
hintsVersion: 1,
157+
pageUrl: 'https://www.harper.fast/multi',
158+
hints: ['https://cdn.example.com/img1.png', 'https://cdn.example.com/img2.png'],
159+
}),
160+
});
161+
162+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/multi')}`);
163+
strictEqual(res.status, 200);
164+
const body = await res.json();
165+
const parts = body.split(',');
166+
strictEqual(parts.length, 2, `expected 2 comma-separated hints, got ${parts.length}`);
167+
168+
// cleanup
169+
await fetch(`${ctx.harper.httpURL}/site-images/${q('1|https://www.harper.fast/multi')}`, { method: 'DELETE' });
170+
});
171+
172+
test('same-origin URL converted to relative path', async () => {
173+
await fetch(`${ctx.harper.httpURL}/site-images/`, {
174+
method: 'POST',
175+
headers: { 'Content-Type': 'application/json' },
176+
body: JSON.stringify({
177+
cacheKey: '1|https://www.harper.fast/relative',
178+
hintsVersion: 1,
179+
pageUrl: 'https://www.harper.fast/relative',
180+
hints: ['https://www.harper.fast/images/hero.png'],
181+
}),
182+
});
183+
184+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/relative')}`);
185+
strictEqual(res.status, 200);
186+
const body = await res.json();
187+
ok(body.includes('</images/hero.png;'), `expected relative path, got: ${body}`);
188+
ok(!body.includes('https://www.harper.fast'), `should not contain full origin, got: ${body}`);
189+
190+
// cleanup
191+
await fetch(`${ctx.harper.httpURL}/site-images/${q('1|https://www.harper.fast/relative')}`, { method: 'DELETE' });
192+
});
193+
194+
test('empty hints array returns 404', async () => {
195+
await fetch(`${ctx.harper.httpURL}/site-images/`, {
196+
method: 'POST',
197+
headers: { 'Content-Type': 'application/json' },
198+
body: JSON.stringify({
199+
cacheKey: '1|https://www.harper.fast/empty',
200+
hintsVersion: 1,
201+
pageUrl: 'https://www.harper.fast/empty',
202+
hints: [],
203+
}),
204+
});
205+
206+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/empty')}`);
207+
strictEqual(res.status, 404);
208+
209+
// cleanup
210+
await fetch(`${ctx.harper.httpURL}/site-images/${q('1|https://www.harper.fast/empty')}`, { method: 'DELETE' });
211+
});
212+
213+
test('response stays within 1024 char limit', async () => {
214+
const longHints = Array.from({ length: 8 }, (_, i) =>
215+
`https://cdn.example.com/image-with-a-really-long-name-that-keeps-going-${String(i).padStart(4, '0')}.png`
216+
);
217+
218+
await fetch(`${ctx.harper.httpURL}/site-images/`, {
219+
method: 'POST',
220+
headers: { 'Content-Type': 'application/json' },
221+
body: JSON.stringify({
222+
cacheKey: '1|https://www.harper.fast/long',
223+
hintsVersion: 1,
224+
pageUrl: 'https://www.harper.fast/long',
225+
hints: longHints,
226+
}),
227+
});
228+
229+
const res = await fetch(`${ctx.harper.httpURL}/hints?q=${q('https://www.harper.fast/long')}`);
230+
strictEqual(res.status, 200);
231+
const body = await res.json();
232+
ok(body.length <= 1024, `response ${body.length} chars exceeds 1024 limit`);
233+
234+
// cleanup
235+
await fetch(`${ctx.harper.httpURL}/site-images/${q('1|https://www.harper.fast/long')}`, { method: 'DELETE' });
236+
});
237+
});

0 commit comments

Comments
 (0)