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
16 changes: 12 additions & 4 deletions apps/desktop/e2e/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ test('adds a catalog provider through the canonical API-key dialog', async ({ wi
expect((await dialog.boundingBox())?.width).toBe(dialogBox?.width);
expect((await dialog.boundingBox())?.height).toBe(dialogBox?.height);
await keyInput.fill('e2e-cerebras-key');
await dialog.getByRole('button', { name: '连接并使用', exact: true }).click();
await dialog.getByRole('button', { name: '保存供应商', exact: true }).click();

await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: /添加模型供应商:Cerebras/ })).toBeFocused();
Expand All @@ -102,14 +102,13 @@ test('adds a catalog provider through the canonical API-key dialog', async ({ wi
expect((await detailDialog.boundingBox())?.height).toBe(detailHeightBefore);
await detailKeyField.fill('');

await expect(detailDialog.getByText('GPT OSS 120B', { exact: true })).toBeHidden();
await detailDialog.getByText('高级设置', { exact: true }).click();

// The full candidate catalog is shown persistently as one checkbox list; the
// default model is checked and locked, and toggling a candidate flows through
// the shared enabledModelIds path (no search-only "add" surface).
const modelList = detailDialog.getByRole('list', { name: '模型列表' });
await expect(modelList).toBeVisible();
await expect(detailDialog.getByRole('button', { name: '测试连接', exact: true })).toBeVisible();
await expect(detailDialog.getByRole('button', { name: '更新模型目录', exact: true })).toBeVisible();
const defaultRow = modelList.getByRole('checkbox', { name: /GPT OSS 120B/ });
await expect(defaultRow).toBeChecked();
await expect(defaultRow).toBeDisabled();
Expand Down Expand Up @@ -155,7 +154,16 @@ test('adds a catalog provider through the canonical API-key dialog', async ({ wi
await toggledRow.click();
await expect(toggledRow).toHaveAttribute('aria-checked', afterArrow.checked!);

// Default-model management is a visible searchable picker in the connection
// detail, rather than a trip through General Settings. Selecting an already
// enabled model persists immediately and locks that row as the new default.
await detailDialog.getByRole('button', { name: '此连接的默认模型', exact: true }).click();
await page.getByRole('option', { name: /Gemma/ }).first().click();
await expect(detailDialog.getByRole('button', { name: '此连接的默认模型', exact: true })).toContainText(/Gemma/);
await expect(modelList.getByRole('checkbox', { name: /Gemma/ }).first()).toBeDisabled();

await expect(detailDialog.getByRole('textbox', { name: /模型密钥/ })).toHaveAttribute('placeholder', '••••••••');
await detailDialog.getByText('高级设置', { exact: true }).click();

// Short-viewport invariant: when the expanded detail content outgrows the
// popup's 85dvh cap, the dialog must stay within the viewport and the BODY
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { LlmConnection } from '@maka/core';
import { deleteConnectionWithCredential } from '../delete-connection-with-credential.js';

function connection(): LlmConnection {
return {
slug: 'codex-subscription',
name: 'Codex OAuth',
providerType: 'openai-codex',
defaultModel: 'gpt-5.6-sol',
enabled: true,
enabledModelIds: ['gpt-5.6-sol'],
createdAt: 1,
updatedAt: 1,
};
}

describe('deleteConnectionWithCredential', () => {
it('disconnects an account-managed connection before removing its catalog row', async () => {
const calls: string[] = [];
await deleteConnectionWithCredential(
{
connectionStore: {
get: async () => connection(),
delete: async (slug) => {
calls.push(`delete:${slug}`);
},
},
credentialStore: {
deleteSecret: async (slug) => {
calls.push(`credential:${slug}`);
},
},
disconnectManagedOAuthConnection: async (item) => {
calls.push(`disconnect:${item.providerType}`);
},
},
'codex-subscription',
);

assert.deepEqual(calls, [
'disconnect:openai-codex',
'delete:codex-subscription',
'credential:codex-subscription',
]);
});

it('does not remove the connection when account logout fails', async () => {
const calls: string[] = [];
await assert.rejects(
deleteConnectionWithCredential(
{
connectionStore: {
get: async () => connection(),
delete: async () => {
calls.push('delete');
},
},
credentialStore: {
deleteSecret: async () => {
calls.push('credential');
},
},
disconnectManagedOAuthConnection: async () => {
calls.push('disconnect');
throw new Error('logout failed');
},
},
'codex-subscription',
),
/logout failed/,
);

assert.deepEqual(calls, ['disconnect']);
});

it('keeps deletion idempotent when the catalog row is already gone', async () => {
const calls: string[] = [];
await deleteConnectionWithCredential(
{
connectionStore: {
get: async () => null,
delete: async (slug) => {
calls.push(`delete:${slug}`);
},
},
credentialStore: {
deleteSecret: async (slug) => {
calls.push(`credential:${slug}`);
},
},
disconnectManagedOAuthConnection: async () => {
calls.push('disconnect');
},
},
'missing',
);

assert.deepEqual(calls, ['delete:missing', 'credential:missing']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
);
assert.match(
detail,
/const connectionDetailActionGuard = useKeyedActionGuard<[\s\S]*'save' \| 'test' \| 'fetch-models' \| 'save-enabled-models' \| 'set-default' \| 'delete'[\s\S]*>\(\)/,
/const connectionDetailActionGuard = useKeyedActionGuard<[\s\S]*'save' \| 'test' \| 'fetch-models' \| 'save-enabled-models' \| 'set-default-model' \| 'set-default' \| 'delete'[\s\S]*>\(\)/,
'ConnectionDetail actions must have synchronous duplicate-action guards from the shared keyed guard, not only React state',
);
assert.match(
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
);
assert.match(
detail,
/const detailActionBusy = busy \|\| testing \|\| fetchingModels \|\| savingEnabledModels \|\| settingDefault \|\| deleting/,
/const detailActionBusy =[\s\S]*busy \|\|[\s\S]*testing \|\|[\s\S]*fetchingModels \|\|[\s\S]*savingEnabledModels \|\|[\s\S]*settingDefaultModel \|\|[\s\S]*settingDefault \|\|[\s\S]*deleting/,
'ConnectionDetail must expose one visible busy state that freezes payload-affecting controls',
);
assert.match(
Expand Down Expand Up @@ -281,7 +281,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
);
assert.match(
addForm,
/disabled=\{isExperimental \|\| busy\} aria-label=\{copy\.slugAria\}/,
/value=\{slug\}[\s\S]*disabled=\{isExperimental \|\| busy\}[\s\S]*aria-label=\{copy\.slugAria\}/,
'AddProviderForm fields must freeze while a create request is in flight so visible draft cannot drift from the submitted payload',
);
// #1565 PR 3: Astryx Button — `isDisabled` + `label` prop, self-closing.
Expand Down Expand Up @@ -683,17 +683,21 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
);
});

it('connection management dialogs lead with credentials and keep model visibility in advanced settings', async () => {
it('keeps common model management visible and reserves advanced settings for the endpoint', async () => {
const src = await readProviderSettingsCombinedSource();
const detail = src.match(/function ConnectionDetailInner[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? '';
const credential = detail.indexOf('<PasswordInput');
const modelPicker = detail.indexOf('<ModelPicker');
const advanced = detail.indexOf('<details className="providerAdvancedSettings"');
const models = detail.indexOf('<EnabledModelManager');

assert.ok(credential >= 0, 'API-key connection detail must expose its credential field');
assert.ok(advanced > credential, 'credentials must remain the primary task before advanced settings');
assert.ok(models > advanced, 'enabled-model management must stay inside advanced settings');
assert.doesNotMatch(detail, /<ModelTable/, 'connection detail must not render a default-model picker');
assert.ok(modelPicker > credential, 'credentials must remain the primary task before model management');
assert.ok(models > modelPicker, 'default and enabled model management must share the visible model section');
assert.ok(advanced > models, 'advanced settings must follow common model management');
const advancedBody = detail.match(/<details className="providerAdvancedSettings"[\s\S]*?<\/details>/)?.[0] ?? '';
assert.match(advancedBody, /<ConnectionEndpointField/);
assert.doesNotMatch(advancedBody, /<ModelPicker|<EnabledModelManager|testConnection|updateModels/);
// The last-test message goes through the display helper in the extracted
// controller (use-connection-detail.ts); the view renders the derived value.
assert.match(src, /connectionLastTestMessageDisplay\(connection\.lastTestMessage, locale\)/);
Expand Down Expand Up @@ -825,10 +829,9 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
/async function remove\(\) \{[\s\S]*setDeleting\(true\);[\s\S]*let deleted = false;[\s\S]*await props\.bridge\.delete\(connection\.slug\);[\s\S]*deleted = true;[\s\S]*await props\.onDeleted\(\);[\s\S]*catch \(error\) \{[\s\S]*toast\.error\([\s\S]*deleted \? copy\.refreshFailed : copy\.deleteFailed/,
'ConnectionDetail delete failures and post-delete refresh failures must be visible',
);
// #1565 PR 3: Astryx Button — `quiet`→`ghost`, `isDisabled`, `label` prop.
assert.match(
detail,
/<Button className="providerAdvancedDanger" variant="ghost" isDisabled=\{detailActionBusy\} onClick=\{remove\} label=\{deleting \? copy\.deleting : copy\.deleteConnection\} \/>/,
/<Button variant="destructive" isDisabled=\{detailActionBusy\} onClick=\{remove\} label=\{deleting \? copy\.deleting : copy\.deleteConnection\} \/>/,
'Delete should be disabled while provider detail actions are busy and show its own pending copy',
);
});
Expand Down Expand Up @@ -1273,8 +1276,8 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO
);
assert.match(
hook,
/async function refresh\(\): Promise<boolean> \{[\s\S]*const next = \(await bridge\.getAccountState\(\)\) as SubscriptionSnapshot;[\s\S]*if \(!oauthLoginFlowMountedRef\.current\) return false;[\s\S]*setState\(next\);[\s\S]*catch \(error\) \{[\s\S]*if \(!oauthLoginFlowMountedRef\.current\) return false;[\s\S]*toast\.error\(copy\.refreshFailed, message\);[\s\S]*return true;/,
'shared OAuth refresh must drop late state/error writes after unmount',
/async function refresh\(\): Promise<boolean> \{[\s\S]*const next = \(await bridge\.getAccountState\(\)\) as SubscriptionSnapshot;[\s\S]*if \(!oauthLoginFlowMountedRef\.current\) return false;[\s\S]*setState\(next\);[\s\S]*catch \(error\) \{[\s\S]*if \(!oauthLoginFlowMountedRef\.current\) return false;[\s\S]*toast\.error\(copy\.refreshFailed, message\);[\s\S]*return false;[\s\S]*return true;/,
'shared OAuth refresh must report failure and drop late state/error writes after unmount',
);
assert.match(
hook,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { ProviderType } from '@maka/core';
import { createOAuthModelConnectionsMainService } from '../oauth-model-connections-main.js';

describe('managed OAuth model connection disconnect', () => {
it('logs out the owning account service so a deleted connection cannot be materialized again', async () => {
const calls: string[] = [];
const ok = async (provider: string) => {
calls.push(provider);
return { ok: true as const };
};
const service = createOAuthModelConnectionsMainService({
connectionStore: {} as never,
credentialStore: {} as never,
claudeSubscription: { logout: () => ok('claude-subscription') } as never,
openAiCodex: { logout: () => ok('openai-codex') } as never,
githubCopilotSubscription: { logout: () => ok('github-copilot') } as never,
xaiOAuth: { logout: () => ok('xai-oauth') } as never,
});

for (const providerType of [
'claude-subscription',
'openai-codex',
'github-copilot',
'xai-oauth',
] satisfies ProviderType[]) {
await service.disconnectManagedOAuthConnection({ providerType });
}
await service.disconnectManagedOAuthConnection({ providerType: 'openai' });
assert.deepEqual(calls, [
'claude-subscription',
'openai-codex',
'github-copilot',
'xai-oauth',
]);
});

it('fails closed when the account service cannot log out', async () => {
const service = createOAuthModelConnectionsMainService({
connectionStore: {} as never,
credentialStore: {} as never,
claudeSubscription: {} as never,
openAiCodex: {
logout: async () => ({
ok: false as const,
reason: 'storage_failed',
message: 'Could not remove the stored OAuth credential',
}),
} as never,
githubCopilotSubscription: {} as never,
xaiOAuth: {} as never,
});

await assert.rejects(
service.disconnectManagedOAuthConnection({ providerType: 'openai-codex' }),
/Could not remove the stored OAuth credential/,
);
});
});
9 changes: 7 additions & 2 deletions apps/desktop/src/main/connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from './connection-model-discovery.js';
import { createFileCredentialStore } from './credential-store.js';
import { createConnectionWithCredential } from './create-connection-with-credential.js';
import { deleteConnectionWithCredential } from './delete-connection-with-credential.js';
import { connectionTestStatusPatch } from './connection-test-status.js';

type ConnectionStore = ReturnType<typeof createConnectionStore>;
Expand All @@ -33,6 +34,7 @@ interface ConnectionsIpcDeps extends ConnectionInputNormalizerDeps {
syncOAuthModelConnections: () => Promise<void>;
resolveConnectionSecret: (slug: string) => Promise<string | null>;
hasConnectionSecret: (connection: LlmConnection) => Promise<boolean>;
disconnectManagedOAuthConnection: (connection: LlmConnection) => Promise<void>;
emitConnectionListChanged: () => void;
/**
* Override for remote model discovery. Only E2E supplies this, to keep the
Expand Down Expand Up @@ -140,6 +142,7 @@ export function registerConnectionsIpc(deps: ConnectionsIpcDeps): void {
syncOAuthModelConnections,
resolveConnectionSecret,
hasConnectionSecret,
disconnectManagedOAuthConnection,
emitConnectionListChanged,
fetchModels,
} = deps;
Expand Down Expand Up @@ -205,8 +208,10 @@ export function registerConnectionsIpc(deps: ConnectionsIpcDeps): void {
});
ipcMain.handle('connections:delete', async (_event, slug: string) => {
slug = normalizeConnectionSlugForIpc(slug, 'connection slug');
await connectionStore.delete(slug);
await credentialStore.deleteSecret(slug);
await deleteConnectionWithCredential(
{ connectionStore, credentialStore, disconnectManagedOAuthConnection },
slug,
);
emitConnectionListChanged();
});
ipcMain.handle('connections:test', async (_event, slug: string, opts?: { model?: string }) => {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/delete-connection-with-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { LlmConnection } from '@maka/core';
import type { ConnectionStore, CredentialStore } from '@maka/storage';

interface DeleteConnectionWithCredentialDeps {
connectionStore: Pick<ConnectionStore, 'get' | 'delete'>;
credentialStore: Pick<CredentialStore, 'deleteSecret'>;
/**
* Logs out account-managed OAuth connections before their catalog row is
* removed. Without this step the next connection-list read materializes the
* still-authenticated account again, making deletion appear to do nothing.
*/
disconnectManagedOAuthConnection(connection: LlmConnection): Promise<void>;
}

export async function deleteConnectionWithCredential(
deps: DeleteConnectionWithCredentialDeps,
slug: string,
): Promise<void> {
const connection = await deps.connectionStore.get(slug);
if (connection) {
await deps.disconnectManagedOAuthConnection(connection);
}
await deps.connectionStore.delete(slug);
await deps.credentialStore.deleteSecret(slug);
}
5 changes: 5 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@ function syncOAuthModelConnections(): Promise<void> {
return oauthModelConnections.syncOAuthModelConnections();
}

function disconnectManagedOAuthConnection(connection: LlmConnection): Promise<void> {
return oauthModelConnections.disconnectManagedOAuthConnection(connection);
}

function resolveConnectionSecret(slug: string): Promise<string | null> {
return oauthModelConnections.resolveConnectionSecret(slug);
}
Expand Down Expand Up @@ -1138,6 +1142,7 @@ function registerIpc(): void {
syncOAuthModelConnections,
resolveConnectionSecret,
hasConnectionSecret,
disconnectManagedOAuthConnection,
emitConnectionListChanged,
// Same seam as the fake-backend override above, for the other IPC that can
// leave the machine: adding a catalog provider runs remote model discovery
Expand Down
Loading
Loading