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+
const fs = require("node:fs");
4+
const LOG = process.env.E2E_LOG;
5+
6+
function canned(p) {
7+
const base = { 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+
return base;
56+
}
57+
58+
global.fetch = async (url, opts = {}) => {
59+
const u = new URL(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+
const data = 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+
const path = require("node:path");
4+
const fs = require("node:fs");
5+
const { spawnSync } = require("node:child_process");
6+
7+
const ROOT = path.join(__dirname, "..");
8+
const BIN = path.join(ROOT, "dist", "bin.cjs");
9+
const STUB = path.join(__dirname, "fetch-stub.cjs");
10+
const LOG = "/tmp/e2e-requests.log";
11+
12+
const cases = require("./cases.cjs");
13+
14+
function run(args) {
15+
fs.writeFileSync(LOG, "");
16+
const env = { ...process.env, RECOUP_API_KEY: "test-key", E2E_LOG: LOG };
17+
const r = spawnSync("node", ["--require", STUB, BIN, ...args], {
18+
env, input: "", encoding: "utf8", timeout: 15000,
19+
});
20+
const log = fs.readFileSync(LOG, "utf8").trim();
21+
const reqs = log ? log.split("\n").map((l) => JSON.parse(l)) : [];
22+
return { code: r.status, stdout: r.stdout || "", stderr: r.stderr || "", reqs };
23+
}
24+
25+
let pass = 0, fail = 0;
26+
const rows = [];
27+
for (const [name, args, m, p, expectOk] of cases) {
28+
const { code, stdout, stderr, reqs } = run(args);
29+
let ok = true, note = "";
30+
if (expectOk) {
31+
if (code !== 0) { ok = false; note = `exit ${code}: ${stderr.trim().slice(0, 50)}`; }
32+
else if (reqs.length === 0) { ok = false; note = "no request made"; }
33+
else {
34+
const req = reqs[reqs.length - 1];
35+
if (req.method !== m) { ok = false; note = `method ${req.method}!=${m}`; }
36+
else if (req.path !== p) { ok = false; note = `path ${req.path}!=${p}`; }
37+
else if (req.apiKey !== "test-key") { ok = false; note = "missing x-api-key"; }
38+
else note = `${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+
else if (!/error:/i.test(stderr)) { ok = false; note = `no error msg (${stderr.slice(0, 30)})`; }
45+
else if (reqs.length > 0) { ok = false; note = "made network call before validating"; }
46+
else note = "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+
const w = Math.max(...rows.map((x) => x[1].length));
52+
const out = 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)