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
12 changes: 7 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ credits are listed soonest-expiring first, and a note counts any the provider
did not list. The dialog title names the account email, adding the workspace
only when the email alone is ambiguous.

The empty state is two deliberate choices: import one or more export files the
user selects, or add an account manually. The add dialog groups the normal path
as **Import existing** (Find on this computer and Choose files) then **Add
new** (official Codex sign-in). Pasted auth JSON and API key remain an **Other
methods** disclosure. One concise privacy note says that GSwitch reads only
The empty state and the add dialog share the same choices: a prominent
**Sign in** action starts official Codex browser sign-in directly, followed by
**Find on this computer** and **Choose files**. Pasted auth JSON and API key
remain an **Other methods** disclosure. Browser actions use a tabbed-window
icon; the globe belongs only to the language menu. The notice for an unsaved
current account retains its **Save current account** shortcut. One concise
privacy note says that GSwitch reads only
accounts the user chooses to import and does not change their source. The local
preview explains its allowlist before scanning and keeps already-saved
identities disabled.
Expand Down
7 changes: 5 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,11 @@ as applicable:
selections, cancellation before write, Unix private permissions, version-1
import round-trip, unknown-version rejection, saved-identity deduplication,
and aggregate-only command output;
- Add Account import-first hierarchy, account selection controls, selected Wake,
unencrypted-export confirmation, and result feedback without credential data.
- shared first-use and Add Account choices, direct browser-sign-in launch,
disabled choices during launch, launch failure and cancellation, collapsed
other methods, explicit scanning and file selection, and the shortcut to save
the current account; account selection controls, selected Wake, unencrypted-export
confirmation, and result feedback without credential data.
- the toolbar's centered brand without a duplicate current-account label,
current-card identification, signed-out and recovery notices, theme-aware
language icon, plus the canonical SVG and generated native/installer icon dimensions.
Expand Down
88 changes: 67 additions & 21 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,9 @@ describe("GSwitch account workspace", () => {

expect(await screen.findByRole("heading", { name: "已保存 0 个账户" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "添加账户" }));
expect(await screen.findByRole("dialog", { name: "添加 Codex 账户" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /粘贴 auth JSON/ })).toBeInTheDocument();
const dialog = await screen.findByRole("dialog", { name: "添加 Codex 账户" });
await userEvent.click(within(dialog).getByText("其他方式"));
expect(within(dialog).getByRole("button", { name: /粘贴 auth JSON/ })).toBeVisible();
});

it("applies and remembers a manual language choice immediately", async () => {
Expand All @@ -315,28 +316,76 @@ describe("GSwitch account workspace", () => {
expect(screen.getByRole("button", { name: "完成" })).toHaveAttribute("aria-pressed", "true");
});

it("guides a first-time user to an explicit import or manual add", async () => {
it("offers the same sign-in and import choices on first use and in Add account", async () => {
render(<App />);

expect(await screen.findByRole("heading", { name: "0 saved accounts" })).toBeInTheDocument();
expect(screen.getByText(/scans this computer only when you choose to/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import account files" })).toBeInTheDocument();
const firstRun = screen.getByRole("heading", { name: "Add your Codex accounts" }).closest("section")!;
expect(within(firstRun).getAllByRole("button").slice(0, 3).map((button) => button.querySelector("strong")?.textContent))
.toEqual(["Sign in", "Find on this computer", "Choose files"]);
expect(within(firstRun).getByText("Paste auth JSON")).not.toBeVisible();
expect(screen.queryByText("Add manually")).not.toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Add manually" }));
await userEvent.click(screen.getByRole("button", { name: "Add account" }));
const dialog = await screen.findByRole("dialog", { name: "Add a Codex account" });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getAllByRole("button").slice(1, 4).map((button) => button.querySelector("strong")?.textContent))
.toEqual(["Sign in", "Find on this computer", "Choose files"]);
expect(within(dialog).getByText("Paste auth JSON")).not.toBeVisible();
await userEvent.click(within(dialog).getByText("Other methods"));
expect(within(dialog).getByRole("button", { name: /Paste auth JSON/ })).toBeInTheDocument();
expect(within(dialog).getByText("Import existing")).toBeInTheDocument();
expect(within(dialog).getByText("Add new")).toBeInTheDocument();
expect(within(dialog).queryByText("Import existing")).not.toBeInTheDocument();
expect(within(dialog).queryByText("Add new")).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /Choose files/ })).toBeInTheDocument();
expect(within(dialog).getByText(/Select one or more account files/i)).toBeInTheDocument();
});

it("starts browser sign-in directly from first use and blocks another start while pending", async () => {
let finishStart!: (value: { login_id: string; auth_url: string }) => void;
mocks.startOAuth.mockImplementation(() => new Promise((resolve) => { finishStart = resolve; }));
render(<App />);
const signIn = await screen.findByRole("button", { name: /^Sign in / });
await userEvent.dblClick(signIn);
expect(mocks.startOAuth).toHaveBeenCalledExactlyOnceWith(undefined);
expect(signIn).toBeDisabled();
expect(screen.getByRole("button", { name: /^Find on this computer/ })).toBeDisabled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

finishStart({ login_id: "login-1", auth_url: "https://example.com/sign-in" });
expect(await screen.findByText("Finish sign-in in your browser")).toBeInTheDocument();
expect(mocks.openOAuth).not.toHaveBeenCalled();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
expect(mocks.importAuthFiles).not.toHaveBeenCalled();
});

it("keeps first-use choices available when browser sign-in cannot start", async () => {
mocks.startOAuth.mockRejectedValue("network");
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: /^Sign in / }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Sign in / })).toBeEnabled();
expect(screen.getByRole("button", { name: /^Choose files/ })).toBeEnabled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("opens other methods directly from the Chinese first-use screen", async () => {
Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" });
render(<App />);
expect(await screen.findByRole("heading", { name: "添加你的 Codex 账户" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^登录 / })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^从本机查找/ })).toBeInTheDocument();
await userEvent.click(screen.getByText("其他方式"));
await userEvent.click(screen.getByRole("button", { name: /^粘贴 auth JSON/ }));
const dialog = await screen.findByRole("dialog", { name: "添加 Codex 账户" });
expect(within(dialog).getByRole("textbox", { name: "auth.json" })).toBeInTheDocument();
expect(mocks.startOAuth).not.toHaveBeenCalled();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
});

it("keeps keyboard focus in a dialog and returns it to the launcher", async () => {
const user = userEvent.setup();
render(<App />);
await screen.findByRole("heading", { name: "0 saved accounts" });
const launcher = screen.getByRole("button", { name: "Add manually" });
const launcher = screen.getByRole("button", { name: "Add account" });
await user.click(launcher);
const dialog = screen.getByRole("dialog", { name: "Add a Codex account" });
expect(dialog).toHaveFocus();
Expand Down Expand Up @@ -430,7 +479,6 @@ describe("GSwitch account workspace", () => {
expect(await screen.findByRole("heading", { name: "0 saved accounts" })).toBeInTheDocument();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();

await userEvent.click(screen.getByRole("button", { name: "Add manually" }));
await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
expect(screen.getByText(/Nothing is imported until you select accounts/i)).toBeInTheDocument();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
Expand Down Expand Up @@ -463,8 +511,7 @@ describe("GSwitch account workspace", () => {
});
render(<App />);

await userEvent.click(await screen.findByRole("button", { name: "Add manually" }));
await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
await userEvent.click(await screen.findByRole("button", { name: /Find on this computer/ }));
await userEvent.click(screen.getByRole("button", { name: "Scan supported locations" }));

expect(await screen.findByText("person@example.com")).toBeInTheDocument();
Expand All @@ -485,8 +532,7 @@ describe("GSwitch account workspace", () => {
mocks.discoverLocalAccounts.mockResolvedValue({ candidates: [] });
render(<App />);

await userEvent.click(await screen.findByRole("button", { name: "Add manually" }));
await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
await userEvent.click(await screen.findByRole("button", { name: /Find on this computer/ }));
await userEvent.click(screen.getByRole("button", { name: "Choose another Cockpit folder" }));

await waitFor(() => expect(mocks.discoverLocalAccounts).toHaveBeenCalledWith("C:\\custom\\cockpit"));
Expand All @@ -503,7 +549,7 @@ describe("GSwitch account workspace", () => {
});
render(<App />);

await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
await waitFor(() =>
expect(mocks.importAuthFiles).toHaveBeenCalledWith([
"C:\\exports\\one.json",
Expand All @@ -518,7 +564,7 @@ describe("GSwitch account workspace", () => {
vi.mocked(open).mockResolvedValue(["C:\\exports\\one.json"]);
render(<App />);

await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
await waitFor(() => expect(mocks.importAuthFiles).toHaveBeenCalledWith(["C:\\exports\\one.json"]));
});

Expand All @@ -538,7 +584,7 @@ describe("GSwitch account workspace", () => {
mocks.refreshAccountQuota.mockRejectedValue(new Error("quota cache unavailable"));
render(<App />);

await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
expect(await screen.findByRole("heading", { name: "person@example.com" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch to person@example.com" })).toBeEnabled();
});
Expand Down Expand Up @@ -1644,7 +1690,7 @@ describe("GSwitch account workspace", () => {

await userEvent.click(screen.getByRole("button", { name: /^Add account$/ }));
const dialog = await screen.findByRole("dialog", { name: "Add a Codex account" });
await userEvent.click(within(dialog).getByRole("button", { name: /Sign in to add an account/ }));
await userEvent.click(within(dialog).getByRole("button", { name: /^Sign in / }));
expect(await screen.findByText("Finish sign-in in your browser")).toBeInTheDocument();
expect(mocks.openOAuth).not.toHaveBeenCalled();

Expand All @@ -1659,7 +1705,7 @@ describe("GSwitch account workspace", () => {
.mockResolvedValue({ status: "complete", account: chatAccount });
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: /^Add account$/ }));
await userEvent.click(screen.getByRole("button", { name: /Sign in to add an account/ }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /^Sign in / }));

expect(await screen.findByText(/cannot check the sign-in result right now/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Add a Codex account" })).toBeInTheDocument();
Expand All @@ -1676,7 +1722,7 @@ describe("GSwitch account workspace", () => {
.mockResolvedValue({ status: "complete", account: { ...chatAccount, needs_apply: true }, cleanup_warning: false });
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: /^Add account$/ }));
await userEvent.click(screen.getByRole("button", { name: /Sign in to add an account/ }));
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /^Sign in / }));
expect(await screen.findByText("Finishing sign-in…")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Retry saving" }));
expect(mocks.retryOAuth).toHaveBeenCalledWith("login-1");
Expand Down
Loading
Loading