|
18 | 18 | */ |
19 | 19 |
|
20 | 20 | import assert from 'node:assert/strict'; |
| 21 | +import { createRequire } from 'node:module'; |
| 22 | +import { fileURLToPath } from 'node:url'; |
| 23 | +import { runInNewContext } from 'node:vm'; |
21 | 24 | import test from 'node:test'; |
| 25 | +import { build } from 'esbuild'; |
| 26 | +import type { GoalEventWaitCancelInput, GoalEventWaitCancelResult, GoalProjection } from '@maka/runtime-host/protocol'; |
| 27 | +import type { MakaBridge } from '../../preload/bridge-contract.js'; |
22 | 28 | import { |
23 | 29 | invokeProjectedSessionRuntimeHost, |
24 | 30 | } from '../../preload/projected-session-runtime-host.js'; |
25 | 31 | import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; |
| 32 | +import { |
| 33 | + registerRuntimeHostSessionDomainsIpc, |
| 34 | + type RuntimeHostSessionDomainsIpcDeps, |
| 35 | +} from '../runtime-host-session-domains-ipc-main.js'; |
| 36 | +import type { IpcHandler } from '../ipc-reconnect-policy.js'; |
| 37 | +import { TerminalCloseIntents } from '../terminal-close-intents.js'; |
26 | 38 |
|
27 | 39 | test('Goal arm preload routing preserves target scope and projects every outcome', async () => { |
28 | 40 | const scope = { hostId: 'host-1', targetEpoch: 'epoch-2' }; |
@@ -87,3 +99,132 @@ test('Goal arm preload routing preserves target scope and projects every outcome |
87 | 99 | assert.deepEqual(result, scenario.projected); |
88 | 100 | } |
89 | 101 | }); |
| 102 | + |
| 103 | +test('Goal wait cancellation resolves Desktop identity once and projects every result', async (t) => { |
| 104 | + const bundle = await build({ |
| 105 | + entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))], |
| 106 | + bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'], |
| 107 | + }); |
| 108 | + for (const profileKind of ['local', 'remote'] as const) { |
| 109 | + for (const outcome of ['cancelled', 'already_cancelled', 'conflict', 'already_admitted'] as const) { |
| 110 | + await t.test(`${profileKind} owner: ${outcome}`, async () => { |
| 111 | + const host = { |
| 112 | + hostId: `${profileKind}-host`, targetEpoch: `${profileKind}-epoch`, |
| 113 | + profileId: profileKind, profileName: profileKind, profileKind, |
| 114 | + profileAccess: 'owner', readiness: 'ready', |
| 115 | + }; |
| 116 | + const scope = { hostId: host.hostId, targetEpoch: host.targetEpoch }; |
| 117 | + const desktopSessionId = desktopSessionKey({ hostId: host.hostId, sessionId: 'session-1' }); |
| 118 | + const goal: GoalProjection = { |
| 119 | + goalId: 'goal-1', sessionId: 'session-1', revision: 3, |
| 120 | + condition: 'Inspect the background tests', status: 'active', setAt: 1, |
| 121 | + iterations: 1, maxIterations: 20, consecutiveNoProgress: 0, blockCap: 8, |
| 122 | + tokenBudget: null, tokensSpent: 100, lastReason: null, achievedAt: null, pausedAt: null, |
| 123 | + eventWait: { |
| 124 | + waitId: 'wait-1', controlGeneration: 5, waitRevision: 2, |
| 125 | + resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task-1' }, |
| 126 | + reason: 'Inspect the test result', deadlineAt: 60_000, phase: 'observing', |
| 127 | + observation: { state: 'pending', lastObservedAt: 1, reasonCode: null }, outcome: null, |
| 128 | + }, |
| 129 | + }; |
| 130 | + const cancelled = outcome === 'cancelled' || outcome === 'already_cancelled'; |
| 131 | + const response: GoalEventWaitCancelResult = { |
| 132 | + sessionId: 'session-1', outcome, |
| 133 | + goal: { |
| 134 | + ...goal, revision: 4, |
| 135 | + status: cancelled ? 'paused' : 'active', |
| 136 | + pausedAt: cancelled ? 2 : null, |
| 137 | + eventWait: outcome === 'conflict' |
| 138 | + ? { ...goal.eventWait!, waitId: 'wait-new', controlGeneration: 6 } |
| 139 | + : null, |
| 140 | + }, |
| 141 | + }; |
| 142 | + const handlers = new Map<string, IpcHandler>(); |
| 143 | + const cancellations: GoalEventWaitCancelInput[] = []; |
| 144 | + const ipcCancellations: unknown[] = []; |
| 145 | + const pauses: string[] = []; |
| 146 | + let queries = 0; |
| 147 | + const domains = registerRuntimeHostSessionDomainsIpc({ |
| 148 | + client: { |
| 149 | + queryGoal: async (sessionId: string) => { |
| 150 | + assert.equal(sessionId, 'session-1'); |
| 151 | + queries++; |
| 152 | + return { sessionId, goal }; |
| 153 | + }, |
| 154 | + cancelGoalEventWait: async (input: GoalEventWaitCancelInput) => { |
| 155 | + cancellations.push(input); |
| 156 | + return response; |
| 157 | + }, |
| 158 | + controlGoalWithRetry: async (sessionId: string, action: string) => { |
| 159 | + assert.equal(action, 'pause'); |
| 160 | + pauses.push(sessionId); |
| 161 | + }, |
| 162 | + } as RuntimeHostSessionDomainsIpcDeps['client'], |
| 163 | + terminalCloses: new TerminalCloseIntents(), |
| 164 | + sessionObserver: { async observe() {}, async unobserve() {} }, |
| 165 | + emitModeChanged() {}, |
| 166 | + }, { |
| 167 | + handle(channel, handler) { handlers.set(channel, handler); }, |
| 168 | + }); |
| 169 | + const ipcRenderer = { |
| 170 | + on() {}, off() {}, send() {}, |
| 171 | + async invoke(channel: string, ...vmArgs: unknown[]) { |
| 172 | + if (channel === 'app:bootstrapReady' || channel === 'runtime-host:awaitReady') return { ready: true }; |
| 173 | + if (channel === 'runtime-host:identities') return [ |
| 174 | + ...(profileKind === 'remote' ? [{ |
| 175 | + ...host, hostId: 'local-host', targetEpoch: 'local-epoch', epoch: 'local-epoch', |
| 176 | + profileId: 'local', profileKind: 'local', isDefault: true, |
| 177 | + }] : []), |
| 178 | + { ...host, epoch: host.targetEpoch, isDefault: profileKind === 'local' }, |
| 179 | + ]; |
| 180 | + // Electron IPC clones arguments out of the preload's realm. |
| 181 | + const [targetScope, rawSessionId, ...args] = structuredClone(vmArgs); |
| 182 | + assert.deepEqual(targetScope, scope); |
| 183 | + assert.equal(rawSessionId, 'session-1'); |
| 184 | + if (channel === 'goal:cancel-event-wait') ipcCancellations.push(args[0]); |
| 185 | + const handler = handlers.get(channel); |
| 186 | + assert.ok(handler, `Unexpected channel: ${channel}`); |
| 187 | + return handler({} as never, rawSessionId, ...args); |
| 188 | + }, |
| 189 | + }; |
| 190 | + let bridge: MakaBridge | undefined; |
| 191 | + const require = createRequire(import.meta.url); |
| 192 | + runInNewContext(bundle.outputFiles[0]!.text, { |
| 193 | + require: (id: string) => id === 'electron' ? { |
| 194 | + ipcRenderer, |
| 195 | + contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) { |
| 196 | + if (name === 'maka') bridge = value; |
| 197 | + } }, |
| 198 | + } : require(id), |
| 199 | + process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder, |
| 200 | + crypto: globalThis.crypto, |
| 201 | + }); |
| 202 | + assert.ok(bridge); |
| 203 | + try { |
| 204 | + const displayed = await bridge.goal.get(desktopSessionId); |
| 205 | + assert.equal(displayed?.sessionId, desktopSessionId); |
| 206 | + assert.ok(displayed?.eventWait); |
| 207 | + const target = Object.freeze({ |
| 208 | + sessionId: displayed.sessionId, goalId: displayed.id, |
| 209 | + controlGeneration: displayed.eventWait.controlGeneration, |
| 210 | + waitId: displayed.eventWait.waitId, expectedGoalRevision: displayed.revision, |
| 211 | + }); |
| 212 | + const result = await bridge.goal.cancelEventWait(target); |
| 213 | + const expectedTarget = { ...target, sessionId: 'session-1' }; |
| 214 | + assert.deepEqual(ipcCancellations, [expectedTarget]); |
| 215 | + assert.deepEqual(cancellations, [expectedTarget]); |
| 216 | + assert.equal(target.sessionId, desktopSessionId, 'do not mutate the displayed target'); |
| 217 | + assert.equal(queries, 1, 'do not query a newer wait to retarget cancellation'); |
| 218 | + assert.deepEqual(structuredClone(result), { |
| 219 | + ...response, sessionId: desktopSessionId, |
| 220 | + goal: { ...response.goal, sessionId: desktopSessionId }, |
| 221 | + }); |
| 222 | + await bridge.goal.pause(desktopSessionId); |
| 223 | + assert.deepEqual(pauses, ['session-1']); |
| 224 | + } finally { |
| 225 | + await domains.close(); |
| 226 | + } |
| 227 | + }); |
| 228 | + } |
| 229 | + } |
| 230 | +}); |
0 commit comments