|
4 | 4 | // a dependency or an API that needs a newer runtime fails here rather than on |
5 | 5 | // a user's machine; the test suite itself runs under bun and cannot tell. |
6 | 6 |
|
7 | | -const { spawnSync } = require('node:child_process'); |
| 7 | +const { spawn, spawnSync } = require('node:child_process'); |
8 | 8 | const fs = require('node:fs'); |
9 | 9 | const path = require('node:path'); |
10 | 10 |
|
@@ -66,3 +66,248 @@ function runCli(args) { |
66 | 66 | runCli(['help']); |
67 | 67 | runCli(['-v']); |
68 | 68 | console.log('smoke: `help` and `-v` ran without warnings'); |
| 69 | + |
| 70 | +// --- proxy scenario --------------------------------------------------------- |
| 71 | +// Node's built-in fetch ignores HTTP(S)_PROXY, so the CLI routes every request |
| 72 | +// itself (src/utils/runtime.ts): every client opens a CONNECT tunnel through |
| 73 | +// the proxy. A mock API and a local forward proxy show that the API call, the |
| 74 | +// registry check and the multipart upload all tunnel through the proxy, and |
| 75 | +// that NO_PROXY switches it off again. |
| 76 | + |
| 77 | +const http = require('node:http'); |
| 78 | +const net = require('node:net'); |
| 79 | +const os = require('node:os'); |
| 80 | + |
| 81 | +function listen(server) { |
| 82 | + return new Promise((resolve) => |
| 83 | + server.listen(0, '127.0.0.1', () => resolve(server.address().port)), |
| 84 | + ); |
| 85 | +} |
| 86 | + |
| 87 | +function createMockApi(requests) { |
| 88 | + return http.createServer((req, res) => { |
| 89 | + requests.push(`${req.method} ${req.url}`); |
| 90 | + const json = (status, body) => { |
| 91 | + res.writeHead(status, { 'content-type': 'application/json' }); |
| 92 | + res.end(JSON.stringify(body)); |
| 93 | + }; |
| 94 | + const url = req.url ?? '/'; |
| 95 | + if (url.startsWith('/registry/')) { |
| 96 | + return json(200, { versions: {}, 'dist-tags': { latest: '0.0.0' } }); |
| 97 | + } |
| 98 | + if (url === '/api/app/list') { |
| 99 | + return json(200, { |
| 100 | + data: [{ id: 100, name: 'DemoApp', platform: 'android' }], |
| 101 | + }); |
| 102 | + } |
| 103 | + if (url === '/api/upload') { |
| 104 | + return json(200, { |
| 105 | + url: `http://${req.headers.host}/oss/upload`, |
| 106 | + formData: { key: 'hash-from-oss' }, |
| 107 | + }); |
| 108 | + } |
| 109 | + if (url === '/oss/upload') { |
| 110 | + req.resume(); |
| 111 | + req.on('end', () => { |
| 112 | + res.writeHead(204); |
| 113 | + res.end(); |
| 114 | + }); |
| 115 | + return; |
| 116 | + } |
| 117 | + if (url === '/api/app/100/version/create') { |
| 118 | + req.resume(); |
| 119 | + req.on('end', () => json(200, { id: 1 })); |
| 120 | + return; |
| 121 | + } |
| 122 | + json(404, { message: `unhandled ${url}` }); |
| 123 | + }); |
| 124 | +} |
| 125 | + |
| 126 | +function createForwardProxy(seen) { |
| 127 | + const proxy = http.createServer((req, res) => { |
| 128 | + seen.push(`${req.method} ${req.url}`); |
| 129 | + const target = new URL(req.url); |
| 130 | + // hop-by-hop headers are not forwarded; the client below frames the body |
| 131 | + // itself (a copied transfer-encoding would make the upstream reject it) |
| 132 | + const { |
| 133 | + connection: _connection, |
| 134 | + 'proxy-connection': _proxyConnection, |
| 135 | + 'keep-alive': _keepAlive, |
| 136 | + 'transfer-encoding': _transferEncoding, |
| 137 | + ...headers |
| 138 | + } = req.headers; |
| 139 | + const upstream = http.request( |
| 140 | + { |
| 141 | + host: target.hostname, |
| 142 | + port: target.port, |
| 143 | + path: `${target.pathname}${target.search}`, |
| 144 | + method: req.method, |
| 145 | + headers: { ...headers, host: target.host }, |
| 146 | + }, |
| 147 | + (response) => { |
| 148 | + res.writeHead(response.statusCode ?? 502, response.headers); |
| 149 | + response.pipe(res); |
| 150 | + }, |
| 151 | + ); |
| 152 | + upstream.on('error', (error) => { |
| 153 | + res.writeHead(502); |
| 154 | + res.end(String(error)); |
| 155 | + }); |
| 156 | + req.pipe(upstream); |
| 157 | + }); |
| 158 | + proxy.on('connect', (req, socket, head) => { |
| 159 | + seen.push(`CONNECT ${req.url}`); |
| 160 | + const [host, port] = req.url.split(':'); |
| 161 | + const tunnel = net.connect(Number(port), host, () => { |
| 162 | + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); |
| 163 | + if (head?.length) tunnel.write(head); |
| 164 | + socket.pipe(tunnel); |
| 165 | + tunnel.pipe(socket); |
| 166 | + }); |
| 167 | + tunnel.on('error', () => socket.destroy()); |
| 168 | + socket.on('error', () => tunnel.destroy()); |
| 169 | + }); |
| 170 | + return proxy; |
| 171 | +} |
| 172 | + |
| 173 | +async function proxyScenario() { |
| 174 | + const apiRequests = []; |
| 175 | + const seenByProxy = []; |
| 176 | + const api = createMockApi(apiRequests); |
| 177 | + const proxy = createForwardProxy(seenByProxy); |
| 178 | + const apiPort = await listen(api); |
| 179 | + const proxyPort = await listen(proxy); |
| 180 | + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-smoke-project-')); |
| 181 | + const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-smoke-cache-')); |
| 182 | + fs.writeFileSync( |
| 183 | + path.join(project, 'update.json'), |
| 184 | + JSON.stringify({ android: { appId: 100, appKey: 'key' } }), |
| 185 | + ); |
| 186 | + fs.writeFileSync(path.join(project, 'bundle.ppk'), 'fake-ppk'); |
| 187 | + const baseEnv = { |
| 188 | + ...process.env, |
| 189 | + NO_INTERACTIVE: 'true', |
| 190 | + RNU_AUTO_UPDATE: '0', |
| 191 | + PUSHY_REGISTRY: `http://127.0.0.1:${apiPort}/api`, |
| 192 | + npm_config_registry: `http://127.0.0.1:${apiPort}/registry/`, |
| 193 | + XDG_CACHE_HOME: cache, |
| 194 | + HTTP_PROXY: `http://127.0.0.1:${proxyPort}`, |
| 195 | + http_proxy: `http://127.0.0.1:${proxyPort}`, |
| 196 | + HTTPS_PROXY: '', |
| 197 | + https_proxy: '', |
| 198 | + NO_PROXY: '', |
| 199 | + no_proxy: '', |
| 200 | + }; |
| 201 | + // asynchronous: spawnSync would block this process, and with it the mock |
| 202 | + // API and the proxy the CLI is talking to |
| 203 | + const run = (args, env) => |
| 204 | + new Promise((resolve, reject) => { |
| 205 | + const child = spawn( |
| 206 | + process.execPath, |
| 207 | + [path.join(lib, 'bin.js'), ...args], |
| 208 | + { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] }, |
| 209 | + ); |
| 210 | + let stdout = ''; |
| 211 | + let stderr = ''; |
| 212 | + child.stdout.setEncoding('utf8'); |
| 213 | + child.stderr.setEncoding('utf8'); |
| 214 | + child.stdout.on('data', (chunk) => { |
| 215 | + stdout += chunk; |
| 216 | + }); |
| 217 | + child.stderr.on('data', (chunk) => { |
| 218 | + stderr += chunk; |
| 219 | + }); |
| 220 | + const timer = setTimeout(() => { |
| 221 | + child.kill('SIGTERM'); |
| 222 | + reject(new Error(`pushy ${args.join(' ')} timed out`)); |
| 223 | + }, 30_000); |
| 224 | + child.on('error', (error) => { |
| 225 | + clearTimeout(timer); |
| 226 | + reject(error); |
| 227 | + }); |
| 228 | + child.on('close', (status) => { |
| 229 | + clearTimeout(timer); |
| 230 | + if (status !== 0) { |
| 231 | + console.error(stdout); |
| 232 | + console.error(stderr); |
| 233 | + reject(new Error(`pushy ${args.join(' ')} exited with ${status}`)); |
| 234 | + return; |
| 235 | + } |
| 236 | + resolve({ stdout, stderr }); |
| 237 | + }); |
| 238 | + }); |
| 239 | + // tunnels to the mock host: the API client (undici), the registry check |
| 240 | + // (http.get) and the upload (node-fetch) each open their own |
| 241 | + const tunnels = () => |
| 242 | + seenByProxy.filter((line) => line === `CONNECT 127.0.0.1:${apiPort}`) |
| 243 | + .length; |
| 244 | + try { |
| 245 | + await run(['apps', '--no-interactive'], baseEnv); |
| 246 | + if (tunnels() < 2) { |
| 247 | + throw new Error( |
| 248 | + `the API call or the registry check bypassed HTTP_PROXY: ${JSON.stringify(seenByProxy)}`, |
| 249 | + ); |
| 250 | + } |
| 251 | + if (!apiRequests.includes('GET /api/app/list')) { |
| 252 | + throw new Error( |
| 253 | + `apps did not reach the API: ${JSON.stringify(apiRequests)}`, |
| 254 | + ); |
| 255 | + } |
| 256 | + seenByProxy.length = 0; |
| 257 | + apiRequests.length = 0; |
| 258 | + await run( |
| 259 | + [ |
| 260 | + 'publish', |
| 261 | + 'bundle.ppk', |
| 262 | + '--platform', |
| 263 | + 'android', |
| 264 | + '--name', |
| 265 | + 'v1', |
| 266 | + '--no-interactive', |
| 267 | + ], |
| 268 | + baseEnv, |
| 269 | + ); |
| 270 | + if (tunnels() < 2) { |
| 271 | + throw new Error( |
| 272 | + `the upload bypassed HTTP_PROXY: ${JSON.stringify(seenByProxy)}`, |
| 273 | + ); |
| 274 | + } |
| 275 | + if (!apiRequests.includes('POST /oss/upload')) { |
| 276 | + throw new Error( |
| 277 | + `the upload did not arrive: ${JSON.stringify(apiRequests)}`, |
| 278 | + ); |
| 279 | + } |
| 280 | + if (!apiRequests.includes('POST /api/app/100/version/create')) { |
| 281 | + throw new Error( |
| 282 | + `publish did not reach the API: ${JSON.stringify(apiRequests)}`, |
| 283 | + ); |
| 284 | + } |
| 285 | + seenByProxy.length = 0; |
| 286 | + apiRequests.length = 0; |
| 287 | + await run(['apps', '--no-interactive'], { |
| 288 | + ...baseEnv, |
| 289 | + NO_PROXY: '127.0.0.1', |
| 290 | + }); |
| 291 | + if (seenByProxy.length > 0) { |
| 292 | + throw new Error(`NO_PROXY was ignored: ${JSON.stringify(seenByProxy)}`); |
| 293 | + } |
| 294 | + if (!apiRequests.includes('GET /api/app/list')) { |
| 295 | + throw new Error( |
| 296 | + `the API call did not arrive directly: ${JSON.stringify(apiRequests)}`, |
| 297 | + ); |
| 298 | + } |
| 299 | + console.log( |
| 300 | + 'smoke: API, registry check and upload honor HTTP_PROXY and NO_PROXY', |
| 301 | + ); |
| 302 | + } finally { |
| 303 | + api.close(); |
| 304 | + proxy.close(); |
| 305 | + fs.rmSync(project, { recursive: true, force: true }); |
| 306 | + fs.rmSync(cache, { recursive: true, force: true }); |
| 307 | + } |
| 308 | +} |
| 309 | + |
| 310 | +proxyScenario().catch((error) => { |
| 311 | + console.error(error); |
| 312 | + process.exit(1); |
| 313 | +}); |
0 commit comments