Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,136 @@ function sendHello(client: WebSocket, token: string): void {
}));
}

function waitForEnvelope(envelopes: ParsedSyncEnvelope[], type: string, requestId: string) {
return waitForValue(
() => envelopes.find((envelope) => envelope.type === type && envelope.requestId === requestId),
`${type} response ${requestId}`,
);
}

async function connectPeer(port: number, token: string, deviceId: string) {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const tracked = trackClientEnvelopes(ws);
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
ws.send(encodeSyncEnvelope({
type: "hello",
payload: {
peer: {
deviceId,
deviceName: deviceId,
platform: "iOS",
deviceType: "phone",
siteId: `${deviceId}-site`,
dbVersion: 0,
},
auth: { kind: "bootstrap", token },
},
}));
await waitForValue(
() => tracked.envelopes.find((envelope) => envelope.type === "hello_ok"),
`hello_ok for ${deviceId}`,
);
return { ws, ...tracked };
}

describe("mobile command result ledger", () => {
beforeEach(() => {
publishMock.mockReset();
spawnMock.mockReset();
bonjourDestroyMock.mockReset();
bonjourConstructorMock.mockReset();
spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() }));
});

it("persists chat.send results so restart replays do not duplicate messages", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const sendMessage = vi.fn(async () => {});
let firstHost: ReturnType<typeof createSyncHostService> | null = null;
let secondHost: ReturnType<typeof createSyncHostService> | null = null;
let firstClient: Awaited<ReturnType<typeof connectPeer>> | null = null;
let secondClient: Awaited<ReturnType<typeof connectPeer>> | null = null;
const commandId = "cmd-chat-send-1";

const makeHost = () => {
const base = createHostArgs(projectRoot, []);
return createSyncHostService({
...base,
projectId: "project-1",
deviceRegistryService: {
...base.deviceRegistryService,
upsertPeerMetadata: vi.fn(),
},
agentChatService: {
sendMessage,
subscribeToEvents: vi.fn(() => vi.fn()),
},
} as unknown as Parameters<typeof createSyncHostService>[0]);
};
const sendChatCommand = (client: Awaited<ReturnType<typeof connectPeer>>, requestId: string): void => {
client.ws.send(encodeSyncEnvelope({
type: "command",
requestId,
projectId: "project-1",
payload: {
commandId,
action: "chat.send",
projectId: "project-1",
args: {
sessionId: "session-1",
text: "hello from iOS",
},
},
}));
};

try {
firstHost = makeHost();
const firstPort = await firstHost.waitUntilListening();
const token = firstHost.getBootstrapToken();
firstClient = await connectPeer(firstPort, token, "ios-chat-1");
sendChatCommand(firstClient, "send-1");
await waitForEnvelope(firstClient.envelopes, "command_ack", "send-1");
const firstResult = await waitForEnvelope(firstClient.envelopes, "command_result", "send-1");
expect(firstResult.payload).toMatchObject({
commandId,
ok: true,
result: { ok: true },
});
expect(sendMessage).toHaveBeenCalledTimes(1);

firstClient.ws.close();
await firstHost.dispose();
firstHost = null;

secondHost = makeHost();
const secondPort = await secondHost.waitUntilListening();
secondClient = await connectPeer(secondPort, token, "ios-chat-1");
sendChatCommand(secondClient, "send-2");
await waitForEnvelope(secondClient.envelopes, "command_ack", "send-2");
const replayResult = await waitForEnvelope(secondClient.envelopes, "command_result", "send-2");
expect(replayResult.payload).toMatchObject({
commandId,
ok: true,
result: { ok: true },
});
expect(sendMessage).toHaveBeenCalledTimes(1);
} finally {
try {
firstClient?.ws.close();
secondClient?.ws.close();
} catch {
// ignore
}
await firstHost?.dispose();
await secondHost?.dispose();
cleanup();
}
});
});

describe("sync host handoff over a shared listener", () => {
beforeEach(() => {
publishMock.mockReset();
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set<string>([
"processes.start",
"processes.stop",
"processes.kill",
"chat.send",
"chat.interrupt",
"chat.approve",
"chat.respondToInput",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* @vitest-environment jsdom */

import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAppStore } from "../../state/appStore";
import { WorktreesCard } from "./WorktreesCard";

describe("WorktreesCard", () => {
const originalAde = window.ade;
const originalRefreshLanes = useAppStore.getState().refreshLanes;
let refreshLanes: typeof originalRefreshLanes;

beforeEach(() => {
refreshLanes = vi.fn(async () => undefined) as unknown as typeof originalRefreshLanes;
useAppStore.setState({ refreshLanes });
window.ade = ({
...(originalAde ?? {}),
lanes: {
...((originalAde as typeof window.ade | undefined)?.lanes ?? {}),
listUnregisteredWorktrees: vi.fn()
.mockResolvedValueOnce([{ path: "/repo/worktree-a", branch: "feature/a" }])
.mockResolvedValueOnce([]),
attach: vi.fn(async () => ({})),
},
} as unknown) as typeof window.ade;
});

afterEach(() => {
cleanup();
window.ade = originalAde;
useAppStore.setState({ refreshLanes: originalRefreshLanes });
vi.restoreAllMocks();
});

it("refreshes the shared lane store after importing existing worktrees", async () => {
render(<WorktreesCard />);

await screen.findByText("feature/a");
fireEvent.click(screen.getByRole("button", { name: "Add 1 as lane" }));

await waitFor(() => {
expect(window.ade.lanes.attach).toHaveBeenCalledWith({
name: "feature/a",
attachedPath: "/repo/worktree-a",
});
expect(refreshLanes).toHaveBeenCalledWith({ includeStatus: false });
expect(window.ade.lanes.listUnregisteredWorktrees).toHaveBeenCalledTimes(2);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { CircleNotch, GitFork, WarningCircle, Check } from "@phosphor-icons/react";
import type { UnregisteredLaneCandidate } from "../../../shared/types/lanes";
import { useAppStore } from "../../state/appStore";
import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens";
import { Button } from "../ui/Button";
import { RescanButton } from "./RescanButton";
Expand All @@ -9,6 +10,7 @@ import { CARD_BASE, SECTION_LABEL } from "./onboardingTheme";
type LoadOptions = { preselectAll?: boolean; keepSelected?: Set<string> };

export function WorktreesCard() {
const refreshLanes = useAppStore((s) => s.refreshLanes);
const [worktrees, setWorktrees] = useState<UnregisteredLaneCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
Expand Down Expand Up @@ -85,6 +87,15 @@ export function WorktreesCard() {
);
}
}
if (ok > 0) {
try {
await refreshLanes({ includeStatus: false });
} catch (err) {
collectedErrors.push(
`Added ${ok} worktree${ok !== 1 ? "s" : ""}, but the lane list did not refresh: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
setErrors(collectedErrors);
setImportedCount((c) => c + ok);
setAttaching(false);
Expand Down
Loading