|
1 | 1 | import '../testing/git-env.ts'; |
2 | 2 | import assert from 'node:assert/strict'; |
3 | | -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; |
| 3 | +import { mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'; |
4 | 4 | import { tmpdir } from 'node:os'; |
5 | 5 | import { join } from 'node:path'; |
6 | | -import { describe, it } from 'node:test'; |
| 6 | +import { afterEach, describe, it } from 'node:test'; |
7 | 7 | import { deviceCommand, deviceToolCommand } from '../src/core/device-command.ts'; |
8 | 8 | import { run } from '../src/core/exec.ts'; |
9 | | -import { VerifyFailure, type LocalLease } from '../src/core/types.ts'; |
| 9 | +import { coreVersion } from '../src/core/manifest.ts'; |
| 10 | +import { remoteBackend } from '../src/core/remote/backend.ts'; |
| 11 | +import type { GitHub } from '../src/core/remote/github.ts'; |
| 12 | +import type { SessionHealth } from '../src/core/remote/protocol.ts'; |
| 13 | +import { VerifyFailure, type LocalLease, type RemoteLease } from '../src/core/types.ts'; |
| 14 | + |
| 15 | +const realFetch = globalThis.fetch; |
| 16 | +afterEach(() => void (globalThis.fetch = realFetch)); |
| 17 | + |
| 18 | +function remoteLease(platform: 'ios' | 'android'): RemoteLease { |
| 19 | + const tokenFile = join(mkdtempSync(join(tmpdir(), 'verify-command-')), 'token'); |
| 20 | + writeFileSync(tokenFile, 'd'.repeat(64)); |
| 21 | + return { backend: 'remote', provider: 'github-actions', platform, session: `${platform}abc123`, providerRef: '7', baseUrl: 'https://quick-fox.trycloudflare.com', tokenFile, deviceId: 'emulator-5554', deviceName: 'Pixel', runner: 'linux', expiresAt: new Date(Date.now() + 3_600_000).toISOString(), builtSha: null, acquiredAt: '', installedBuild: null }; |
| 22 | +} |
| 23 | + |
| 24 | +function tunnel(reply: (path: string) => { status: number; body: unknown }): { readonly calls: { path: string; method: string; authorization: string | null; body: unknown }[] } { |
| 25 | + const calls: { path: string; method: string; authorization: string | null; body: unknown }[] = []; |
| 26 | + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { |
| 27 | + const url = new URL(String(input)); |
| 28 | + assert.equal(url.host, 'quick-fox.trycloudflare.com'); |
| 29 | + calls.push({ path: url.pathname, method: init?.method ?? 'GET', authorization: new Headers(init?.headers).get('authorization'), body: typeof init?.body === 'string' ? JSON.parse(init.body) : null }); |
| 30 | + const { status, body } = reply(url.pathname); |
| 31 | + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); |
| 32 | + }) as typeof fetch; |
| 33 | + return { calls }; |
| 34 | +} |
10 | 35 |
|
11 | 36 | describe('the device tool', () => { |
12 | 37 | it('is adb on the device for shell and for reverse to a TCP port, and nothing that reaches the files or sockets of the machine adb runs on', () => { |
@@ -51,3 +76,63 @@ describe('deviceCommand', () => { |
51 | 76 | await assert.rejects(deviceCommand(lease, ['pull', '/sdcard/x', 'x'], { runner: async () => assert.fail('ran') }), (error: VerifyFailure) => error.code === 'UNSUPPORTED'); |
52 | 77 | }); |
53 | 78 | }); |
| 79 | + |
| 80 | +describe('a session started from another core', () => { |
| 81 | + const health = (core: string | undefined): SessionHealth => |
| 82 | + ({ core, device: { id: 'emulator-5554', name: 'Pixel', ready: true }, daemon: true, build: { state: 'none' }, capAt: '', ending: null }) as SessionHealth; |
| 83 | + const backend = () => |
| 84 | + remoteBackend( |
| 85 | + { platform: 'android', repo: 'clerk/clerk-android', workflow: 'verify-remote.yml', sessionsDir: mkdtempSync(join(tmpdir(), 'verify-core-')), runner: 'linux', plumbingRunner: 'ubuntu-latest', device: 'Pixel', idleMinutes: 15, capMinutes: 60, requirement: '' }, |
| 86 | + { env: {}, runner: run, github: async () => assert.fail('a session that answers is not looked up at GitHub') }, |
| 87 | + ); |
| 88 | + |
| 89 | + it('is held when its agent has this checkout\'s core', async () => { |
| 90 | + tunnel(() => ({ status: 200, body: health(coreVersion()) })); |
| 91 | + assert.equal(await backend().check(remoteLease('android')), 'held'); |
| 92 | + }); |
| 93 | + |
| 94 | + it('is refused, not renewed, when its agent has another core, with the fix that gets both onto one commit', async () => { |
| 95 | + for (const core of ['0123456789ab', undefined]) { |
| 96 | + tunnel(() => ({ status: 200, body: health(core) })); |
| 97 | + await assert.rejects(backend().check(remoteLease('android')), (error: VerifyFailure) => error.code === 'NOT_READY' && error.message.includes(coreVersion()) && error.fix === 'commit and push the changes under src/core, then {cli} down and {cli} up'); |
| 98 | + } |
| 99 | + }); |
| 100 | +}); |
| 101 | + |
| 102 | +describe('acquiring a session whose agent has another core', () => { |
| 103 | + it('asks that session to stop through its tunnel and keeps no lease or token', async () => { |
| 104 | + const sessionsDir = mkdtempSync(join(tmpdir(), 'verify-acquire-')); |
| 105 | + const tunnelCalls: string[] = []; |
| 106 | + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { |
| 107 | + const url = new URL(String(input)); |
| 108 | + tunnelCalls.push(`${init?.method ?? 'GET'} ${url.host}${url.pathname}`); |
| 109 | + const body = url.pathname === '/__sim/stop' ? { ok: true } : ({ core: '0123456789ab', device: { id: 'emulator-5560', name: 'Pixel', ready: false }, daemon: false, build: { state: 'none' }, capAt: '', ending: null } satisfies SessionHealth); |
| 110 | + return new Response(JSON.stringify(body), { status: 200 }); |
| 111 | + }) as typeof fetch; |
| 112 | + const hubCalls: string[] = []; |
| 113 | + const hub: GitHub = { |
| 114 | + repo: 'clerk/clerk-android', |
| 115 | + workflow: 'verify-remote.yml', |
| 116 | + tokenSource: 'none', |
| 117 | + async api(method, path) { |
| 118 | + hubCalls.push(`${method} ${path}`); |
| 119 | + if (path.endsWith('/dispatches')) return { status: 200, json: { workflow_run_id: 9 }, headers: new Headers() }; |
| 120 | + if (path.includes('/jobs')) return { status: 200, json: { jobs: [{ name: 'session', status: 'in_progress', conclusion: null, steps: [{ name: 'verify-remote tunnel quick-fox.trycloudflare.com', status: 'completed', conclusion: 'success' }] }] }, headers: new Headers() }; |
| 121 | + const stopped = tunnelCalls.some((call) => call.endsWith('/__sim/stop')); |
| 122 | + return { status: 200, json: { status: stopped ? 'completed' : 'in_progress', conclusion: stopped ? 'success' : null, html_url: 'https://github.com/x' }, headers: new Headers() }; |
| 123 | + }, |
| 124 | + }; |
| 125 | + const backend = remoteBackend( |
| 126 | + { platform: 'android', repo: 'clerk/clerk-android', workflow: 'verify-remote.yml', sessionsDir, runner: 'linux', plumbingRunner: 'ubuntu-latest', device: 'Pixel', idleMinutes: 15, capMinutes: 60, requirement: '' }, |
| 127 | + { env: {}, runner: async () => ({ code: 0, stdout: 'my-branch\n', stderr: '' }), github: async () => hub }, |
| 128 | + ); |
| 129 | + const progress: string[] = []; |
| 130 | + await assert.rejects( |
| 131 | + backend.acquire({ platform: 'android', worktree: '/w', waitSeconds: 0, app: { sourceSha: 'f'.repeat(40) } as never, retryWith: '', progress: (line) => progress.push(line) }), |
| 132 | + (error: VerifyFailure) => error.code === 'NOT_READY' && error.message.includes('0123456789ab') && error.fix.includes('{cli} down and {cli} up'), |
| 133 | + ); |
| 134 | + assert.deepEqual(tunnelCalls, ['GET quick-fox.trycloudflare.com/__sim/health', 'POST quick-fox.trycloudflare.com/__sim/stop']); |
| 135 | + assert.equal(hubCalls.some((call) => call.endsWith('/cancel')), false, 'a session that stops when asked is not cancelled'); |
| 136 | + assert.deepEqual(readdirSync(sessionsDir), ['owner'], 'the token of the session that was ended is gone'); |
| 137 | + }); |
| 138 | +}); |
0 commit comments