import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import http from 'node:http' import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs' import { Mux } from '../lib/mux.mjs' import { Store, sha256 } from '../lib/store.mjs' const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port))) async function linkPair({ handlersA = {}, handlersB = {} } = {}) { const srv = http.createServer() let serverMux const ready = new Promise((resolve) => { srv.on('upgrade', (req, socket, head) => { const ws = acceptUpgrade(req, socket, head) serverMux = new Mux(ws, { odd: false, handlers: handlersA }) resolve() }) }) const port = await listen(srv) const ws = await connect(`ws://127.0.0.1:${port}/link`) const clientMux = new Mux(ws, { odd: true, handlers: handlersB }) await ready return { srv, port, serverMux, clientMux, close: () => { clientMux.close(); srv.close() } } } test('store: session lineage tracking and offline move queue', () => { const tmp = path.join(os.tmpdir(), `dsh-remote-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`) try { const store = new Store(tmp) // 1. Lineage record const lin = store.recordLineage('sess-1', { originDevice: 'ai-pc', baseSeq: 10, versionToken: sha256('v1'), status: 'active' }) assert.equal(lin.originDevice, 'ai-pc') assert.equal(lin.baseSeq, 10) assert.equal(store.sessionLineage()['sess-1'].status, 'active') // 2. Queue move notice for offline device store.queueMoveNotice({ sessionId: 'sess-1', fromDevice: 'ai-pc', toDevice: 'local-mac', atSeq: 10, versionToken: sha256('v1') }) assert.equal(Object.keys(store.pendingMoves()).length, 1) // 3. Popping moves for an unrelated device returns empty const unrelated = store.popPendingMovesForDevice('other-pc') assert.equal(unrelated.length, 0) assert.equal(Object.keys(store.pendingMoves()).length, 1) // 4. Popping moves for 'ai-pc' returns the notice and clears queue const popped = store.popPendingMovesForDevice('ai-pc') assert.equal(popped.length, 1) assert.equal(popped[0].sessionId, 'sess-1') assert.equal(popped[0].toDevice, 'local-mac') assert.equal(Object.keys(store.pendingMoves()).length, 0) } finally { try { fs.unlinkSync(tmp) } catch {} } }) test('mux: session move and git-like conflict detection across link', async () => { const recorded = [] const handlersServer = { recordSessionMoved: async (p) => { // Simulate Git-like conflict detection: // If local session progressed past atSeq, both diverged! const localLastSeq = p.mockLocalSeq ?? 5 const conflict = localLastSeq > p.atSeq recorded.push({ ...p, conflict, localLastSeq }) return { ok: true, conflict, localLastSeq } } } const p = await linkPair({ handlersA: handlersServer }) try { // 1. Clean move (no divergence: localSeq <= atSeq) const cleanRes = await p.clientMux.call('recordSessionMoved', { sessionId: 'sess-abc', movedTo: 'device-b', targetSessionId: 'sess-xyz', atSeq: 5, mockLocalSeq: 5 }) assert.equal(cleanRes.ok, true) assert.equal(cleanRes.conflict, false) // 2. Conflicting move (local progressed while offline: localSeq > atSeq) const conflictRes = await p.clientMux.call('recordSessionMoved', { sessionId: 'sess-diverged', movedTo: 'device-b', targetSessionId: 'sess-xyz2', atSeq: 5, mockLocalSeq: 8 }) assert.equal(conflictRes.ok, true) assert.equal(conflictRes.conflict, true) assert.equal(conflictRes.localLastSeq, 8) } finally { p.close() } })