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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"dependencies": {
"@fontsource/atkinson-hyperlegible": "5.3.0",
"@fontsource/azeret-mono": "5.3.0",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz",
"@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.11.tgz",
"lucide-react": "1.25.0",
"react": "19.2.8",
Expand Down Expand Up @@ -55,7 +55,7 @@
},
"pnpm": {
"overrides": {
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz"
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz"
}
}
}
28 changes: 14 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

260 changes: 259 additions & 1 deletion src/components/connection-gate.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,267 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";

import type { MdbaseApplicationSessionSnapshot } from "@mdbase-dev/connect";

const connectMock = vi.hoisted(() => {
const ok = <Value,>(value: Value) => ({
ok: true as const,
value,
diagnostics: [],
});
let snapshot: MdbaseApplicationSessionSnapshot = {
status: "unselected",
connections: [],
};
const listeners = new Set<() => void>();
const isAuthorizationCallback = vi.fn(() => false);
const session = {
applyCollectionSetup: vi.fn(),
authorize: vi.fn(),
clearSelection: vi.fn(),
connection: vi.fn(() => null),
getSnapshot: vi.fn(() => snapshot),
handleAuthorizationCallback: vi.fn(),
select: vi.fn(),
start: vi.fn(),
subscribe: vi.fn((listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
};
return {
session,
isAuthorizationCallback,
reset() {
snapshot = { status: "unselected", connections: [] };
listeners.clear();
for (const mock of Object.values(session)) {
if ("mockReset" in mock) mock.mockReset();
}
session.connection.mockReturnValue(null);
session.start.mockResolvedValue(ok(snapshot));
session.authorize.mockResolvedValue(ok({ kind: "redirected" }));
session.applyCollectionSetup.mockResolvedValue(ok(snapshot));
session.handleAuthorizationCallback.mockResolvedValue(ok({}));
session.select.mockReturnValue(ok({}));
session.clearSelection.mockReturnValue(ok(undefined));
isAuthorizationCallback.mockReset();
isAuthorizationCallback.mockReturnValue(false);
},
setSnapshot(next: MdbaseApplicationSessionSnapshot) {
snapshot = next;
for (const listener of listeners) listener();
},
};
});

vi.mock("../data/connect", () => ({
isAuthorizationCallback: connectMock.isAuthorizationCallback,
plannerSession: connectMock.session,
}));

import { SetupReview, WelcomePreview } from "./connection-gate";

beforeEach(() => connectMock.reset());

describe("ConnectionGate lifecycle", () => {
it("retries a failed explicit start and gates collection actions", async () => {
connectMock.session.start
.mockImplementationOnce(async () => {
const problem = {
code: "temporarily_unavailable",
message: "Connect is temporarily unavailable.",
};
connectMock.setSnapshot({
status: "start_failed",
problem,
connections: [],
} as unknown as MdbaseApplicationSessionSnapshot);
return { ok: false as const, problem, diagnostics: [] };
})
.mockImplementationOnce(async () => {
const snapshot: MdbaseApplicationSessionSnapshot = {
status: "unselected",
connections: [],
};
connectMock.setSnapshot(snapshot);
return { ok: true as const, value: snapshot, diagnostics: [] };
});

const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

expect(
await screen.findByRole("button", { name: "Retry opening Planner" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Open collection" }),
).not.toBeInTheDocument();

fireEvent.click(
screen.getByRole("button", { name: "Retry opening Planner" }),
);

await screen.findByRole("button", { name: "Open collection" });
expect(connectMock.session.start).toHaveBeenCalledTimes(2);
});

it("makes an abandoned startup timeout retryable from not_started", async () => {
connectMock.session.start
.mockImplementationOnce(async () => {
connectMock.setSnapshot({ status: "starting", connections: [] });
await Promise.resolve();
connectMock.setSnapshot({ status: "not_started", connections: [] });
return {
ok: false as const,
problem: { code: "timeout", message: "Startup timed out." },
};
})
.mockImplementationOnce(async () => {
const snapshot: MdbaseApplicationSessionSnapshot = {
status: "unselected",
connections: [],
};
connectMock.setSnapshot(snapshot);
return { ok: true as const, value: snapshot, diagnostics: [] };
});

const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

fireEvent.click(
await screen.findByRole("button", { name: "Retry opening Planner" }),
);
await screen.findByRole("button", { name: "Open collection" });

expect(connectMock.session.start).toHaveBeenNthCalledWith(1, {
timeoutMs: 20_000,
});
expect(connectMock.session.start).toHaveBeenCalledTimes(2);
});

it("lets startup own callback completion with the callback budget", async () => {
connectMock.isAuthorizationCallback.mockReturnValue(true);
const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

await waitFor(() =>
expect(connectMock.session.start).toHaveBeenCalledWith({
timeoutMs: 60_000,
}),
);
expect(
connectMock.session.handleAuthorizationCallback,
).not.toHaveBeenCalled();
});

it("reviews declaration changes through explicit selected authorization", async () => {
connectMock.setSnapshot(authorizationRequiredSnapshot());
const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

fireEvent.click(
await screen.findByRole("button", { name: "Review updated access" }),
);

await waitFor(() =>
expect(connectMock.session.authorize).toHaveBeenCalledWith("selected", {
timeoutMs: 60_000,
}),
);
});

it("omits the selected unusable collection from alternatives", async () => {
const selected = authorizationRequiredSnapshot();
connectMock.setSnapshot({
...selected,
connections: [
selected.info,
{ ...selected.info, collectionId: "collection-2", displayName: "Home" },
],
});
const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

expect(
await screen.findByRole("button", { name: "Open Home" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Open Product" }),
).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Open Home" }));
expect(connectMock.session.select).toHaveBeenCalledWith("collection-2", {
history: "replace",
});
});

it("reauthorizes an unavailable selected collection in place", async () => {
connectMock.setSnapshot({
status: "unavailable",
collectionId: "collection-1",
reason: "invalid_stored_grant",
connections: [],
});
const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

fireEvent.click(
await screen.findByRole("button", { name: "Review updated access" }),
);

await waitFor(() =>
expect(connectMock.session.authorize).toHaveBeenCalledWith("selected", {
timeoutMs: 60_000,
}),
);
});

it("keeps destroyed sessions terminal", async () => {
connectMock.setSnapshot({ status: "destroyed", connections: [] });
const { ConnectionGate } = await import("./connection-gate");
render(<ConnectionGate onDemo={vi.fn()} />);

expect(
await screen.findByText(
"This collection session has closed. Reload Planner to reconnect.",
),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /open collection|retry opening/i }),
).not.toBeInTheDocument();
expect(connectMock.session.start).not.toHaveBeenCalled();
});
});

function authorizationRequiredSnapshot(): Extract<
MdbaseApplicationSessionSnapshot,
{ status: "authorization_required" }
> {
return {
status: "authorization_required",
collectionId: "collection-1",
info: {
collectionId: "collection-1",
displayName: "Product",
operations: [],
scope: { kind: "collection" },
authority: { kind: "connector", durability: "computer" },
route: "relay",
directAccess: "unavailable",
},
capabilities: {
requiredAvailable: false,
optionalAvailable: false,
available: [],
unavailable: [],
},
connections: [],
} as unknown as Extract<
MdbaseApplicationSessionSnapshot,
{ status: "authorization_required" }
>;
}

describe("WelcomePreview", () => {
it("moves example tasks with pointer dragging", () => {
render(<WelcomePreview />);
Expand Down
Loading