|
| 1 | +/** |
| 2 | + * Integration test: `remote --report` uploads its zip to the deployed |
| 3 | + * diagnostics Worker. |
| 4 | + * |
| 5 | + * Runs the built CLI against the live services: the report's network checks go |
| 6 | + * to mcp.desktopcommander.app (MCP_SERVER_URL can point elsewhere), and the |
| 7 | + * upload to the Worker's default address (DC_DIAGNOSTICS_URL can point |
| 8 | + * elsewhere). It never uses the real home folder: a temporary HOME / |
| 9 | + * USERPROFILE holds a device.json that looks like a real sign-in, with ids |
| 10 | + * fixed and reserved for tests, never real Supabase ids: device |
| 11 | + * 00000000-0000-0000-0000-000000000001, and a fake, unsigned access token |
| 12 | + * whose payload has sub 00000000-0000-0000-0000-000000000000 (the CLI only |
| 13 | + * reads `sub`; nothing signs in or refreshes). Uploads land like any report, |
| 14 | + * under <user>/<device>/<YYYY-MM-DD-HHMM>-<id>.zip. |
| 15 | + * |
| 16 | + * 1. remote --report: exit 0, the zip in the temporary home, and |
| 17 | + * "Sent to Desktop Commander support. Report id: <8 chars>". |
| 18 | + * 2. remote --report --no-upload: the zip, no "Sent" line. |
| 19 | + * 3. A non-zip POSTed straight to the Worker: 415. |
| 20 | + * |
| 21 | + * Needs a built dist/ (npm run test:integration builds it). Prints the report |
| 22 | + * id so it can be found in the bucket. |
| 23 | + */ |
| 24 | + |
| 25 | +import assert from 'assert'; |
| 26 | +import { spawn } from 'child_process'; |
| 27 | +import fs from 'fs'; |
| 28 | +import os from 'os'; |
| 29 | +import path from 'path'; |
| 30 | +import { fileURLToPath } from 'url'; |
| 31 | + |
| 32 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 33 | +const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); |
| 34 | +const CLI = path.join(PROJECT_ROOT, 'dist', 'index.js'); |
| 35 | +/** Reserved for tests: never a real Supabase user or device. */ |
| 36 | +const TEST_USER_ID = '00000000-0000-0000-0000-000000000000'; |
| 37 | +const TEST_DEVICE_ID = '00000000-0000-0000-0000-000000000001'; |
| 38 | +/** The Worker's report ids: 8 characters without 0, 1, I and O. */ |
| 39 | +const REPORT_ID = /\nSent to Desktop Commander support\. Report id: ([23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8})\n/; |
| 40 | +const RUN_TIMEOUT_MS = 120_000; |
| 41 | + |
| 42 | +/** A JWT-shaped token: the CLI decodes the payload's `sub` and never verifies it. */ |
| 43 | +function fakeAccessToken(sub) { |
| 44 | + return [{ alg: 'none', typ: 'JWT' }, { sub, exp: 1 }] |
| 45 | + .map((part) => Buffer.from(JSON.stringify(part)).toString('base64url')) |
| 46 | + .join('.') + '.unsigned'; |
| 47 | +} |
| 48 | + |
| 49 | +function makeHome() { |
| 50 | + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-integration-report-home-')); |
| 51 | + const deviceDir = path.join(home, '.desktop-commander-device'); |
| 52 | + fs.mkdirSync(deviceDir, { recursive: true }); |
| 53 | + fs.writeFileSync(path.join(deviceDir, 'device.json'), JSON.stringify({ |
| 54 | + deviceId: TEST_DEVICE_ID, |
| 55 | + session: { access_token: fakeAccessToken(TEST_USER_ID), refresh_token: 'test-refresh-token' }, |
| 56 | + }, null, 2)); |
| 57 | + return home; |
| 58 | +} |
| 59 | + |
| 60 | +function runReport(home, args = []) { |
| 61 | + return new Promise((resolve) => { |
| 62 | + const { FORCE_COLOR, ...inherited } = process.env; |
| 63 | + const child = spawn(process.execPath, [CLI, 'remote', '--report', ...args], { |
| 64 | + cwd: home, |
| 65 | + env: { |
| 66 | + ...inherited, |
| 67 | + HOME: home, |
| 68 | + USERPROFILE: home, |
| 69 | + DESKTOP_COMMANDER_DISABLE_TELEMETRY: '1', |
| 70 | + DC_FLAG_URL: 'http://127.0.0.1:9/', |
| 71 | + }, |
| 72 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 73 | + }); |
| 74 | + let output = ''; |
| 75 | + child.stdout.on('data', (d) => { output += d; }); |
| 76 | + child.stderr.on('data', (d) => { output += d; }); |
| 77 | + const timer = setTimeout(() => child.kill(), RUN_TIMEOUT_MS); |
| 78 | + child.on('close', (code) => { |
| 79 | + clearTimeout(timer); |
| 80 | + resolve({ code, output }); |
| 81 | + }); |
| 82 | + }); |
| 83 | +} |
| 84 | + |
| 85 | +function savedZip(result, home) { |
| 86 | + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; |
| 87 | + assert.ok(saved, `a "Saved:" line:\n${result.output}`); |
| 88 | + assert.ok(saved.startsWith(home), `the zip is in the temporary home: ${saved}`); |
| 89 | + assert.ok(fs.existsSync(saved), `the zip exists: ${saved}`); |
| 90 | + return saved; |
| 91 | +} |
| 92 | + |
| 93 | +async function main() { |
| 94 | + const { DEFAULT_DIAGNOSTICS_URL } = await import('../../dist/remote-device/diagnostics/upload.js'); |
| 95 | + const workerUrl = process.env.DC_DIAGNOSTICS_URL || DEFAULT_DIAGNOSTICS_URL; |
| 96 | + console.log(`Worker: ${workerUrl}${process.env.MCP_SERVER_URL ? `, server: ${process.env.MCP_SERVER_URL}` : ''}`); |
| 97 | + const home = makeHome(); |
| 98 | + try { |
| 99 | + console.log('\n[Case 1] remote --report uploads the zip'); |
| 100 | + const sent = await runReport(home); |
| 101 | + console.log(sent.output.split('\n').filter((line) => /^(Saved|Sent|Give|Not sent)/.test(line)).join('\n')); |
| 102 | + assert.strictEqual(sent.code, 0, `exit code ${sent.code}:\n${sent.output}`); |
| 103 | + savedZip(sent, home); |
| 104 | + const id = sent.output.match(REPORT_ID)?.[1]; |
| 105 | + assert.ok(id, `a report id from the Worker:\n${sent.output}`); |
| 106 | + console.log(`[Case 1] PASS - report id ${id}`); |
| 107 | + |
| 108 | + console.log('\n[Case 2] remote --report --no-upload only saves the zip'); |
| 109 | + const kept = await runReport(home, ['--no-upload']); |
| 110 | + assert.strictEqual(kept.code, 0, `exit code ${kept.code}:\n${kept.output}`); |
| 111 | + savedZip(kept, home); |
| 112 | + assert.ok(!/Sent to|Not sent/.test(kept.output), `no upload line:\n${kept.output}`); |
| 113 | + assert.match(kept.output, /Reply to your support conversation with this file attached\./); |
| 114 | + console.log('[Case 2] PASS - saved, not sent'); |
| 115 | + |
| 116 | + console.log('\n[Case 3] a non-zip POSTed straight to the Worker'); |
| 117 | + const response = await fetch(workerUrl, { |
| 118 | + method: 'POST', |
| 119 | + headers: { 'Content-Type': 'application/zip', 'X-DC-User-Id': TEST_USER_ID, 'X-DC-Device-Id': TEST_DEVICE_ID }, |
| 120 | + body: 'not a zip', |
| 121 | + signal: AbortSignal.timeout(30_000), |
| 122 | + }); |
| 123 | + assert.strictEqual(response.status, 415, `415 for a body that doesn't start with PK, got ${response.status}`); |
| 124 | + console.log('[Case 3] PASS - 415'); |
| 125 | + |
| 126 | + console.log(`\nAll assertions passed. Report id: ${id}`); |
| 127 | + } finally { |
| 128 | + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +main().catch((err) => { |
| 133 | + console.error('\nTEST FAILED:', err && err.message ? err.message : err); |
| 134 | + process.exit(1); |
| 135 | +}); |
0 commit comments