Skip to content

Commit 075b051

Browse files
committed
test(e2e): add no-network end-to-end smoke harness
Drives the real built dist/bin.cjs with a preloaded fetch stub (no sockets) and asserts method+path+x-api-key per command plus fast-fail validation. Covers all 22 command groups: 82/82 cases pass. Adds `pnpm e2e` script.
1 parent f4d831b commit 075b051

5 files changed

Lines changed: 241 additions & 0 deletions

File tree

‎e2e/README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# E2E smoke harness
2+
3+
Drives the **real built CLI binary** (`dist/bin.cjs`) end-to-end with a preloaded
4+
`fetch` stub (`node --require ./e2e/fetch-stub.cjs`). No network or sockets — the
5+
stub records each outbound request and returns canned JSON, so the test exercises
6+
the genuine argument parsing, HTTP client (method/path/query/body + `x-api-key`),
7+
output formatting, and fast-fail validation paths.
8+
9+
```bash
10+
pnpm e2e # builds, then runs e2e/run-stub.cjs
11+
```
12+
13+
-`cases.cjs``[name, argv, expectedMethod, expectedPath, expectOk]` matrix
14+
covering every command group plus validation/fast-fail cases.
15+
-`fetch-stub.cjs` — global `fetch` replacement: logs requests, returns canned
16+
responses.
17+
-`run-stub.cjs` — spawns the binary per case, asserts the recorded request and
18+
exit behavior, writes a report to `/tmp/e2e-report.txt`.
19+
20+
A happy-path case passes when the binary exits 0, makes exactly the expected
21+
`METHOD /path` call, and sends `x-api-key`. A validation case passes when the
22+
binary exits non-zero with an error message **before** making any network call.

‎e2e/cases.cjs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/* [name, argv, expectedMethod, expectedPath, expectOk] */
2+
module.exports=[
3+
["whoami",["whoami"],"GET","/api/accounts/id",true],
4+
["accounts get",["accounts","get","--account","acc_test"],"GET","/api/accounts/acc_test",true],
5+
["accounts credits",["accounts","credits","--account","acc_test"],"GET","/api/accounts/acc_test/credits",true],
6+
["accounts subscription",["accounts","subscription","--account","acc_test"],"GET","/api/accounts/acc_test/subscription",true],
7+
["accounts update",["accounts","update","--name","New"],"PATCH","/api/accounts",true],
8+
["artists list",["artists","list"],"GET","/api/artists",true],
9+
["artists create",["artists","create","--name","Daft Punk"],"POST","/api/artists",true],
10+
["artists update",["artists","update","a1","--label","Columbia","--pinned"],"PATCH","/api/artists/a1",true],
11+
["artists delete",["artists","delete","a1"],"DELETE","/api/artists/a1",true],
12+
["artists fans",["artists","fans","a1","--limit","5"],"GET","/api/artists/a1/fans",true],
13+
["artists scrape",["artists","scrape","--artist","a1"],"POST","/api/artist/socials/scrape",true],
14+
["chats list",["chats","list","--artist","a1"],"GET","/api/chats",true],
15+
["chats create",["chats","create","--name","T"],"POST","/api/chats",true],
16+
["chats update",["chats","update","--chat","c1","--topic","New topic"],"PATCH","/api/chats",true],
17+
["chats delete",["chats","delete","--chat","c1"],"DELETE","/api/chats",true],
18+
["chats messages",["chats","messages","--chat","c1"],"GET","/api/chats/c1/messages",true],
19+
["chats artist",["chats","artist","--chat","c1"],"GET","/api/chats/c1/artist",true],
20+
["chats compact",["chats","compact","--chat","c1","--chat","c2"],"POST","/api/chats/compact",true],
21+
["generate",["generate","--prompt","hi","--artist","a1"],"POST","/api/chat/generate",true],
22+
["tasks list",["tasks","list"],"GET","/api/tasks",true],
23+
["tasks create",["tasks","create","--title","Daily","--prompt","x","--schedule","0 9 * * *","--artist","a1"],"POST","/api/tasks",true],
24+
["tasks update",["tasks","update","--id","t1","--enabled","false"],"PATCH","/api/tasks",true],
25+
["tasks delete",["tasks","delete","--id","t1"],"DELETE","/api/tasks",true],
26+
["tasks runs",["tasks","runs","--limit","5"],"GET","/api/tasks/runs",true],
27+
["tasks status",["tasks","status","--run","run_1"],"GET","/api/tasks/runs",true],
28+
["pulses list",["pulses","list"],"GET","/api/pulses",true],
29+
["pulses set",["pulses","set","--active","true"],"PATCH","/api/pulses",true],
30+
["connectors list",["connectors","list"],"GET","/api/connectors",true],
31+
["connectors connect",["connectors","connect","--connector","googlesheets"],"POST","/api/connectors",true],
32+
["connectors actions",["connectors","actions"],"GET","/api/connectors/actions",true],
33+
["connectors run",["connectors","run","--action","GMAIL_FETCH_EMAILS","--params",'{"max_results":1}'],"POST","/api/connectors/actions",true],
34+
["connectors disconnect",["connectors","disconnect","--id","ca1"],"DELETE","/api/connectors",true],
35+
["research search",["research","search","--q","Daft Punk","--type","artists"],"GET","/api/research",true],
36+
["research profile",["research","profile","--artist","Daft Punk"],"GET","/api/research/profile",true],
37+
["research audience",["research","audience","--artist","x"],"GET","/api/research/audience",true],
38+
["research metrics",["research","metrics","--artist","x","--source","spotify"],"GET","/api/research/metrics",true],
39+
["research similar",["research","similar","--artist","x","--genre","high"],"GET","/api/research/similar",true],
40+
["research deep",["research","deep","--query","q"],"POST","/api/research/deep",true],
41+
["research web",["research","web","--query","news","--max-results","5"],"POST","/api/research/web",true],
42+
["research enrich",["research","enrich","--input","https://x","--schema",'{"type":"object"}'],"POST","/api/research/enrich",true],
43+
["research extract",["research","extract","--url","https://x"],"POST","/api/research/extract",true],
44+
["research track-stats",["research","track-stats","--isrc","US1","--source","spotify"],"GET","/api/research/track/stats",true],
45+
["research track-measurements",["research","track-measurements","--track","tk1","--window","30d"],"GET","/api/research/tracks/tk1/measurements",true],
46+
["research snapshots",["research","snapshots","--isrcs","US1,US2"],"POST","/api/research/snapshots",true],
47+
["spotify search",["spotify","search","--q","x","--type","artist"],"GET","/api/spotify/search",true],
48+
["spotify top-tracks",["spotify","top-tracks","--id","z","--market","US"],"GET","/api/spotify/artist/topTracks",true],
49+
["spotify album",["spotify","album","--id","al1"],"GET","/api/spotify/album",true],
50+
["songs list",["songs","list","--artist","a1"],"GET","/api/songs",true],
51+
["songs presets",["songs","presets"],"GET","/api/songs/analyze/presets",true],
52+
["catalogs create",["catalogs","create","--name","2024"],"POST","/api/catalogs",true],
53+
["catalogs songs",["catalogs","songs","--catalog","cat1"],"GET","/api/catalogs/songs",true],
54+
["catalogs add-songs",["catalogs","add-songs","--catalog","cat1","--songs",'[{"isrc":"US1"}]'],"POST","/api/catalogs/songs",true],
55+
["catalogs remove-songs",["catalogs","remove-songs","--catalog","cat1","--isrc","US1"],"DELETE","/api/catalogs/songs",true],
56+
["models list",["models","list"],"GET","/api/ai/models",true],
57+
["orgs list",["orgs","list"],"GET","/api/organizations",true],
58+
["orgs create",["orgs","create","--name","Label","--account","acc_test"],"POST","/api/organizations",true],
59+
["orgs add-artist",["orgs","add-artist","--artist","a1","--org","o1"],"POST","/api/organizations/artists",true],
60+
["workspaces create",["workspaces","create","--name","Q1"],"POST","/api/workspaces",true],
61+
["templates list",["templates","list"],"GET","/api/agents/templates",true],
62+
["templates create",["templates","create","--title","Recap","--description","Weekly recap of streams","--prompt","Produce a weekly streaming recap report","--tags","a,b"],"POST","/api/agents/templates",true],
63+
["templates favorite",["templates","favorite","tpl1"],"PUT","/api/agents/templates/tpl1/favorite",true],
64+
["templates delete",["templates","delete","tpl1"],"DELETE","/api/agents/templates/tpl1",true],
65+
["sessions create",["sessions","create","--title","Build"],"POST","/api/sessions",true],
66+
["sessions get",["sessions","get","s1"],"GET","/api/sessions/s1",true],
67+
["sessions update",["sessions","update","s1","--status","completed"],"PATCH","/api/sessions/s1",true],
68+
["sandboxes list",["sandboxes","list"],"GET","/api/sandboxes",true],
69+
["sandboxes file",["sandboxes","file","--path","README.md"],"GET","/api/sandboxes/file",true],
70+
["sandboxes setup",["sandboxes","setup"],"POST","/api/sandboxes/setup",true],
71+
["content templates",["content","templates"],"GET","/api/content/templates",true],
72+
["content template",["content","template","tpl"],"GET","/api/content/templates/tpl",true],
73+
["content caption",["content","caption","--topic","tour"],"POST","/api/content/caption",true],
74+
["content image",["content","image","--prompt","art","--num","2"],"POST","/api/content/image",true],
75+
["content transcribe",["content","transcribe","--audio","https://a.mp3"],"POST","/api/content/transcribe",true],
76+
["content edit",["content","edit","--video","https://v.mp4","--template","cut"],"PATCH","/api/content",true],
77+
["notifications",["notifications","--subject","Hi","--text","Body"],"POST","/api/notifications",true],
78+
// Fast-fail validation cases (must exit non-zero with Error:, no network call)
79+
["ERR generate no prompt",["generate"],null,null,false],
80+
["ERR track-stats no id",["research","track-stats","--source","spotify"],null,null,false],
81+
["ERR accounts update empty",["accounts","update"],null,null,false],
82+
["ERR catalogs create empty",["catalogs","create"],null,null,false],
83+
["ERR tasks create missing req",["tasks","create","--title","x"],null,null,false],
84+
["ERR research lookup no args",["research","lookup"],null,null,false],
85+
["ERR research snapshots no scope",["research","snapshots"],null,null,false],
86+
];

‎e2e/fetch-stub.cjs‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* Preloaded via `node --require`. Replaces global.fetch so the real CLI binary
2+
* runs end-to-end without any network. Logs each request to E2E_LOG. */
3+
constfs=require("node:fs");
4+
constLOG=process.env.E2E_LOG;
5+
6+
functioncanned(p){
7+
constbase={status: "success"};
8+
if(p==="/api/accounts/id")return{accountId: "acc_test"};
9+
if(/^\/api\/accounts\/[^/]+\/credits$/.test(p))return{remaining_credits: 50,used_credits: 10,total_credits: 60,is_pro: true};
10+
if(/^\/api\/accounts\/[^/]+\/subscription$/.test(p))return{isPro: true,status: "active",plan: "pro",source: "account"};
11+
if(/^\/api\/accounts\/[^/]+\/catalogs$/.test(p))return{ ...base,catalogs: []};
12+
if(/^\/api\/accounts\/[^/]+$/.test(p))return{ ...base,account: {account_id: "acc_test",name: "Test"}};
13+
if(p==="/api/accounts")return{data: {account_id: "acc_test",name: "Test"}};
14+
if(p==="/api/artists")return{ ...base,artists: [{account_id: "a1",name: "Daft Punk",label: "Columbia"}],artist: {account_id: "a1"}};
15+
if(/^\/api\/artists\/[^/]+\/fans$/.test(p))return{ ...base,fans: [],pagination: {page: 1,total_pages: 1,total_count: 0}};
16+
if(/^\/api\/artists\/[^/]+$/.test(p))return{ ...base,artist: {account_id: "a1"},success: true,artistId: "a1"};
17+
if(p==="/api/artist/socials/scrape")return[{runId: "r1"}];
18+
if(p==="/api/chats")return{ ...base,chats: [{id: "c1",topic: "T",updated_at: "2026-06-21"}],chat: {id: "c1"}};
19+
if(/^\/api\/chats\/[^/]+\/messages$/.test(p))return{data: []};
20+
if(/^\/api\/chats\/[^/]+\/artist$/.test(p))return{ ...base,room_id: "c1",artist_id: "a1",artist_exists: true};
21+
if(p==="/api/chats/compact")return{chats: [{chatId: "c1",compacted: true}]};
22+
if(p==="/api/chat/generate")return{ ...base,text: "Generated answer.",roomId: "c1"};
23+
if(p==="/api/tasks")return{ ...base,tasks: [{id: "t1",title: "Daily",schedule: "0 9 * * *",enabled: true}]};
24+
if(p==="/api/tasks/runs")return{ ...base,runs: [{id: "run_1",status: "COMPLETED",createdAt: "2026-06-21"}]};
25+
if(p==="/api/pulses")return{ ...base,pulses: [{account_id: "acc_test",active: true}]};
26+
if(p==="/api/connectors")return{success: true,connectors: [{slug: "googlesheets",connected: false}],data: {redirectUrl: "https://auth"}};
27+
if(p==="/api/connectors/actions")return{success: true,actions: [{slug: "GMAIL_FETCH_EMAILS",connectorSlug: "gmail",isConnected: true}],result: {ok: true}};
28+
if(p==="/api/research")return{ ...base,results: [{id: "x",name: "Daft Punk"}]};
29+
if(p.startsWith("/api/research/"))return{ ...base,content: "Deep answer",citations: [],results: [],tracks: []};
30+
if(p.startsWith("/api/spotify/"))return{ ...base,items: []};
31+
if(p==="/api/songs")return{ ...base,songs: [{isrc: "US1",name: "Track",album: "Album"}]};
32+
if(p==="/api/songs/analyze/presets")return{ ...base,presets: [{name: "catalog_metadata",description: "x",requiresAudio: false,responseFormat: "json"}]};
33+
if(p==="/api/catalogs")return{ ...base,catalog: {id: "cat1"},songs_added: 3};
34+
if(p==="/api/catalogs/songs")return{ ...base,songs: [{isrc: "US1",name: "Track",album: "Album"}]};
35+
if(p==="/api/ai/models")return{models: [{id: "m1",name: "Model One"}]};
36+
if(p==="/api/organizations")return{ ...base,organizations: [{organization_id: "o1",organization_name: "Label"}],organization: {id: "o1"}};
37+
if(p==="/api/organizations/artists")return{ ...base,id: "link1"};
38+
if(p==="/api/workspaces")return{workspace: {id: "w1",name: "Q1"}};
39+
if(p==="/api/agents/templates")return{ ...base,templates: [{id: "tpl1",title: "Recap",is_private: false,is_favourite: false}],template: {id: "tpl1"}};
40+
if(/^\/api\/agents\/templates\/[^/]+\/favorite$/.test(p))return{ ...base};
41+
if(/^\/api\/agents\/templates\/[^/]+$/.test(p))return{ ...base,template: {id: "tpl1"}};
42+
if(p==="/api/sandboxes")return{ ...base,sandboxes: [{sandboxId: "sb1",sandboxStatus: "running",createdAt: "2026-06-21"}]};
43+
if(p==="/api/sandboxes/file")return{ ...base,content: "file contents\n"};
44+
if(p==="/api/sandboxes/files")return{ ...base,uploaded: [{path: "a.png",sha: "abc"}]};
45+
if(p==="/api/sandboxes/setup")return{ ...base,runId: "setup1"};
46+
if(p==="/api/sessions")return{session: {id: "s1",title: "Build"},chat: {id: "c1"}};
47+
if(/^\/api\/sessions\/[^/]+$/.test(p))return{session: {id: "s1",title: "Build",status: "running"}};
48+
if(p==="/api/content/templates")return{ ...base,templates: [{name: "tpl",description: "d"}]};
49+
if(/^\/api\/content\/templates\/[^/]+$/.test(p))return{id: "tpl",name: "tpl"};
50+
if(p==="/api/content/caption")return{content: "A great caption"};
51+
if(p==="/api/content/image")return{imageUrl: "https://img/1.png",images: ["https://img/1.png"]};
52+
if(p==="/api/content/transcribe")return{transcript: "hello world"};
53+
if(p==="/api/content")return{runId: "edit1",status: "triggered"};
54+
if(p==="/api/notifications")return{ ...base,message: "Notification sent."};
55+
returnbase;
56+
}
57+
58+
global.fetch=async(url,opts={})=>{
59+
constu=newURL(url);
60+
fs.appendFileSync(
61+
LOG,
62+
JSON.stringify({
63+
method: opts.method||"GET",
64+
path: u.pathname,
65+
query: Object.fromEntries(u.searchParams.entries()),
66+
body: opts.body ? JSON.parse(opts.body) : null,
67+
apiKey: (opts.headers&&(opts.headers["x-api-key"]||opts.headers["X-Api-Key"]))||null,
68+
})+"\n",
69+
);
70+
constdata=canned(u.pathname);
71+
return{
72+
ok: true,
73+
status: 200,
74+
text: async()=>JSON.stringify(data),
75+
json: async()=>data,
76+
};
77+
};

‎e2e/run-stub.cjs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* E2E: runs the real built CLI binary with a preloaded fetch stub (no sockets).
2+
* Verifies method+path+x-api-key per command, and fast-fail validation. */
3+
constpath=require("node:path");
4+
constfs=require("node:fs");
5+
const{ spawnSync }=require("node:child_process");
6+
7+
constROOT=path.join(__dirname,"..");
8+
constBIN=path.join(ROOT,"dist","bin.cjs");
9+
constSTUB=path.join(__dirname,"fetch-stub.cjs");
10+
constLOG="/tmp/e2e-requests.log";
11+
12+
constcases=require("./cases.cjs");
13+
14+
functionrun(args){
15+
fs.writeFileSync(LOG,"");
16+
constenv={ ...process.env,RECOUP_API_KEY: "test-key",E2E_LOG: LOG};
17+
constr=spawnSync("node",["--require",STUB,BIN, ...args],{
18+
env,input: "",encoding: "utf8",timeout: 15000,
19+
});
20+
constlog=fs.readFileSync(LOG,"utf8").trim();
21+
constreqs=log ? log.split("\n").map((l)=>JSON.parse(l)) : [];
22+
return{code: r.status,stdout: r.stdout||"",stderr: r.stderr||"", reqs };
23+
}
24+
25+
letpass=0,fail=0;
26+
constrows=[];
27+
for(const[name,args,m,p,expectOk]ofcases){
28+
const{ code, stdout, stderr, reqs }=run(args);
29+
letok=true,note="";
30+
if(expectOk){
31+
if(code!==0){ok=false;note=`exit ${code}: ${stderr.trim().slice(0,50)}`;}
32+
elseif(reqs.length===0){ok=false;note="no request made";}
33+
else{
34+
constreq=reqs[reqs.length-1];
35+
if(req.method!==m){ok=false;note=`method ${req.method}!=${m}`;}
36+
elseif(req.path!==p){ok=false;note=`path ${req.path}!=${p}`;}
37+
elseif(req.apiKey!=="test-key"){ok=false;note="missing x-api-key";}
38+
elsenote=`${req.method}${req.path} out:${stdout.trim().split("\n")[0].slice(0,22)}`;
39+
}
40+
}else{
41+
// Accept both runAction's "Error:" and Commander's built-in "error:" — both
42+
// are correct fast-fails (non-zero exit, no network call made).
43+
if(code===0){ok=false;note="expected failure, exit 0";}
44+
elseif(!/error:/i.test(stderr)){ok=false;note=`no error msg (${stderr.slice(0,30)})`;}
45+
elseif(reqs.length>0){ok=false;note="made network call before validating";}
46+
elsenote="fast-fail: "+stderr.trim().split("\n")[0].replace(/^error:\s*/i,"").slice(0,46);
47+
}
48+
rows.push([ok ? "PASS" : "FAIL",name,note]);
49+
ok ? pass++ : fail++;
50+
}
51+
constw=Math.max(...rows.map((x)=>x[1].length));
52+
constout=rows.map(([s,n,note])=>`${s}${n.padEnd(w)}${note}`).join("\n")+`\n\n${pass}/${pass+fail} passed, ${fail} failed`;
53+
fs.writeFileSync("/tmp/e2e-report.txt",out+"\n");
54+
process.stdout.write(out+"\n");
55+
process.exit(fail ? 1 : 0);

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"scripts": {
1414
"build": "tsup",
1515
"test": "vitest run",
16+
"e2e": "pnpm build && node e2e/run-stub.cjs",
1617
"lint": "eslint . --ext .ts --fix",
1718
"format": "prettier --write \"src/**/*.ts\""
1819
},

0 commit comments

Comments
 (0)