From 6219144557ffaab863bbed481322c91ea8bdbd96 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:25:18 -0700 Subject: [PATCH 1/2] feat(accounts): isolate ZCode sign-in terminal setup --- .../settings/ZcodeCliSetupSection.test.tsx | 74 +++++++++++ .../settings/ZcodeCliSetupSection.tsx | 122 ++++++++++++++++++ .../ZcodePlanAccountsSection.test.tsx | 1 + .../settings/ZcodePlanAccountsSection.tsx | 3 + src/renderer/src/i18n/locales/en.json | 14 ++ src/renderer/src/i18n/locales/zh.json | 14 ++ tests/e2e/helpers/orca-app.ts | 2 +- tests/e2e/zcode-cli-setup.spec.ts | 40 ++++++ 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx create mode 100644 src/renderer/src/components/settings/ZcodeCliSetupSection.tsx create mode 100644 tests/e2e/zcode-cli-setup.spec.ts diff --git a/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx b/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx new file mode 100644 index 000000000000..b8f37d868839 --- /dev/null +++ b/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ZcodeCliSetupSection } from './ZcodeCliSetupSection' + +const mocks = vi.hoisted(() => ({ settings: { agentCmdOverrides: { zcode: '' } } })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector(mocks) +})) +vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: ({ + command, + description + }: { + command: string + description: string + }) => ( +
+ {command} +

{description}

+
+ ) +})) +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +beforeEach(() => { + mocks.settings.agentCmdOverrides.zcode = '' +}) +afterEach(() => cleanup()) + +describe('ZCode CLI-owned sign-in setup', () => { + it('starts no terminal until the user opens a supported flow', () => { + render() + expect(screen.queryByTestId('setup-terminal')).not.toBeInTheDocument() + expect(screen.getByText(/ZCode’s masked API-key setup/)).toBeInTheDocument() + expect(screen.getByText(/use \/model to choose a GLM model/)).toBeInTheDocument() + }) + + it.each([ + ['Z.AI browser sign-in', 'zcode login zai --no-browser'], + ['BigModel browser sign-in', 'zcode login bigmodel --no-browser'], + ['ZCode API-key setup', 'zcode'] + ])('delegates %s to the supported upstream CLI command', async (label, command) => { + render() + fireEvent.click(screen.getByRole('button', { name: label })) + expect(await screen.findByTestId('setup-terminal')).toHaveTextContent(command) + expect(screen.getByRole('button', { name: 'Z.AI browser sign-in' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'BigModel browser sign-in' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'ZCode API-key setup' })).toBeDisabled() + if (label === 'ZCode API-key setup') { + expect(screen.getByText(/enter \/login.*masked prompt/)).toBeInTheDocument() + } else { + expect(screen.getByText(/authorization URL printed by ZCode/)).toBeInTheDocument() + } + fireEvent.click(screen.getByRole('button', { name: 'Close setup terminal' })) + expect(screen.queryByTestId('setup-terminal')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: label })).toBeEnabled() + }) + + it('preserves the configured CLI executable instead of inventing model flags', async () => { + mocks.settings.agentCmdOverrides.zcode = 'node "C:\\ZCode CLI\\zcode.cjs"' + render() + fireEvent.click(screen.getByRole('button', { name: 'BigModel browser sign-in' })) + expect(await screen.findByTestId('setup-terminal')).toHaveTextContent( + 'node "C:\\ZCode CLI\\zcode.cjs" login bigmodel --no-browser' + ) + expect(screen.getByTestId('setup-terminal')).not.toHaveTextContent('--model') + }) +}) diff --git a/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx b/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx new file mode 100644 index 000000000000..922c926380db --- /dev/null +++ b/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx @@ -0,0 +1,122 @@ +import { lazy, Suspense, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { SearchableSetting } from './SearchableSetting' + +const OnboardingInlineCommandTerminal = lazy(() => + import('../onboarding/OnboardingInlineCommandTerminal').then((module) => ({ + default: module.OnboardingInlineCommandTerminal + })) +) + +type SetupChoice = 'zai' | 'bigmodel' | 'api-key' + +export function ZcodeCliSetupSection(): React.JSX.Element { + const commandOverride = useAppStore((s) => s.settings?.agentCmdOverrides?.zcode) + const [choice, setChoice] = useState(null) + const executable = commandOverride?.trim() || 'zcode' + const command = choice === 'api-key' ? executable : `${executable} login ${choice} --no-browser` + + const title = translate( + 'auto.components.settings.ZcodeCliSetupSection.title', + 'Use GLM with ZCode' + ) + const description = translate( + 'auto.components.settings.ZcodeCliSetupSection.description', + 'Sign in to use GLM as a coding agent. The quota API key above only monitors usage; ZCode owns its own sign-in and model selection.' + ) + + return ( + +

{title}

+

{description}

+

+ {translate( + 'auto.components.settings.ZcodeCliSetupSection.help', + 'Choose browser sign-in for your plan site, or use ZCode’s masked API-key setup. After signing in, start ZCode and use /model to choose a GLM model.' + )} +

+
+ + + + {choice ? ( + + ) : null} +
+ {choice ? ( + + {translate( + 'auto.components.settings.ZcodeCliSetupSection.loading', + 'Loading sign-in terminal…' + )} +

+ } + > + +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index 32c277b72ced..89f9c1f708e5 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -145,6 +145,7 @@ describe('ZcodePlanAccountsSection', () => { render() const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key') + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) fireEvent.change(input, { target: { value: ' glm-secret ' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx index be3d5e1010ef..7efad3c3a170 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -18,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { SearchableSetting } from './SearchableSetting' import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows' import { useZcodePlanCredentials } from './use-zcode-plan-credentials' +import { ZcodeCliSetupSection } from './ZcodeCliSetupSection' import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' const SEARCH_KEYWORDS = [ @@ -330,6 +331,8 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {

+ {credentialEditable ? : null} + {usageWindows.length > 0 ? ( ({ if (!store) { return false } - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/zcode-cli-setup.spec.ts b/tests/e2e/zcode-cli-setup.spec.ts new file mode 100644 index 000000000000..7f92e3d956b1 --- /dev/null +++ b/tests/e2e/zcode-cli-setup.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { expectTerminalAccessibilityText } from './helpers/terminal-accessibility-tree' + +test('Accounts prepares each ZCode sign-in command and closes its terminal', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-zcode' }) + state?.openSettingsPage() + }) + const section = orcaPage.locator('#accounts-zcode') + await expect(section).toBeVisible({ timeout: 30_000 }) + await section.screenshot({ path: testInfo.outputPath('01-setup-buttons.png') }) + for (const [label, command] of [ + ['Z.AI browser sign-in', 'zcode login zai --no-browser'], + ['BigModel browser sign-in', 'zcode login bigmodel --no-browser'], + ['ZCode API-key setup', 'zcode'] + ] as const) { + await section.getByRole('button', { name: label, exact: true }).click() + const close = section.getByRole('button', { name: 'Close setup terminal', exact: true }) + await expect(close).toBeVisible({ timeout: 15_000 }) + const tabId = await section + .locator('[data-terminal-tab-id]') + .first() + .getAttribute('data-terminal-tab-id') + if (!tabId) { + throw new Error('Setup terminal did not mount') + } + await expectTerminalAccessibilityText(orcaPage, tabId, command) + await expect(section.getByRole('button', { name: label, exact: true })).toBeDisabled() + await section.screenshot({ path: testInfo.outputPath(`${label}.png`) }) + await close.click() + await expect(close).toHaveCount(0) + await expect(section.locator('[data-terminal-tab-id]')).toHaveCount(0) + await expect(section.getByRole('button', { name: label, exact: true })).toBeEnabled() + } +}) From 3bc0200f9b390b1054303a5240007eeda0417b86 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:39:10 -0700 Subject: [PATCH 2/2] test(composer): reuse fixtures to restore the main lint gate --- .../use-automations-page-setup-state.ts | 3 +- ...lder-workspace-composer-submit-fixtures.ts | 36 ++++++++++++++++++ ...pace-composer-submit-host-platform.test.ts | 36 +----------------- .../folder-workspace-composer-submit.test.ts | 37 +------------------ 4 files changed, 39 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index ae0939085fe3..5ada1d683d28 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -150,8 +150,7 @@ export function useAutomationsPageSetupState({ automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, - setAutomationYamlHooksByRepoKey, - settings + setAutomationYamlHooksByRepoKey ] ) const getDraftSetupDecisionDefaultSignature = useCallback( diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts new file mode 100644 index 000000000000..303445980ab1 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts @@ -0,0 +1,36 @@ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' + +export function makeProjectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/repo/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +export function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'hi', + folderPath: '/repo/platform/hi', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts index f1fcb5e6f008..95a01c86f40b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn() })) @@ -14,39 +13,6 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => { import { useAppStore } from '@/store' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } -} - // Why: a folder group on an Orca server runs its agents there, so the server's OS decides quoting. describe('submitFolderWorkspaceCreate on an Orca server', () => { afterEach(() => { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 85d5f2020be0..0964e076f87a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type * as NewWorkspaceModule from '@/lib/new-workspace' @@ -30,40 +29,6 @@ import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - ...overrides - } -} - describe('submitFolderWorkspaceCreate', () => { it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { const linkedWorkItem = {