@pi-chamber/web 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.test.js +6 -8
- package/bin/lib/cli-tunnel-capabilities.js +0 -2
- package/dist/assets/{CommitSection-DPjTv8fA.js → CommitSection-C336377Z.js} +1 -1
- package/dist/assets/{DiagramEditor-ChOor9l9.js → DiagramEditor-gHLCXHtr.js} +1 -1
- package/dist/assets/{DiagramView-3hAT86v_.js → DiagramView-DlHzRRBc.js} +1 -1
- package/dist/assets/{DiffView-D1dyOiw2.js → DiffView-5wHwK-lg.js} +1 -1
- package/dist/assets/{FilesView-BCzghrc7.js → FilesView-5bswI3JZ.js} +6 -6
- package/dist/assets/{GitView-CYF1E1p4.js → GitView-BfykGpGh.js} +4 -4
- package/dist/assets/{JsonTreeView-CrJO6LNu.js → JsonTreeView-HVwQS7qG.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BmKjrkGD.js → MarkdownRendererImpl-kjd4e0N8.js} +1 -1
- package/dist/assets/{PatchDiff-DgvPLx6x.js → PatchDiff-Dlm4RU8n.js} +1 -1
- package/dist/assets/{PierreDiffViewer-InnT0JVA.js → PierreDiffViewer-St32rXlD.js} +1 -1
- package/dist/assets/{SessionSwitcherDropdown-C0OdvirK.js → SessionSwitcherDropdown-D5bWfZNA.js} +100 -101
- package/dist/assets/SettingsView-FGe5TEQq.js +4 -0
- package/dist/assets/SettingsWindow-Ck_uhTnx.js +1 -0
- package/dist/assets/{ToolOutputDialog-gzwcb8nI.js → ToolOutputDialog-BCONUv3r.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-C45V5bhy.js → ToolPartDiffPreview-BdVuk2kc.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-uKfnkQWb.js → VirtualizedFileDiff-BsAZM0jo.js} +1 -1
- package/dist/assets/{collapsible-Bq8sDiKS.js → collapsible-Wnmq-13H.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BPgZ_RSe.js → computeVirtualFileMetrics-skbyL2Az.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-CydO7PkI.js → contextFileOpenGuard-BsxQITMZ.js} +1 -1
- package/dist/assets/index-8TL2HlaR.js +8 -0
- package/dist/assets/{index-BSuNNtBh.js → index-Bfp6eYap.js} +1 -1
- package/dist/assets/index-riABQmQ_.css +1 -0
- package/dist/assets/{isDiffPlainText-f9b4cKNo.js → isDiffPlainText-dZkfk3yG.js} +1 -1
- package/dist/assets/{isFilePlainText-BjESb-gU.js → isFilePlainText-UL_tMQZx.js} +1 -1
- package/dist/assets/{main-B508FBXr.js → main-CHQfoyRP.js} +2 -2
- package/dist/assets/main-DbnYXZeK.js +133 -0
- package/dist/assets/{markdownCore-BFmf8a-O.js → markdownCore-qX3mJFkL.js} +2 -2
- package/dist/assets/{miniChat-BTlz8IPL.js → miniChat-3QtCNwHl.js} +2 -2
- package/dist/assets/{mobile-DBkrRt80.js → mobile-B_CyfRAX.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-OToslrv-.js +1 -0
- package/dist/assets/renderMobileApp-BD_wep_I.js +14 -0
- package/dist/assets/{runtimeSurface-Bec4O8FD.js → runtimeSurface-CkdOpqo7.js} +1 -1
- package/dist/assets/{useInlineCommentController-CHmIm0Rr.js → useInlineCommentController-DVif5Fr8.js} +2 -2
- package/dist/assets/usePanelSlide-C_PJmPXW.js +56 -0
- package/dist/assets/{useWindowTitle-CE5N_NeU.js → useWindowTitle-BZVlLMCy.js} +3 -3
- package/dist/assets/{vendor-base-ui-DwfTg5wD.js → vendor-base-ui-DL8r2fgQ.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-q1yHU1R9.js → vendor-codemirror-language-data-C-924Z4o.js} +2 -2
- package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
- package/dist/assets/{web-components-CLirSfwt.js → web-components-BG-6PEN4.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/package-manager.js +0 -34
- package/server/lib/pi/routes.js +52 -4
- package/server/lib/pi/routes.test.js +14 -6
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
- package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
- package/server/lib/pi/session-daemon/runtime-registry.js +3 -0
- package/server/lib/pi/session-daemon/session-daemon.js +184 -16
- package/server/lib/pi/session-daemon/session-daemon.test.js +310 -5
- package/server/lib/pi/session-daemon/supervisor.js +20 -1
- package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
- package/server/lib/tunnels/DOCUMENTATION.md +6 -7
- package/server/lib/tunnels/executable-search.test.js +4 -4
- package/server/lib/tunnels/install-help.js +0 -10
- package/server/lib/tunnels/install-help.test.js +0 -9
- package/server/lib/tunnels/types.js +0 -1
- package/dist/assets/SettingsView-JAfC_cBL.js +0 -3
- package/dist/assets/SettingsWindow-BBEsjcJE.js +0 -1
- package/dist/assets/index-CH7o4-_q.js +0 -8
- package/dist/assets/index-o4Eea60G.css +0 -1
- package/dist/assets/main-nG4uFyAy.js +0 -133
- package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +0 -1
- package/dist/assets/renderMobileApp-9qkroKty.js +0 -14
- package/dist/assets/usePanelSlide-DCX8qQ4K.js +0 -56
- package/server/lib/ngrok-tunnel.js +0 -343
- package/server/lib/ngrok-tunnel.test.js +0 -50
- package/server/lib/tunnels/providers/ngrok.js +0 -32
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
8
|
|
|
9
|
+
import { createMessageEntryAliases } from './message-entry-aliases.js';
|
|
9
10
|
import { createSessionDaemon, isLocalSessionDaemonEndpoint } from './session-daemon.js';
|
|
10
11
|
|
|
11
12
|
const credential = 'a-private-daemon-credential';
|
|
@@ -42,6 +43,7 @@ class FakeSession {
|
|
|
42
43
|
getSessionFile: () => sessionFile,
|
|
43
44
|
getHeader: () => ({ timestamp: '2026-01-01T00:00:00.000Z' }),
|
|
44
45
|
getEntries: () => this.entries,
|
|
46
|
+
getEntry: (id) => this.entries.find((entry) => entry.id === id),
|
|
45
47
|
getLeafId: () => 'fake-entry',
|
|
46
48
|
getTree: () => [{ entry: { id: 'fake-entry', parentId: undefined, timestamp: '2026-01-01T00:00:00.000Z' }, children: [] }],
|
|
47
49
|
appendSessionInfo: (name) => this.names.push(name),
|
|
@@ -315,6 +317,59 @@ describe('Pi session daemon spike', () => {
|
|
|
315
317
|
await stale.close();
|
|
316
318
|
});
|
|
317
319
|
|
|
320
|
+
it('keeps existing and late-joining device streams independent during one turn', async () => {
|
|
321
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-multi-client-'));
|
|
322
|
+
const endpoint = testDaemonEndpoint(root);
|
|
323
|
+
const session = new FakeSession();
|
|
324
|
+
session.isStreaming = true;
|
|
325
|
+
session.messages = [{
|
|
326
|
+
role: 'assistant',
|
|
327
|
+
content: [{ type: 'text', text: 'half' }],
|
|
328
|
+
provider: 'test',
|
|
329
|
+
model: 'model',
|
|
330
|
+
timestamp: 1_000,
|
|
331
|
+
}];
|
|
332
|
+
daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
|
|
333
|
+
await daemon.start();
|
|
334
|
+
|
|
335
|
+
const first = connectClient(endpoint);
|
|
336
|
+
await first.authenticate();
|
|
337
|
+
await first.request('sessions.create', { cwd: root });
|
|
338
|
+
const detail = await first.request('sessions.open', { sessionId: 'pi-session-1', cwd: root });
|
|
339
|
+
expect(detail.result).toMatchObject({
|
|
340
|
+
isStreaming: true,
|
|
341
|
+
lifecycle: 'busy',
|
|
342
|
+
lastSequence: expect.any(Number),
|
|
343
|
+
messages: [expect.objectContaining({ parts: [expect.objectContaining({ text: 'half' })] })],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const late = connectClient(endpoint);
|
|
347
|
+
await late.authenticate(credential, {
|
|
348
|
+
sessionId: 'pi-session-1',
|
|
349
|
+
fromSequence: detail.result.lastSequence,
|
|
350
|
+
});
|
|
351
|
+
const firstDelta = first.next((frame) => frame.event === 'assistant.message.delta');
|
|
352
|
+
const lateDelta = late.next((frame) => frame.event === 'assistant.message.delta');
|
|
353
|
+
session.emit({
|
|
354
|
+
type: 'message_update',
|
|
355
|
+
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: ' plus the rest' },
|
|
356
|
+
});
|
|
357
|
+
const [firstDeltaFrame, lateDeltaFrame] = await Promise.all([firstDelta, lateDelta]);
|
|
358
|
+
expect(firstDeltaFrame.sequence).toBe(lateDeltaFrame.sequence);
|
|
359
|
+
expect(lateDeltaFrame.payload.delta).toBe(' plus the rest');
|
|
360
|
+
|
|
361
|
+
const firstIdle = first.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
|
|
362
|
+
const lateIdle = late.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
|
|
363
|
+
session.isStreaming = false;
|
|
364
|
+
session.emit({ type: 'agent_settled' });
|
|
365
|
+
const [firstIdleFrame, lateIdleFrame] = await Promise.all([firstIdle, lateIdle]);
|
|
366
|
+
expect(firstIdleFrame.sequence).toBe(lateIdleFrame.sequence);
|
|
367
|
+
expect(firstIdleFrame.sequence).toBeGreaterThan(firstDeltaFrame.sequence);
|
|
368
|
+
|
|
369
|
+
await late.close();
|
|
370
|
+
await first.close();
|
|
371
|
+
});
|
|
372
|
+
|
|
318
373
|
it('lists only validated cwd-scoped sessions without exposing Pi JSONL paths', async () => {
|
|
319
374
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
320
375
|
const endpoint = testDaemonEndpoint(root);
|
|
@@ -611,6 +666,174 @@ describe('Pi session daemon spike', () => {
|
|
|
611
666
|
await client.close();
|
|
612
667
|
});
|
|
613
668
|
|
|
669
|
+
it('resolves live message ids to Pi entry ids for navigation and forking', async () => {
|
|
670
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-'));
|
|
671
|
+
const endpoint = testDaemonEndpoint(root);
|
|
672
|
+
const sessionFile = join(root, 'fixture.jsonl');
|
|
673
|
+
await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
674
|
+
const session = new FakeSession('fixture-session', sessionFile);
|
|
675
|
+
const hydratedMessage = { role: 'user', timestamp: 1, content: 'm3' };
|
|
676
|
+
session.entries.push({ type: 'message', id: 'm3-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: hydratedMessage });
|
|
677
|
+
const navigated = [];
|
|
678
|
+
session.navigateTree = async (entryId) => {
|
|
679
|
+
if (!session.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
|
|
680
|
+
navigated.push(entryId);
|
|
681
|
+
return { cancelled: false };
|
|
682
|
+
};
|
|
683
|
+
const forked = [];
|
|
684
|
+
const runtime = new FakeRuntime({ cwd: root, session });
|
|
685
|
+
runtime.fork = async (entryId) => {
|
|
686
|
+
if (!session.sessionManager.getEntry(entryId)) throw new Error('Invalid entry ID for forking');
|
|
687
|
+
forked.push(entryId);
|
|
688
|
+
return { cancelled: false };
|
|
689
|
+
};
|
|
690
|
+
daemon = createSessionDaemon({
|
|
691
|
+
endpoint,
|
|
692
|
+
credential,
|
|
693
|
+
cwd: root,
|
|
694
|
+
createRuntime: async () => runtime,
|
|
695
|
+
listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
|
|
696
|
+
});
|
|
697
|
+
await daemon.start();
|
|
698
|
+
|
|
699
|
+
const client = connectClient(endpoint);
|
|
700
|
+
await client.authenticate();
|
|
701
|
+
await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
|
|
702
|
+
|
|
703
|
+
await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: 'm3-entry' })).resolves.toMatchObject({
|
|
704
|
+
result: { navigation: { targetEntryId: 'm3-entry' } },
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const replacementUser = { role: 'user', timestamp: 2, content: 'm4 replacement' };
|
|
708
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user' && frame.payload?.text === 'm4 replacement');
|
|
709
|
+
session.emit({ type: 'message_start', message: replacementUser });
|
|
710
|
+
session.emit({ type: 'message_end', message: replacementUser });
|
|
711
|
+
session.entries.push({ type: 'message', id: 'm4-rev-entry', parentId: 'm3-entry', timestamp: '2026-01-01T00:00:02.000Z', message: replacementUser });
|
|
712
|
+
const publishedUserId = (await userStartPromise).payload.messageId;
|
|
713
|
+
await Promise.resolve();
|
|
714
|
+
|
|
715
|
+
await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({
|
|
716
|
+
result: { navigation: { targetEntryId: 'm4-rev-entry' } },
|
|
717
|
+
});
|
|
718
|
+
await expect(client.request('sessions.fork', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({ result: expect.any(Object) });
|
|
719
|
+
|
|
720
|
+
// Pi agent-core shallow-copies the streaming start; message_end carries
|
|
721
|
+
// the distinct finalized object that SessionManager persists.
|
|
722
|
+
const assistantStartMessage = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [] };
|
|
723
|
+
const replacementAssistant = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [{ type: 'text', text: 'replacement answer' }] };
|
|
724
|
+
const assistantStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
725
|
+
session.emit({ type: 'message_start', message: assistantStartMessage });
|
|
726
|
+
session.emit({ type: 'message_end', message: replacementAssistant });
|
|
727
|
+
session.entries.push({ type: 'message', id: 'm4-assistant-entry', parentId: 'm4-rev-entry', timestamp: '2026-01-01T00:00:03.000Z', message: replacementAssistant });
|
|
728
|
+
const publishedAssistantId = (await assistantStartPromise).payload.messageId;
|
|
729
|
+
await Promise.resolve();
|
|
730
|
+
|
|
731
|
+
await expect(client.request('sessions.navigate', {
|
|
732
|
+
sessionId: 'fixture-session',
|
|
733
|
+
directory: root,
|
|
734
|
+
messageId: `${publishedAssistantId}:text:0`,
|
|
735
|
+
})).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'm4-assistant-entry' } } });
|
|
736
|
+
await expect(client.request('sessions.fork', {
|
|
737
|
+
sessionId: 'fixture-session',
|
|
738
|
+
directory: root,
|
|
739
|
+
messageId: `${publishedAssistantId}:text:0`,
|
|
740
|
+
})).resolves.toMatchObject({ result: expect.any(Object) });
|
|
741
|
+
|
|
742
|
+
expect(navigated).toEqual(['m3-entry', 'm4-rev-entry', 'm4-assistant-entry']);
|
|
743
|
+
expect(forked).toEqual(['m4-rev-entry', 'm4-assistant-entry']);
|
|
744
|
+
await expect(client.request('sessions.fork', {
|
|
745
|
+
sessionId: 'fixture-session',
|
|
746
|
+
directory: root,
|
|
747
|
+
messageId: 'user-fixture-session-unknown',
|
|
748
|
+
})).rejects.toThrow('Daemon connection closed');
|
|
749
|
+
|
|
750
|
+
const navigationClient = connectClient(endpoint);
|
|
751
|
+
await navigationClient.authenticate();
|
|
752
|
+
await expect(navigationClient.request('sessions.navigate', {
|
|
753
|
+
sessionId: 'fixture-session',
|
|
754
|
+
directory: root,
|
|
755
|
+
messageId: 'user-fixture-session-unknown',
|
|
756
|
+
})).rejects.toThrow('Daemon connection closed');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('keeps live message aliases across idle disposal and runtime reopening', async () => {
|
|
760
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-idle-'));
|
|
761
|
+
const endpoint = testDaemonEndpoint(root);
|
|
762
|
+
const sessionFile = join(root, 'fixture.jsonl');
|
|
763
|
+
await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
764
|
+
const liveMessage = { role: 'user', timestamp: 1, content: 'persist me' };
|
|
765
|
+
const persistedEntry = { type: 'message', id: 'persisted-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: liveMessage };
|
|
766
|
+
const firstSession = new FakeSession('fixture-session', sessionFile);
|
|
767
|
+
const firstRuntime = new FakeRuntime({ cwd: root, session: firstSession });
|
|
768
|
+
const reopenedSession = new FakeSession('fixture-session', sessionFile);
|
|
769
|
+
reopenedSession.entries.push({ ...persistedEntry, message: { ...liveMessage } });
|
|
770
|
+
reopenedSession.navigateTree = async (entryId) => {
|
|
771
|
+
if (!reopenedSession.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
|
|
772
|
+
reopenedSession.navigatedTo = entryId;
|
|
773
|
+
return { cancelled: false };
|
|
774
|
+
};
|
|
775
|
+
const reopenedRuntime = new FakeRuntime({ cwd: root, session: reopenedSession });
|
|
776
|
+
let runtimeCount = 0;
|
|
777
|
+
daemon = createSessionDaemon({
|
|
778
|
+
endpoint,
|
|
779
|
+
credential,
|
|
780
|
+
cwd: root,
|
|
781
|
+
idleTimeoutMs: 10,
|
|
782
|
+
listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
|
|
783
|
+
createRuntime: async () => (++runtimeCount === 1 ? firstRuntime : reopenedRuntime),
|
|
784
|
+
});
|
|
785
|
+
await daemon.start();
|
|
786
|
+
|
|
787
|
+
const client = connectClient(endpoint);
|
|
788
|
+
await client.authenticate();
|
|
789
|
+
await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
|
|
790
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
791
|
+
firstSession.emit({ type: 'message_start', message: liveMessage });
|
|
792
|
+
firstSession.emit({ type: 'message_end', message: liveMessage });
|
|
793
|
+
firstSession.entries.push(persistedEntry);
|
|
794
|
+
const publishedId = (await userStartPromise).payload.messageId;
|
|
795
|
+
await Promise.resolve();
|
|
796
|
+
firstSession.emit({ type: 'agent_settled' });
|
|
797
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
798
|
+
expect(firstRuntime.disposed).toBe(true);
|
|
799
|
+
|
|
800
|
+
await expect(client.request('sessions.navigate', {
|
|
801
|
+
sessionId: 'fixture-session',
|
|
802
|
+
directory: root,
|
|
803
|
+
messageId: publishedId,
|
|
804
|
+
})).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'persisted-entry' } } });
|
|
805
|
+
expect(reopenedSession.navigatedTo).toBe('persisted-entry');
|
|
806
|
+
await client.close();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('scopes live message aliases by directory and session id', () => {
|
|
810
|
+
const aliases = createMessageEntryAliases({ scheduleMicrotask: (callback) => callback() });
|
|
811
|
+
const messageA = { role: 'user', content: 'same' };
|
|
812
|
+
const messageB = { role: 'user', content: 'same' };
|
|
813
|
+
const managerA = {
|
|
814
|
+
getEntry: () => undefined,
|
|
815
|
+
getEntries: () => [{ type: 'message', id: 'entry-a', message: messageA }],
|
|
816
|
+
};
|
|
817
|
+
const managerB = {
|
|
818
|
+
getEntry: () => undefined,
|
|
819
|
+
getEntries: () => [{ type: 'message', id: 'entry-b', message: messageB }],
|
|
820
|
+
};
|
|
821
|
+
aliases.retain({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA });
|
|
822
|
+
aliases.retain({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB });
|
|
823
|
+
aliases.observeMessageEnd({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA, sessionManager: managerA });
|
|
824
|
+
aliases.observeMessageEnd({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB, sessionManager: managerB });
|
|
825
|
+
|
|
826
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('entry-a');
|
|
827
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('entry-b');
|
|
828
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-unknown', sessionManager: managerA })).toBe('user-same-session-unknown');
|
|
829
|
+
|
|
830
|
+
aliases.clearSession({ cwd: '/project-a', sessionId: 'same-session' });
|
|
831
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('user-same-session-1');
|
|
832
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerB })).toBe('entry-b');
|
|
833
|
+
aliases.clear();
|
|
834
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('user-same-session-1');
|
|
835
|
+
});
|
|
836
|
+
|
|
614
837
|
it('disposes an idle runtime without deleting its Pi JSONL and restores it on demand', async () => {
|
|
615
838
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
616
839
|
const endpoint = testDaemonEndpoint(root);
|
|
@@ -971,7 +1194,62 @@ describe('Pi session daemon spike', () => {
|
|
|
971
1194
|
await client.close();
|
|
972
1195
|
});
|
|
973
1196
|
|
|
974
|
-
it('
|
|
1197
|
+
it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
|
|
1198
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
|
|
1199
|
+
const endpoint = testDaemonEndpoint(root);
|
|
1200
|
+
const session = new FakeSession('pi-session-retry');
|
|
1201
|
+
daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
|
|
1202
|
+
await daemon.start();
|
|
1203
|
+
const client = connectClient(endpoint);
|
|
1204
|
+
await client.authenticate();
|
|
1205
|
+
await client.request('sessions.create', { cwd: root });
|
|
1206
|
+
|
|
1207
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1208
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'recover this turn', timestamp: 1_000 } });
|
|
1209
|
+
const userStart = await userStartPromise;
|
|
1210
|
+
|
|
1211
|
+
const failedStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
1212
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_100 } });
|
|
1213
|
+
const failedStart = await failedStartPromise;
|
|
1214
|
+
session.emit({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Rate limit exceeded' } });
|
|
1215
|
+
|
|
1216
|
+
const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
|
|
1217
|
+
const prematureError = client.next((frame) => frame.event === 'session.error');
|
|
1218
|
+
session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
|
|
1219
|
+
session.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2_000, errorMessage: 'Rate limit exceeded' });
|
|
1220
|
+
|
|
1221
|
+
await expect(retryLifecycle).resolves.toMatchObject({
|
|
1222
|
+
payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
const retryObserver = connectClient(endpoint);
|
|
1226
|
+
const retrySnapshot = await retryObserver.authenticate();
|
|
1227
|
+
expect(retrySnapshot.payload).toMatchObject({
|
|
1228
|
+
lifecycle: 'retry',
|
|
1229
|
+
retry: { attempt: 1, message: 'Rate limit exceeded' },
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
session.emit({ type: 'agent_start' });
|
|
1233
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1234
|
+
&& frame.payload?.role === 'assistant'
|
|
1235
|
+
&& frame.payload.messageId !== failedStart.payload.messageId);
|
|
1236
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_200 } });
|
|
1237
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1238
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1239
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1240
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered' } });
|
|
1241
|
+
|
|
1242
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered' } });
|
|
1243
|
+
expect(failedStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1244
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1245
|
+
await expect(prematureError).rejects.toThrow(/Timed out/);
|
|
1246
|
+
|
|
1247
|
+
session.emit({ type: 'agent_settled' });
|
|
1248
|
+
await retryObserver.close();
|
|
1249
|
+
await client.close();
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
|
|
975
1253
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
976
1254
|
const endpoint = testDaemonEndpoint(root);
|
|
977
1255
|
const session = new FakeSession('pi-session-tool-seq');
|
|
@@ -981,16 +1259,28 @@ describe('Pi session daemon spike', () => {
|
|
|
981
1259
|
await client.authenticate();
|
|
982
1260
|
await client.request('sessions.create', { cwd: root });
|
|
983
1261
|
|
|
984
|
-
const
|
|
1262
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1263
|
+
const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
985
1264
|
const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
|
|
986
1265
|
const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
|
|
987
1266
|
const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
|
|
988
1267
|
|
|
1268
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
|
|
989
1269
|
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 1 } });
|
|
990
|
-
session.emit({
|
|
1270
|
+
session.emit({
|
|
1271
|
+
type: 'message_end',
|
|
1272
|
+
message: {
|
|
1273
|
+
role: 'assistant',
|
|
1274
|
+
content: [
|
|
1275
|
+
{ type: 'text', text: 'Calling tool' },
|
|
1276
|
+
{ type: 'toolCall', id: 'tool-call-1', name: 'bash', arguments: { command: 'echo hi' } },
|
|
1277
|
+
],
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
991
1280
|
session.emit({ type: 'tool_execution_start', toolCallId: 'tool-call-1', toolName: 'bash', args: { command: 'echo hi' } });
|
|
992
|
-
session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: '
|
|
1281
|
+
session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'command failed' }] }, isError: true });
|
|
993
1282
|
|
|
1283
|
+
const userStart = await userStartPromise;
|
|
994
1284
|
const messageStart = await messageStartPromise;
|
|
995
1285
|
const messageEnd = await messageEndPromise;
|
|
996
1286
|
const toolStart = await toolStartPromise;
|
|
@@ -998,11 +1288,26 @@ describe('Pi session daemon spike', () => {
|
|
|
998
1288
|
|
|
999
1289
|
expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
|
|
1000
1290
|
expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1291
|
+
expect(messageEnd.payload.continuing).toBe(true);
|
|
1001
1292
|
expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1002
1293
|
expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1003
1294
|
expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1004
1295
|
expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1005
|
-
|
|
1296
|
+
expect(toolEnd.payload).toMatchObject({ state: 'error', isError: true, error: 'command failed' });
|
|
1297
|
+
|
|
1298
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1299
|
+
&& frame.payload?.role === 'assistant'
|
|
1300
|
+
&& frame.payload.messageId !== messageStart.payload.messageId);
|
|
1301
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 2 } });
|
|
1302
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1303
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1304
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1305
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered from tool failure' } });
|
|
1306
|
+
|
|
1307
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered from tool failure' } });
|
|
1308
|
+
expect(messageStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1309
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1310
|
+
session.emit({ type: 'agent_settled' });
|
|
1006
1311
|
await client.close();
|
|
1007
1312
|
});
|
|
1008
1313
|
|
|
@@ -24,6 +24,20 @@ class PiSessionDaemonUnavailableError extends Error {
|
|
|
24
24
|
|
|
25
25
|
const delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
26
26
|
|
|
27
|
+
const rejectAfter = (promise, timeoutMs, code) => new Promise((resolvePromise, rejectPromise) => {
|
|
28
|
+
const timer = setTimeout(() => rejectPromise(new SessionDaemonClientError(code)), timeoutMs);
|
|
29
|
+
promise.then(
|
|
30
|
+
(value) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolvePromise(value);
|
|
33
|
+
},
|
|
34
|
+
(error) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
rejectPromise(error);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
27
41
|
const getWindowsOwnerKey = () => {
|
|
28
42
|
try {
|
|
29
43
|
return createHash('sha256').update(userInfo().username).digest('hex').slice(0, 16);
|
|
@@ -253,7 +267,12 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
253
267
|
throw new PiSessionDaemonUnavailableError('DAEMON_UNAVAILABLE');
|
|
254
268
|
}
|
|
255
269
|
try {
|
|
256
|
-
const health = await request({
|
|
270
|
+
const health = await rejectAfter(request({
|
|
271
|
+
endpoint: paths.endpoint,
|
|
272
|
+
credential,
|
|
273
|
+
command: 'runtime.health',
|
|
274
|
+
timeoutMs: startupTimeoutMs,
|
|
275
|
+
}), startupTimeoutMs, 'DAEMON_UNAVAILABLE');
|
|
257
276
|
if (health?.state !== 'ready' || health.daemonPid !== state.pid) {
|
|
258
277
|
throw new PiSessionDaemonUnavailableError('DAEMON_IDENTITY_MISMATCH');
|
|
259
278
|
}
|
|
@@ -194,6 +194,49 @@ describe('Pi session daemon supervisor', () => {
|
|
|
194
194
|
expect(windowsSupervisor.paths.endpoint).toMatch(/^\\\\\.\\pipe\\pichamber-pi-session-daemon-[0-9a-f]{16}$/);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it('bounds an unresponsive Windows daemon probe by the supervisor operation timeout', async () => {
|
|
198
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-hung-win-'));
|
|
199
|
+
const dataDir = join(root, 'data');
|
|
200
|
+
const piDataDir = join(dataDir, 'pi');
|
|
201
|
+
await mkdir(piDataDir, { recursive: true });
|
|
202
|
+
const daemonPid = 7_331;
|
|
203
|
+
const processLike = {
|
|
204
|
+
pid: 4_242,
|
|
205
|
+
execPath: process.execPath,
|
|
206
|
+
versions: { electron: '41.2.1' },
|
|
207
|
+
kill(pid, signal) {
|
|
208
|
+
if (pid === daemonPid && signal === 0) return;
|
|
209
|
+
const error = new Error('ESRCH');
|
|
210
|
+
error.code = 'ESRCH';
|
|
211
|
+
throw error;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
const probeTimeoutMs = 20;
|
|
215
|
+
const windowsSupervisor = createPiSessionDaemonSupervisor({
|
|
216
|
+
env: { PICHAMBER_DATA_DIR: dataDir },
|
|
217
|
+
cwd: root,
|
|
218
|
+
dataDir,
|
|
219
|
+
platform: 'win32',
|
|
220
|
+
processLike,
|
|
221
|
+
startupTimeoutMs: probeTimeoutMs,
|
|
222
|
+
request: () => new Promise(() => {}),
|
|
223
|
+
});
|
|
224
|
+
await writeFile(windowsSupervisor.paths.credentialFile, `${'a'.repeat(64)}\n`);
|
|
225
|
+
await writeFile(windowsSupervisor.paths.stateFile, JSON.stringify({
|
|
226
|
+
protocolVersion: 1,
|
|
227
|
+
pid: daemonPid,
|
|
228
|
+
endpoint: windowsSupervisor.paths.endpoint,
|
|
229
|
+
startedAt: new Date().toISOString(),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
const startedAt = Date.now();
|
|
233
|
+
await expect(windowsSupervisor.health()).resolves.toMatchObject({
|
|
234
|
+
state: 'unavailable',
|
|
235
|
+
error: { code: 'DAEMON_UNAVAILABLE' },
|
|
236
|
+
});
|
|
237
|
+
expect(Date.now() - startedAt).toBeLessThan(probeTimeoutMs * 5);
|
|
238
|
+
});
|
|
239
|
+
|
|
197
240
|
it('recovers from a leftover empty daemon lock instead of failing as unavailable', async () => {
|
|
198
241
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-empty-lock-'));
|
|
199
242
|
const cwd = join(root, 'project');
|
|
@@ -5,16 +5,15 @@ This module contains the shared tunnel support surface for PiChamber: provider
|
|
|
5
5
|
capability metadata, tunnel types/normalization helpers, cross-platform
|
|
6
6
|
executable discovery, and dependency install-help metadata.
|
|
7
7
|
|
|
8
|
-
The
|
|
9
|
-
`packages/web/server/lib/cloudflare-tunnel.js
|
|
10
|
-
`packages/web/server/lib/ngrok-tunnel.js`; CLI orchestration lives in
|
|
8
|
+
The tunnel start implementation lives in
|
|
9
|
+
`packages/web/server/lib/cloudflare-tunnel.js`; CLI orchestration lives in
|
|
11
10
|
`packages/web/bin/lib/commands-tunnel.js`.
|
|
12
11
|
|
|
13
12
|
> Wiring note: the previous service orchestration entrypoint
|
|
14
13
|
> (`tunnels/index.js`, `createTunnelService`) was removed as dead code — no
|
|
15
14
|
> runtime entrypoint registered tunnel routes or started a tunnel service.
|
|
16
15
|
> The remaining files are consumed by the CLI capability surface and by the
|
|
17
|
-
> tunnel provider
|
|
16
|
+
> Cloudflare tunnel provider implementation, not by a tunnel orchestration runtime.
|
|
18
17
|
|
|
19
18
|
## Entrypoints and structure
|
|
20
19
|
- `packages/web/server/lib/tunnels/executable-search.js`: cross-platform executable discovery, including Windows Store app aliases. Consumed by the tunnel providers and `packages/web/server/lib/workspace/host.js`.
|
|
@@ -24,10 +23,10 @@ The actual tunnel start implementations live in
|
|
|
24
23
|
## Public exports
|
|
25
24
|
- `install-help.js` → `getTunnelDependencyInstallInfo(provider, platform)`
|
|
26
25
|
- `executable-search.js` → `getExecutableSearchDirectories`, `createExecutableSearchEnv`, `findExecutableOnPath`, `resolveExecutableLaunchTarget`
|
|
27
|
-
- `types.js` → `TUNNEL_PROVIDER_CLOUDFLARE`, `
|
|
26
|
+
- `types.js` → `TUNNEL_PROVIDER_CLOUDFLARE`, `TUNNEL_MODE_QUICK`, `TUNNEL_MODE_MANAGED_REMOTE`, `TUNNEL_MODE_MANAGED_LOCAL`, `TUNNEL_INTENT_EPHEMERAL_PUBLIC`, `TUNNEL_INTENT_PERSISTENT_PUBLIC`, `TunnelServiceError`
|
|
28
27
|
|
|
29
28
|
## Consumers
|
|
30
|
-
- `packages/web/bin/lib/cli-tunnel-capabilities.js`: provider capability metadata for CLI tunnel commands.
|
|
31
|
-
- `packages/web/server/lib/cloudflare-tunnel.js
|
|
29
|
+
- `packages/web/bin/lib/cli-tunnel-capabilities.js`: provider capability metadata for CLI tunnel commands (Cloudflare only).
|
|
30
|
+
- `packages/web/server/lib/cloudflare-tunnel.js`: executable discovery, install-help metadata, and provider constants.
|
|
32
31
|
- `packages/web/server/lib/workspace/host.js`: executable discovery.
|
|
33
32
|
|
|
@@ -36,7 +36,7 @@ describe('getExecutableSearchDirectories', () => {
|
|
|
36
36
|
|
|
37
37
|
describe('findExecutableOnPath', () => {
|
|
38
38
|
it('finds Windows Store app execution aliases even when PATH omits WindowsApps', () => {
|
|
39
|
-
const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\
|
|
39
|
+
const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\cloudflared.exe';
|
|
40
40
|
const fsLike = {
|
|
41
41
|
statSync: (candidate) => {
|
|
42
42
|
if (candidate === aliasPath) {
|
|
@@ -47,7 +47,7 @@ describe('findExecutableOnPath', () => {
|
|
|
47
47
|
accessSync: () => {},
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
const resolved = findExecutableOnPath('
|
|
50
|
+
const resolved = findExecutableOnPath('cloudflared', {
|
|
51
51
|
platform: 'win32',
|
|
52
52
|
env: {
|
|
53
53
|
PATH: 'C:\\Tools',
|
|
@@ -63,7 +63,7 @@ describe('findExecutableOnPath', () => {
|
|
|
63
63
|
|
|
64
64
|
describe('resolveExecutableLaunchTarget', () => {
|
|
65
65
|
it('returns a Windows launch target with WindowsApps on PATH when stat lookup fails', () => {
|
|
66
|
-
const target = resolveExecutableLaunchTarget('
|
|
66
|
+
const target = resolveExecutableLaunchTarget('cloudflared', {
|
|
67
67
|
platform: 'win32',
|
|
68
68
|
env: {
|
|
69
69
|
PATH: 'C:\\Windows\\System32',
|
|
@@ -75,7 +75,7 @@ describe('resolveExecutableLaunchTarget', () => {
|
|
|
75
75
|
},
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
expect(target?.command).toBe('
|
|
78
|
+
expect(target?.command).toBe('cloudflared');
|
|
79
79
|
expect(target?.env.Path).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps');
|
|
80
80
|
});
|
|
81
81
|
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
TUNNEL_PROVIDER_CLOUDFLARE,
|
|
3
|
-
TUNNEL_PROVIDER_NGROK,
|
|
4
3
|
} from './types.js';
|
|
5
4
|
|
|
6
5
|
const PROVIDER_INSTALL_INFO = {
|
|
@@ -13,15 +12,6 @@ const PROVIDER_INSTALL_INFO = {
|
|
|
13
12
|
linux: 'Download cloudflared from https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/',
|
|
14
13
|
},
|
|
15
14
|
},
|
|
16
|
-
[TUNNEL_PROVIDER_NGROK]: {
|
|
17
|
-
dependency: 'ngrok',
|
|
18
|
-
installUrl: 'https://ngrok.com/download',
|
|
19
|
-
commands: {
|
|
20
|
-
darwin: 'brew install ngrok',
|
|
21
|
-
win32: 'winget install ngrok -s msstore',
|
|
22
|
-
linux: 'Download ngrok from https://ngrok.com/download',
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
15
|
};
|
|
26
16
|
|
|
27
17
|
const normalizeInstallPlatform = (platform) => {
|
|
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'bun:test';
|
|
|
3
3
|
import { getTunnelDependencyInstallInfo } from './install-help.js';
|
|
4
4
|
import {
|
|
5
5
|
TUNNEL_PROVIDER_CLOUDFLARE,
|
|
6
|
-
TUNNEL_PROVIDER_NGROK,
|
|
7
6
|
} from './types.js';
|
|
8
7
|
|
|
9
8
|
describe('getTunnelDependencyInstallInfo', () => {
|
|
@@ -15,14 +14,6 @@ describe('getTunnelDependencyInstallInfo', () => {
|
|
|
15
14
|
expect(info.message).toContain('Cloudflare.cloudflared');
|
|
16
15
|
});
|
|
17
16
|
|
|
18
|
-
it('returns Windows ngrok winget guidance', () => {
|
|
19
|
-
const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK, 'win32');
|
|
20
|
-
|
|
21
|
-
expect(info.dependency).toBe('ngrok');
|
|
22
|
-
expect(info.installCommand).toBe('winget install ngrok -s msstore');
|
|
23
|
-
expect(info.message).toContain('ngrok -s msstore');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
17
|
it('keeps macOS Homebrew guidance', () => {
|
|
27
18
|
const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, 'darwin');
|
|
28
19
|
|