runwork 0.9.4 → 0.10.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/dist/agents/__tests__/intro-skill.test.js +6 -2
- package/dist/agents/codex.js +9 -3
- package/dist/api/__tests__/client.test.js +10 -2
- package/dist/api/client.js +5 -4
- package/dist/auth/__tests__/login-flow.test.js +57 -81
- package/dist/auth/__tests__/store.test.js +35 -6
- package/dist/commands/__tests__/upgrade.test.js +25 -42
- package/dist/commands/clone.d.ts +2 -2
- package/dist/commands/clone.js +39 -7
- package/dist/commands/dev.js +9 -1
- package/dist/commands/endpoints.js +2 -1
- package/dist/commands/files.js +3 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +34 -5
- package/dist/commands/upgrade.js +4 -3
- package/dist/commands/welcome.js +2 -2
- package/dist/devtools/registry-data.d.ts +7 -0
- package/dist/devtools/registry-data.js +1 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credentials.test.js +4 -4
- package/dist/git/__tests__/identity.test.d.ts +1 -0
- package/dist/git/__tests__/identity.test.js +146 -0
- package/dist/git/__tests__/preflight.test.d.ts +1 -0
- package/dist/git/__tests__/preflight.test.js +36 -0
- package/dist/git/auto-commit.js +8 -2
- package/dist/git/credentials.js +1 -1
- package/dist/git/identity.d.ts +44 -0
- package/dist/git/identity.js +133 -0
- package/dist/git/preflight.d.ts +28 -0
- package/dist/git/preflight.js +50 -0
- package/dist/git/sync.js +3 -1
- package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
- package/dist/health/checks.js +3 -2
- package/dist/index.js +32 -1
- package/dist/utils/__tests__/format-error.test.d.ts +1 -0
- package/dist/utils/__tests__/format-error.test.js +43 -0
- package/dist/utils/__tests__/http.test.d.ts +1 -0
- package/dist/utils/__tests__/http.test.js +381 -0
- package/dist/utils/agent-guidance.js +10 -3
- package/dist/utils/format-error.d.ts +10 -0
- package/dist/utils/format-error.js +38 -0
- package/dist/utils/http.d.ts +46 -0
- package/dist/utils/http.js +421 -0
- package/package.json +3 -2
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Probe whether `git` is callable from this process. We use `execFileSync`
|
|
4
|
+
* (not `which`/`where.exe`) so the check follows the exact PATH lookup any
|
|
5
|
+
* subsequent git invocation will use -- that way we never report "found"
|
|
6
|
+
* when the real call would fail with ENOENT, and vice versa.
|
|
7
|
+
*/
|
|
8
|
+
export function probeGit() {
|
|
9
|
+
try {
|
|
10
|
+
const out = execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
return { installed: true, version: out.toString('utf-8').trim() };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
return { installed: false, error: err };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a beginner-friendly message explaining how to recover from a missing
|
|
19
|
+
* git binary. Includes a Windows-specific hint because the most common
|
|
20
|
+
* scenario there is "winget install Git.Git just succeeded but this shell's
|
|
21
|
+
* PATH was cached at launch" -- restarting the shell fixes it without a
|
|
22
|
+
* second install attempt.
|
|
23
|
+
*/
|
|
24
|
+
export function buildMissingGitMessage(commandName) {
|
|
25
|
+
const lines = [
|
|
26
|
+
`Git is required to run \`runwork ${commandName}\`, but it was not found on PATH.`,
|
|
27
|
+
'',
|
|
28
|
+
'Install Git, then re-run this command:',
|
|
29
|
+
' - macOS: brew install git (or: xcode-select --install)',
|
|
30
|
+
' - Windows: winget install --id Git.Git -e',
|
|
31
|
+
' - Linux: sudo apt-get install -y git (or your distro\'s equivalent)',
|
|
32
|
+
];
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
lines.push('', 'If you just installed Git, this shell\'s PATH was cached when it opened', 'and does not yet include Git. Close this PowerShell / Command Prompt', 'window, open a fresh one, and re-run the command.');
|
|
35
|
+
}
|
|
36
|
+
lines.push('', 'Verify the install with: git --version');
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Convenience wrapper for command entry points: probe git, and if it's
|
|
41
|
+
* missing, print the beginner-friendly message and exit with code 1.
|
|
42
|
+
* Returns void on success so callers can early-return on failure.
|
|
43
|
+
*/
|
|
44
|
+
export function requireGit(commandName) {
|
|
45
|
+
const probe = probeGit();
|
|
46
|
+
if (probe.installed)
|
|
47
|
+
return;
|
|
48
|
+
console.error(buildMissingGitMessage(commandName));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
package/dist/git/sync.js
CHANGED
|
@@ -147,7 +147,9 @@ export function syncWithRemote(cwd) {
|
|
|
147
147
|
}
|
|
148
148
|
let pushed = false;
|
|
149
149
|
try {
|
|
150
|
-
|
|
150
|
+
// `HEAD:main` so the push works regardless of local branch name
|
|
151
|
+
// (older Git defaults to `master`, some users keep custom defaults).
|
|
152
|
+
execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
|
|
151
153
|
pushed = true;
|
|
152
154
|
}
|
|
153
155
|
catch {
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the first-party distribution checks added to runwork doctor.
|
|
3
3
|
*
|
|
4
|
-
* These checks rely on fetch
|
|
5
|
-
*
|
|
6
|
-
* so the suite has no outbound network
|
|
4
|
+
* These checks rely on `httpFetch` (our node:https-backed fetch wrapper) to
|
|
5
|
+
* hit https://runwork.ai/cli/latest.json. We mock the wrapper to redirect
|
|
6
|
+
* those calls to a local HTTP server so the suite has no outbound network
|
|
7
|
+
* dependency. We still exercise the real wrapper end to end -- only the
|
|
8
|
+
* URL is rewritten -- so this remains an integration test of the network
|
|
9
|
+
* code path.
|
|
7
10
|
*/
|
|
8
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
12
|
import { createServer } from 'node:http';
|
|
13
|
+
let currentRedirectBase = null;
|
|
14
|
+
vi.mock('../../utils/http.js', async () => {
|
|
15
|
+
const actual = await vi.importActual('../../utils/http.js');
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
httpFetch: (url, init) => {
|
|
19
|
+
if (currentRedirectBase && url.startsWith('https://runwork.ai')) {
|
|
20
|
+
const redirected = url.replace('https://runwork.ai', currentRedirectBase);
|
|
21
|
+
return actual.httpFetch(redirected, init);
|
|
22
|
+
}
|
|
23
|
+
return actual.httpFetch(url, init);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
10
27
|
import { checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, } from '../checks.js';
|
|
11
28
|
function startServer() {
|
|
12
29
|
return new Promise((resolvePromise) => {
|
|
@@ -56,33 +73,14 @@ function startServer() {
|
|
|
56
73
|
});
|
|
57
74
|
});
|
|
58
75
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Redirect fetch calls to https://runwork.ai/... at the real test server.
|
|
61
|
-
* Returns a restore function.
|
|
62
|
-
*/
|
|
63
|
-
function redirectFetch(baseUrl) {
|
|
64
|
-
const realFetch = globalThis.fetch;
|
|
65
|
-
globalThis.fetch = ((input, init) => {
|
|
66
|
-
const href = typeof input === 'string' ? input : input.toString();
|
|
67
|
-
if (href.startsWith('https://runwork.ai')) {
|
|
68
|
-
const redirected = href.replace('https://runwork.ai', baseUrl);
|
|
69
|
-
return realFetch(redirected, init);
|
|
70
|
-
}
|
|
71
|
-
return realFetch(input, init);
|
|
72
|
-
});
|
|
73
|
-
return () => {
|
|
74
|
-
globalThis.fetch = realFetch;
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
76
|
describe('checkCliVersion (runwork.ai backed)', () => {
|
|
78
77
|
let server;
|
|
79
|
-
let restoreFetch;
|
|
80
78
|
beforeEach(async () => {
|
|
81
79
|
server = await startServer();
|
|
82
|
-
|
|
80
|
+
currentRedirectBase = server.url;
|
|
83
81
|
});
|
|
84
82
|
afterEach(async () => {
|
|
85
|
-
|
|
83
|
+
currentRedirectBase = null;
|
|
86
84
|
await server.stop();
|
|
87
85
|
});
|
|
88
86
|
it('skips with an explicit unreachable message when the manifest 404s', async () => {
|
|
@@ -117,13 +115,12 @@ describe('checkCliVersion (runwork.ai backed)', () => {
|
|
|
117
115
|
});
|
|
118
116
|
describe('checkCliArtifactReachable', () => {
|
|
119
117
|
let server;
|
|
120
|
-
let restoreFetch;
|
|
121
118
|
beforeEach(async () => {
|
|
122
119
|
server = await startServer();
|
|
123
|
-
|
|
120
|
+
currentRedirectBase = server.url;
|
|
124
121
|
});
|
|
125
122
|
afterEach(async () => {
|
|
126
|
-
|
|
123
|
+
currentRedirectBase = null;
|
|
127
124
|
await server.stop();
|
|
128
125
|
});
|
|
129
126
|
function platformKey() {
|
package/dist/health/checks.js
CHANGED
|
@@ -6,6 +6,7 @@ import { VERSION } from '../generated/version.js';
|
|
|
6
6
|
import { getCredentials } from '../auth/store.js';
|
|
7
7
|
import { ApiClient } from '../api/client.js';
|
|
8
8
|
import { detectAgents, getAdapterBySlug } from '../agents/detect.js';
|
|
9
|
+
import { httpFetch } from '../utils/http.js';
|
|
9
10
|
// First-party distribution endpoints. Defaults match the plan, can be
|
|
10
11
|
// overridden via RUNWORK_DOWNLOAD_BASE_URL for staging or debugging.
|
|
11
12
|
const BASE_URL = process.env.RUNWORK_DOWNLOAD_BASE_URL || 'https://runwork.ai';
|
|
@@ -23,7 +24,7 @@ function detectPlatform() {
|
|
|
23
24
|
}
|
|
24
25
|
async function fetchReleaseManifest() {
|
|
25
26
|
try {
|
|
26
|
-
const response = await
|
|
27
|
+
const response = await httpFetch(LATEST_JSON_URL, { cache: 'no-store' });
|
|
27
28
|
if (!response.ok)
|
|
28
29
|
return null;
|
|
29
30
|
return await response.json();
|
|
@@ -123,7 +124,7 @@ export async function checkCliArtifactReachable() {
|
|
|
123
124
|
}
|
|
124
125
|
const artifactUrl = `${BASE_URL}${entry.path}`;
|
|
125
126
|
try {
|
|
126
|
-
const response = await
|
|
127
|
+
const response = await httpFetch(artifactUrl, { method: 'HEAD' });
|
|
127
128
|
if (!response.ok) {
|
|
128
129
|
return {
|
|
129
130
|
name: 'cli-artifact',
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import { formatError } from './utils/format-error.js';
|
|
2
3
|
import { loginCommand } from './commands/login.js';
|
|
3
4
|
import { initCommand } from './commands/init.js';
|
|
4
5
|
import { cloneCommand } from './commands/clone.js';
|
|
@@ -30,6 +31,20 @@ import { handleGitCredentialRequest } from './git/credentials.js';
|
|
|
30
31
|
import { VERSION } from './generated/version.js';
|
|
31
32
|
import { shouldOutputJson, jsonOut } from './utils/output.js';
|
|
32
33
|
import { buildHelpJson, buildCommandHelpJson } from './utils/help-json.js';
|
|
34
|
+
// Surface escaped errors and rejections instead of letting the process exit
|
|
35
|
+
// silently. Without these handlers, an unhandled rejection inside a Commander
|
|
36
|
+
// async action vanishes on the bun-compiled Windows binary -- the user sees
|
|
37
|
+
// only the last `console.log` we managed to flush before the runtime tore
|
|
38
|
+
// the program down. Both handlers are deliberately strict (exit 1) so the
|
|
39
|
+
// failure is visible in CI / wrappers like the desktop app.
|
|
40
|
+
process.on('unhandledRejection', (reason) => {
|
|
41
|
+
console.error(`Unhandled rejection: ${formatError(reason)}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
|
44
|
+
process.on('uncaughtException', (err) => {
|
|
45
|
+
console.error(`Uncaught exception: ${formatError(err)}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
33
48
|
const program = new Command();
|
|
34
49
|
program
|
|
35
50
|
.name('runwork')
|
|
@@ -115,4 +130,20 @@ if (!hasCommand && !isHelpOrVersion && args.length === 0) {
|
|
|
115
130
|
process.exit(0);
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
|
-
|
|
133
|
+
// IMPORTANT: use parseAsync, not parse. Many of our actions are async and
|
|
134
|
+
// the bun-compiled Windows binary will exit before pending promises resolve
|
|
135
|
+
// when we don't await the returned promise -- that manifests as a silent
|
|
136
|
+
// abort partway through `runwork clone`, `dev`, etc. with no error output.
|
|
137
|
+
//
|
|
138
|
+
// Call with NO arguments so Commander reads `process.argv` itself with its
|
|
139
|
+
// `'auto'` detection. Passing `process.argv` explicitly defaults to the
|
|
140
|
+
// `'node'` parse mode and strips the wrong elements when this is run as a
|
|
141
|
+
// bun-compiled standalone binary (where there is no separate "script" argv
|
|
142
|
+
// slot), making every command silently fail to match.
|
|
143
|
+
try {
|
|
144
|
+
await program.parseAsync();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error(formatError(err));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatError } from '../format-error.js';
|
|
3
|
+
describe('formatError', () => {
|
|
4
|
+
it('handles plain Error objects', () => {
|
|
5
|
+
expect(formatError(new Error('boom'))).toBe('boom');
|
|
6
|
+
});
|
|
7
|
+
it('returns "unknown error" for null/undefined', () => {
|
|
8
|
+
expect(formatError(null)).toBe('unknown error');
|
|
9
|
+
expect(formatError(undefined)).toBe('unknown error');
|
|
10
|
+
});
|
|
11
|
+
it('extracts stderr buffer from execFileSync-style errors', () => {
|
|
12
|
+
const err = Object.assign(new Error('Command failed: git fetch'), {
|
|
13
|
+
stderr: Buffer.from('fatal: could not read Username\n'),
|
|
14
|
+
});
|
|
15
|
+
expect(formatError(err)).toContain('fatal: could not read Username');
|
|
16
|
+
});
|
|
17
|
+
it('extracts stderr string when not a buffer', () => {
|
|
18
|
+
const err = Object.assign(new Error('Command failed'), {
|
|
19
|
+
stderr: ' fatal: bad ref ',
|
|
20
|
+
});
|
|
21
|
+
expect(formatError(err)).toContain('fatal: bad ref');
|
|
22
|
+
});
|
|
23
|
+
it('falls back to stdout when stderr is empty', () => {
|
|
24
|
+
const err = Object.assign(new Error('Command failed'), {
|
|
25
|
+
stderr: '',
|
|
26
|
+
stdout: Buffer.from('CONFLICT (content): merge conflict in foo'),
|
|
27
|
+
});
|
|
28
|
+
expect(formatError(err)).toContain('CONFLICT');
|
|
29
|
+
});
|
|
30
|
+
it('prefixes with errno code when available', () => {
|
|
31
|
+
const err = Object.assign(new Error('spawn git ENOENT'), {
|
|
32
|
+
code: 'ENOENT',
|
|
33
|
+
stderr: 'spawn git ENOENT',
|
|
34
|
+
});
|
|
35
|
+
const out = formatError(err);
|
|
36
|
+
expect(out).toMatch(/^ENOENT:/);
|
|
37
|
+
expect(out).toContain('spawn git ENOENT');
|
|
38
|
+
});
|
|
39
|
+
it('coerces non-Error values to strings', () => {
|
|
40
|
+
expect(formatError('a plain string')).toBe('a plain string');
|
|
41
|
+
expect(formatError(42)).toBe('42');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { gzipSync, deflateSync, brotliCompressSync } from 'node:zlib';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { httpFetch, __setTransportForTests } from '../http.js';
|
|
6
|
+
let server;
|
|
7
|
+
let port;
|
|
8
|
+
const calls = [];
|
|
9
|
+
function startServer() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
server = createServer(async (req, res) => {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of req)
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
16
|
+
calls.push({ url: req.url || '', method: req.method || '', headers: req.headers, body });
|
|
17
|
+
const url = req.url || '/';
|
|
18
|
+
// Simple endpoints exercised by the test cases below.
|
|
19
|
+
if (url === '/json') {
|
|
20
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
21
|
+
res.end(JSON.stringify({ hello: 'world' }));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (url === '/text') {
|
|
25
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
26
|
+
res.end('plain body');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (url === '/binary') {
|
|
30
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
31
|
+
res.end(Buffer.from([1, 2, 3, 4, 5]));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (url === '/echo-headers') {
|
|
35
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify({ headers: req.headers, method: req.method }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (url === '/echo-body') {
|
|
40
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
41
|
+
res.end(JSON.stringify({ body, contentType: req.headers['content-type'] }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (url === '/status/404') {
|
|
45
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
46
|
+
res.end('not found');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (url === '/status/500') {
|
|
50
|
+
res.writeHead(500, { 'content-type': 'text/plain' });
|
|
51
|
+
res.end('boom');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (url === '/redirect/302') {
|
|
55
|
+
res.writeHead(302, { location: '/json' });
|
|
56
|
+
res.end();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (url === '/redirect/303-post') {
|
|
60
|
+
res.writeHead(303, { location: '/echo-headers' });
|
|
61
|
+
res.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (url === '/redirect/307') {
|
|
65
|
+
res.writeHead(307, { location: '/echo-body' });
|
|
66
|
+
res.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (url === '/redirect/loop') {
|
|
70
|
+
res.writeHead(302, { location: '/redirect/loop' });
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (url === '/gzip') {
|
|
75
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'gzip' });
|
|
76
|
+
res.end(gzipSync(Buffer.from('compressed-gzip-body')));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (url === '/deflate') {
|
|
80
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'deflate' });
|
|
81
|
+
res.end(deflateSync(Buffer.from('compressed-deflate-body')));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (url === '/brotli') {
|
|
85
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'br' });
|
|
86
|
+
res.end(brotliCompressSync(Buffer.from('compressed-brotli-body')));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (url === '/head') {
|
|
90
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'x-marker': 'present' });
|
|
91
|
+
res.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end();
|
|
96
|
+
});
|
|
97
|
+
server.listen(0, '127.0.0.1', () => {
|
|
98
|
+
const addr = server.address();
|
|
99
|
+
if (typeof addr === 'object' && addr && 'port' in addr) {
|
|
100
|
+
port = addr.port;
|
|
101
|
+
}
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
beforeAll(async () => { await startServer(); });
|
|
107
|
+
afterAll(() => new Promise((resolve) => server.close(() => resolve())));
|
|
108
|
+
function url(path) { return `http://127.0.0.1:${port}${path}`; }
|
|
109
|
+
describe('httpFetch — basic Response surface', () => {
|
|
110
|
+
it('parses JSON responses', async () => {
|
|
111
|
+
const res = await httpFetch(url('/json'));
|
|
112
|
+
expect(res.ok).toBe(true);
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
expect(res.headers.get('content-type')).toContain('application/json');
|
|
115
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
116
|
+
});
|
|
117
|
+
it('reads text responses', async () => {
|
|
118
|
+
const res = await httpFetch(url('/text'));
|
|
119
|
+
expect(res.ok).toBe(true);
|
|
120
|
+
expect(await res.text()).toBe('plain body');
|
|
121
|
+
});
|
|
122
|
+
it('reads binary responses as ArrayBuffer with the exact byte content', async () => {
|
|
123
|
+
const res = await httpFetch(url('/binary'));
|
|
124
|
+
const ab = await res.arrayBuffer();
|
|
125
|
+
expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
126
|
+
});
|
|
127
|
+
it('returns ok=false for 4xx without throwing', async () => {
|
|
128
|
+
const res = await httpFetch(url('/status/404'));
|
|
129
|
+
expect(res.ok).toBe(false);
|
|
130
|
+
expect(res.status).toBe(404);
|
|
131
|
+
expect(await res.text()).toBe('not found');
|
|
132
|
+
});
|
|
133
|
+
it('returns ok=false for 5xx without throwing', async () => {
|
|
134
|
+
const res = await httpFetch(url('/status/500'));
|
|
135
|
+
expect(res.ok).toBe(false);
|
|
136
|
+
expect(res.status).toBe(500);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('httpFetch — request shape', () => {
|
|
140
|
+
it('passes through plain object headers, lowercased per HTTP convention', async () => {
|
|
141
|
+
const res = await httpFetch(url('/echo-headers'), {
|
|
142
|
+
headers: { 'X-Custom-Foo': 'bar', Authorization: 'Bearer abc' },
|
|
143
|
+
});
|
|
144
|
+
const data = await res.json();
|
|
145
|
+
expect(data.method).toBe('GET');
|
|
146
|
+
expect(data.headers['x-custom-foo']).toBe('bar');
|
|
147
|
+
expect(data.headers['authorization']).toBe('Bearer abc');
|
|
148
|
+
});
|
|
149
|
+
it('passes through Headers instance', async () => {
|
|
150
|
+
const headers = new Headers();
|
|
151
|
+
headers.set('X-One', 'a');
|
|
152
|
+
headers.set('X-Two', 'b');
|
|
153
|
+
const res = await httpFetch(url('/echo-headers'), { headers });
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
expect(data.headers['x-one']).toBe('a');
|
|
156
|
+
expect(data.headers['x-two']).toBe('b');
|
|
157
|
+
});
|
|
158
|
+
it('uppercases lowercase methods', async () => {
|
|
159
|
+
const res = await httpFetch(url('/echo-headers'), { method: 'post', body: '' });
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
expect(data.method).toBe('POST');
|
|
162
|
+
});
|
|
163
|
+
it('sends string body and content-length', async () => {
|
|
164
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ a: 1 }),
|
|
168
|
+
});
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
expect(data.body).toBe('{"a":1}');
|
|
171
|
+
expect(data.contentType).toBe('application/json');
|
|
172
|
+
});
|
|
173
|
+
it('sends Buffer body', async () => {
|
|
174
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
175
|
+
method: 'PUT',
|
|
176
|
+
body: Buffer.from('raw-buffer'),
|
|
177
|
+
});
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
expect(data.body).toBe('raw-buffer');
|
|
180
|
+
});
|
|
181
|
+
it('sends Uint8Array body with offset', async () => {
|
|
182
|
+
const backing = new Uint8Array([0, 0, 65, 66, 67, 0]);
|
|
183
|
+
const view = backing.subarray(2, 5); // "ABC"
|
|
184
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: view });
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
expect(data.body).toBe('ABC');
|
|
187
|
+
});
|
|
188
|
+
it('sends ArrayBuffer body', async () => {
|
|
189
|
+
const ab = new TextEncoder().encode('via-arraybuffer').buffer;
|
|
190
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: ab });
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
expect(data.body).toBe('via-arraybuffer');
|
|
193
|
+
});
|
|
194
|
+
it('drops body on bodyless GET/HEAD methods (matches fetch behavior)', async () => {
|
|
195
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
196
|
+
method: 'GET',
|
|
197
|
+
// GET requests with bodies are non-conformant; fetch silently drops the body.
|
|
198
|
+
body: 'should-not-be-sent',
|
|
199
|
+
});
|
|
200
|
+
const data = await res.json();
|
|
201
|
+
expect(data.body).toBe('');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('httpFetch — redirects', () => {
|
|
205
|
+
it('follows a 302 to the final URL', async () => {
|
|
206
|
+
const res = await httpFetch(url('/redirect/302'));
|
|
207
|
+
expect(res.status).toBe(200);
|
|
208
|
+
expect(res.url.endsWith('/json')).toBe(true);
|
|
209
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
210
|
+
});
|
|
211
|
+
it('downgrades POST to GET on 303 (matches fetch semantics)', async () => {
|
|
212
|
+
const res = await httpFetch(url('/redirect/303-post'), {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: 'ignored-after-303',
|
|
215
|
+
});
|
|
216
|
+
const data = await res.json();
|
|
217
|
+
expect(data.method).toBe('GET');
|
|
218
|
+
});
|
|
219
|
+
it('preserves method and body on 307', async () => {
|
|
220
|
+
const res = await httpFetch(url('/redirect/307'), {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: 'preserved-on-307',
|
|
223
|
+
});
|
|
224
|
+
const data = await res.json();
|
|
225
|
+
expect(data.body).toBe('preserved-on-307');
|
|
226
|
+
});
|
|
227
|
+
it('rejects after exceeding the redirect limit', async () => {
|
|
228
|
+
// The endpoint redirects to itself; after MAX_REDIRECTS we stop following
|
|
229
|
+
// and return the redirect response itself rather than crash.
|
|
230
|
+
const res = await httpFetch(url('/redirect/loop'));
|
|
231
|
+
expect(res.status).toBe(302);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
describe('httpFetch — content decoding', () => {
|
|
235
|
+
it('advertises gzip/deflate/br by default', async () => {
|
|
236
|
+
await httpFetch(url('/echo-headers'));
|
|
237
|
+
const last = calls[calls.length - 1];
|
|
238
|
+
expect(last.headers['accept-encoding']).toBe('gzip, deflate, br');
|
|
239
|
+
});
|
|
240
|
+
it('decompresses gzip responses transparently', async () => {
|
|
241
|
+
const res = await httpFetch(url('/gzip'));
|
|
242
|
+
expect(await res.text()).toBe('compressed-gzip-body');
|
|
243
|
+
});
|
|
244
|
+
it('decompresses brotli responses transparently', async () => {
|
|
245
|
+
const res = await httpFetch(url('/brotli'));
|
|
246
|
+
expect(await res.text()).toBe('compressed-brotli-body');
|
|
247
|
+
});
|
|
248
|
+
it('decompresses deflate responses transparently', async () => {
|
|
249
|
+
const res = await httpFetch(url('/deflate'));
|
|
250
|
+
expect(await res.text()).toBe('compressed-deflate-body');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('httpFetch — HEAD requests', () => {
|
|
254
|
+
it('returns headers and an empty body', async () => {
|
|
255
|
+
const res = await httpFetch(url('/head'), { method: 'HEAD' });
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
expect(res.headers.get('x-marker')).toBe('present');
|
|
258
|
+
expect(await res.text()).toBe('');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe('httpFetch — errors', () => {
|
|
262
|
+
it('rejects on connection failure', async () => {
|
|
263
|
+
// 127.0.0.1:1 is virtually guaranteed to refuse connections.
|
|
264
|
+
await expect(httpFetch('http://127.0.0.1:1/')).rejects.toThrow();
|
|
265
|
+
});
|
|
266
|
+
it('rejects on unsupported protocols', async () => {
|
|
267
|
+
await expect(httpFetch('ftp://example.com/')).rejects.toThrow(/Unsupported protocol/);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
271
|
+
// curl-transport tests
|
|
272
|
+
//
|
|
273
|
+
// `curl` is the opt-in escape hatch (`RUNWORK_HTTP_TRANSPORT=curl`). It
|
|
274
|
+
// shells out to the system `curl` so HTTP runs in a subprocess, immune to
|
|
275
|
+
// any future runtime regression. These tests force the curl path via the
|
|
276
|
+
// test-only override so the break-glass path stays exercised in CI even
|
|
277
|
+
// though the default transport is `node:https`. Skipped when curl is
|
|
278
|
+
// missing on the host.
|
|
279
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
280
|
+
const curlAvailable = (() => {
|
|
281
|
+
try {
|
|
282
|
+
const r = spawnSync('curl', ['--version'], { encoding: 'utf-8' });
|
|
283
|
+
return r.status === 0;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
describe.skipIf(!curlAvailable)('httpFetch — curl transport (opt-in via env var)', () => {
|
|
290
|
+
beforeAll(() => { __setTransportForTests('curl'); });
|
|
291
|
+
afterAll(() => { __setTransportForTests('auto'); });
|
|
292
|
+
it('parses JSON responses through curl', async () => {
|
|
293
|
+
const res = await httpFetch(url('/json'));
|
|
294
|
+
expect(res.ok).toBe(true);
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
297
|
+
});
|
|
298
|
+
it('reads binary responses with exact byte content', async () => {
|
|
299
|
+
const res = await httpFetch(url('/binary'));
|
|
300
|
+
const ab = await res.arrayBuffer();
|
|
301
|
+
expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
302
|
+
});
|
|
303
|
+
it('returns ok=false for 4xx without throwing', async () => {
|
|
304
|
+
const res = await httpFetch(url('/status/404'));
|
|
305
|
+
expect(res.ok).toBe(false);
|
|
306
|
+
expect(res.status).toBe(404);
|
|
307
|
+
expect(await res.text()).toBe('not found');
|
|
308
|
+
});
|
|
309
|
+
it('follows 302 redirects to the final URL', async () => {
|
|
310
|
+
const res = await httpFetch(url('/redirect/302'));
|
|
311
|
+
expect(res.status).toBe(200);
|
|
312
|
+
expect(res.url.endsWith('/json')).toBe(true);
|
|
313
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
314
|
+
});
|
|
315
|
+
it('downgrades POST to GET on 303 (matches fetch semantics)', async () => {
|
|
316
|
+
const res = await httpFetch(url('/redirect/303-post'), {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
body: 'ignored-after-303',
|
|
319
|
+
});
|
|
320
|
+
const data = await res.json();
|
|
321
|
+
expect(data.method).toBe('GET');
|
|
322
|
+
});
|
|
323
|
+
it('preserves method and body on 307', async () => {
|
|
324
|
+
const res = await httpFetch(url('/redirect/307'), {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
body: 'preserved-on-307',
|
|
327
|
+
});
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
expect(data.body).toBe('preserved-on-307');
|
|
330
|
+
});
|
|
331
|
+
it('passes plain object headers through', async () => {
|
|
332
|
+
const res = await httpFetch(url('/echo-headers'), {
|
|
333
|
+
headers: { 'X-Custom': 'curl-roundtrip', Authorization: 'Bearer abc' },
|
|
334
|
+
});
|
|
335
|
+
const data = await res.json();
|
|
336
|
+
expect(data.headers['x-custom']).toBe('curl-roundtrip');
|
|
337
|
+
expect(data.headers['authorization']).toBe('Bearer abc');
|
|
338
|
+
});
|
|
339
|
+
it('sends string body and content-type', async () => {
|
|
340
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'content-type': 'application/json' },
|
|
343
|
+
body: JSON.stringify({ a: 1 }),
|
|
344
|
+
});
|
|
345
|
+
const data = await res.json();
|
|
346
|
+
expect(data.body).toBe('{"a":1}');
|
|
347
|
+
expect(data.contentType).toBe('application/json');
|
|
348
|
+
});
|
|
349
|
+
it('sends binary Uint8Array body with offset', async () => {
|
|
350
|
+
const backing = new Uint8Array([0, 0, 65, 66, 67, 0]);
|
|
351
|
+
const view = backing.subarray(2, 5); // "ABC"
|
|
352
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: view });
|
|
353
|
+
const data = await res.json();
|
|
354
|
+
expect(data.body).toBe('ABC');
|
|
355
|
+
});
|
|
356
|
+
it('returns headers and an empty body for HEAD', async () => {
|
|
357
|
+
const res = await httpFetch(url('/head'), { method: 'HEAD' });
|
|
358
|
+
expect(res.status).toBe(200);
|
|
359
|
+
expect(res.headers.get('x-marker')).toBe('present');
|
|
360
|
+
expect(await res.text()).toBe('');
|
|
361
|
+
});
|
|
362
|
+
it('warns and returns raw bytes on unexpected gzip', async () => {
|
|
363
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
364
|
+
try {
|
|
365
|
+
const res = await httpFetch(url('/gzip'));
|
|
366
|
+
expect(res.ok).toBe(true);
|
|
367
|
+
const ab = await res.arrayBuffer();
|
|
368
|
+
expect(ab.byteLength).toBeGreaterThan(0);
|
|
369
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/Content-Encoding: gzip/));
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
stderr.mockRestore();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
it('rejects on connection failure', async () => {
|
|
376
|
+
await expect(httpFetch('http://127.0.0.1:1/')).rejects.toThrow();
|
|
377
|
+
});
|
|
378
|
+
it('rejects on unsupported protocols', async () => {
|
|
379
|
+
await expect(httpFetch('ftp://example.com/')).rejects.toThrow(/Unsupported protocol/);
|
|
380
|
+
});
|
|
381
|
+
});
|