Skip to content

Commit fbe3a6f

Browse files
authored
Add manifest endpoint test helper (#33)
1 parent 25fbce6 commit fbe3a6f

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ npm test
138138
npm run build
139139
```
140140

141+
### Manifest endpoint testing
142+
143+
To post the current lockfile manifest to a local Patchstack API endpoint and provision a new site:
144+
145+
```bash
146+
bun run test:manifest -- --endpoint http://localhost:8000/monitor/pulse/manifest
147+
```
148+
149+
The response should include the new site UUID. To re-test an existing site, pass that UUID explicitly:
150+
151+
```bash
152+
bun run test:manifest -- --endpoint http://localhost:8000/monitor/pulse/manifest --site-uuid YOUR_REAL_UUID
153+
```
154+
155+
Use `--dry-run` to preview the payload without posting.
156+
141157
## Release process
142158

143159
Pull requests run typecheck, tests, build, package verification, and a production dependency audit in GitHub Actions.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"build": "tsup",
3636
"dev": "tsup --watch",
3737
"test": "vitest run",
38+
"test:manifest": "bun scripts/test-manifest.ts",
3839
"test:watch": "vitest",
3940
"typecheck": "tsc --noEmit",
4041
"prepare": "tsup",

scripts/test-manifest.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env bun
2+
3+
import { buildWirePayload } from '../src/normalize.ts';
4+
import { scanLockfile } from '../src/parsers/index.ts';
5+
6+
const DEFAULT_URL = 'https://api.patchstack.com/monitor/pulse/manifest';
7+
const DEFAULT_TIMEOUT_MS = 30_000;
8+
9+
type TestEnvironment = 'production' | 'sandbox';
10+
11+
interface Options {
12+
url: string;
13+
siteUuid?: string;
14+
provisioning: boolean;
15+
timeoutMs: number;
16+
environment: TestEnvironment;
17+
dryRun: boolean;
18+
}
19+
20+
async function main(): Promise<number> {
21+
const options = parseOptions(process.argv.slice(2));
22+
23+
const manifest = await scanLockfile(process.cwd());
24+
for (const warning of manifest.warnings ?? []) {
25+
console.warn(`patchstack test: ${warning}`);
26+
}
27+
28+
const { payload, stats } = buildWirePayload(manifest);
29+
const body = { ...payload, environment: options.environment };
30+
31+
console.log(`Manifest test URL: ${options.url}`);
32+
console.log(`Site UUID: ${options.siteUuid ?? '(auto-provision)'}`);
33+
console.log(`Environment: ${options.environment}`);
34+
console.log(
35+
`Payload: ${payload.packages.length} package version(s), ${stats.uniqueNames} package name(s)`,
36+
);
37+
38+
if (options.dryRun) {
39+
console.log('');
40+
console.log('--dry-run: not posting. Payload preview:');
41+
printPayloadPreview(body);
42+
return 0;
43+
}
44+
45+
const response = await fetch(options.url, {
46+
method: 'POST',
47+
headers: {
48+
'Content-Type': 'application/json',
49+
Accept: 'application/json',
50+
'User-Agent': '@patchstack/connect manifest-test',
51+
},
52+
body: JSON.stringify(body),
53+
signal: AbortSignal.timeout(options.timeoutMs),
54+
});
55+
56+
const text = await response.text();
57+
const responseBody = parseResponseBody(text);
58+
console.log('');
59+
console.log(`Response: ${response.status} ${response.statusText}`);
60+
if (text.length > 0) {
61+
console.log(formatResponseBody(text, responseBody));
62+
}
63+
64+
if (response.ok && options.provisioning) {
65+
const uuid = getResponseUuid(responseBody);
66+
if (uuid !== null) {
67+
console.log('');
68+
console.log(`Provisioned site UUID: ${uuid}`);
69+
console.log(
70+
`Re-test this site with: bun run test:manifest -- --endpoint ${stripSiteUuidFromManifestUrl(options.url)} --site-uuid ${uuid}`,
71+
);
72+
}
73+
}
74+
75+
return response.ok ? 0 : 1;
76+
}
77+
78+
function parseOptions(args: string[]): Options {
79+
let url =
80+
process.env.PATCHSTACK_TEST_MANIFEST_URL ??
81+
process.env.PATCHSTACK_ENDPOINT ??
82+
DEFAULT_URL;
83+
let siteUuid =
84+
process.env.PATCHSTACK_TEST_SITE_UUID ??
85+
process.env.PATCHSTACK_SITE_UUID;
86+
let dryRun = false;
87+
88+
for (let i = 0; i < args.length; i++) {
89+
const arg = args[i]!;
90+
91+
if (arg === '--help' || arg === '-h') {
92+
printHelp();
93+
process.exit(0);
94+
}
95+
96+
if (arg === '--dry-run') {
97+
dryRun = true;
98+
continue;
99+
}
100+
101+
if (arg === '--url' || arg === '--endpoint') {
102+
const next = args[i + 1];
103+
if (next === undefined || next.startsWith('--')) {
104+
throw new Error(`${arg} requires a URL value.`);
105+
}
106+
url = next;
107+
i++;
108+
continue;
109+
}
110+
111+
if (arg === '--site-uuid') {
112+
const next = args[i + 1];
113+
if (next === undefined || next.startsWith('--')) {
114+
throw new Error(`${arg} requires a UUID value.`);
115+
}
116+
siteUuid = next;
117+
i++;
118+
continue;
119+
}
120+
121+
if (arg.startsWith('--url=')) {
122+
url = arg.slice('--url='.length);
123+
continue;
124+
}
125+
126+
if (arg.startsWith('--endpoint=')) {
127+
url = arg.slice('--endpoint='.length);
128+
continue;
129+
}
130+
131+
if (arg.startsWith('--site-uuid=')) {
132+
siteUuid = arg.slice('--site-uuid='.length);
133+
continue;
134+
}
135+
136+
if (!arg.startsWith('--')) {
137+
url = arg;
138+
continue;
139+
}
140+
141+
throw new Error(`Unknown option: ${arg}`);
142+
}
143+
144+
validateUrl(url);
145+
siteUuid = siteUuid !== undefined && siteUuid.length > 0 ? siteUuid : undefined;
146+
const provisioning = siteUuid === undefined;
147+
const finalUrl = provisioning ? stripSiteUuidFromManifestUrl(url) : addSiteUuidToUrl(url, siteUuid);
148+
149+
return {
150+
url: finalUrl,
151+
siteUuid,
152+
provisioning,
153+
timeoutMs: readTimeoutMs(),
154+
environment: readEnvironment(),
155+
dryRun,
156+
};
157+
}
158+
159+
function readTimeoutMs(): number {
160+
const raw = process.env.PATCHSTACK_TIMEOUT_MS;
161+
if (raw === undefined || raw.length === 0) {
162+
return DEFAULT_TIMEOUT_MS;
163+
}
164+
165+
const parsed = Number(raw);
166+
if (!Number.isFinite(parsed) || parsed <= 0) {
167+
throw new Error(`PATCHSTACK_TIMEOUT_MS must be a positive number; got "${raw}".`);
168+
}
169+
return parsed;
170+
}
171+
172+
function readEnvironment(): TestEnvironment {
173+
const value =
174+
process.env.PATCHSTACK_TEST_ENVIRONMENT ??
175+
process.env.PATCHSTACK_ENVIRONMENT ??
176+
'sandbox';
177+
178+
if (value === 'production' || value === 'sandbox') {
179+
return value;
180+
}
181+
182+
throw new Error(
183+
`PATCHSTACK_TEST_ENVIRONMENT must be "production" or "sandbox"; got "${value}".`,
184+
);
185+
}
186+
187+
function validateUrl(value: string): void {
188+
try {
189+
new URL(value);
190+
} catch {
191+
throw new Error(`Manifest test URL must be a full URL; got "${value}".`);
192+
}
193+
}
194+
195+
function addSiteUuidToUrl(value: string, siteUuid: string): string {
196+
const parsed = new URL(value);
197+
const encodedUuid = encodeURIComponent(siteUuid);
198+
const trimmedPath = parsed.pathname.replace(/\/+$/, '');
199+
200+
if (trimmedPath.endsWith('/monitor/pulse/manifest')) {
201+
parsed.pathname = `${trimmedPath}/${encodedUuid}`;
202+
return parsed.toString();
203+
}
204+
205+
if (/\/monitor\/pulse\/manifest\/[^/]+$/.test(trimmedPath)) {
206+
parsed.pathname = trimmedPath.replace(/\/[^/]+$/, `/${encodedUuid}`);
207+
return parsed.toString();
208+
}
209+
210+
parsed.pathname = `${trimmedPath}/${encodedUuid}`;
211+
return parsed.toString();
212+
}
213+
214+
function stripSiteUuidFromManifestUrl(value: string): string {
215+
const parsed = new URL(value);
216+
const trimmedPath = parsed.pathname.replace(/\/+$/, '');
217+
218+
if (/\/monitor\/pulse\/manifest\/[^/]+$/.test(trimmedPath)) {
219+
parsed.pathname = trimmedPath.replace(/\/[^/]+$/, '');
220+
return parsed.toString();
221+
}
222+
223+
return value;
224+
}
225+
226+
function parseResponseBody(text: string): unknown {
227+
try {
228+
return JSON.parse(text);
229+
} catch {
230+
return null;
231+
}
232+
}
233+
234+
function formatResponseBody(text: string, parsedBody: unknown): string {
235+
if (parsedBody === null) {
236+
return text;
237+
}
238+
return JSON.stringify(parsedBody, null, 2);
239+
}
240+
241+
function getResponseUuid(parsedBody: unknown): string | null {
242+
if (
243+
typeof parsedBody === 'object' &&
244+
parsedBody !== null &&
245+
'uuid' in parsedBody &&
246+
typeof parsedBody.uuid === 'string' &&
247+
parsedBody.uuid.length > 0
248+
) {
249+
return parsedBody.uuid;
250+
}
251+
return null;
252+
}
253+
254+
function printPayloadPreview(body: unknown): void {
255+
const preview = JSON.stringify(body, null, 2).split('\n');
256+
console.log(preview.slice(0, Math.min(preview.length, 30)).join('\n'));
257+
if (preview.length > 30) {
258+
console.log(` ... (${preview.length - 30} more lines)`);
259+
}
260+
}
261+
262+
function printHelp(): void {
263+
console.log(`Usage:
264+
bun run test:manifest [url]
265+
bun run test:manifest -- --url <url>
266+
bun run test:manifest -- --site-uuid <uuid>
267+
268+
Environment:
269+
PATCHSTACK_TEST_MANIFEST_URL Full URL to POST to
270+
PATCHSTACK_TEST_SITE_UUID Existing site UUID to append to the endpoint
271+
PATCHSTACK_TEST_ENVIRONMENT production | sandbox (default: sandbox)
272+
PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000)
273+
274+
If no site UUID is provided, the script posts to the bare manifest endpoint so
275+
the API can provision a new site and return its UUID.
276+
277+
Examples:
278+
bun run test:manifest
279+
bun run test:manifest -- --endpoint http://localhost:8000/monitor/pulse/manifest
280+
bun run test:manifest -- --endpoint http://localhost:8000/monitor/pulse/manifest --site-uuid 550e8400-e29b-41d4-a716-446655440000
281+
`);
282+
}
283+
284+
main().then(
285+
(code) => {
286+
process.exitCode = code;
287+
},
288+
(error: unknown) => {
289+
console.error(`Manifest test failed: ${(error as Error).message}`);
290+
process.exitCode = 1;
291+
},
292+
);

0 commit comments

Comments
 (0)