From 099b903ac6ccd124acb653db4d528dd51f9b7c00 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:15:09 +0800 Subject: [PATCH] fix(web): preserve provider credential retry checkpoint --- apps/web/tests/models-settings.e2e.ts | 26 ++++++++++++++++--- .../models-settings/native-delete.expected.md | 7 +++++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++--- .../client/ui-models/src/client/locales.ts | 4 +-- .../ui-models/tests/components.spec.tsx | 11 ++++++-- 5 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/native-delete.expected.md diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 36892f2071..9078e53ff6 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -9,9 +9,9 @@ // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can -// never shadow the derived reference. Removing that row is guarded by the -// localized, identified provider-confirmation dialog before the credential -// and settings unsets reach the wire. +// never shadow the derived reference. The deletion dialog distinguishes a +// reference-free profile from a page-managed key before the credential and +// settings unsets reach the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -88,6 +89,21 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).not.toContain('MINIMAX_CN_API_KEY') }, 60_000) + it('describes reference-free deletion without claiming a credential exists', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' }) + await deleteDialog.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="删除 minimax-cn?"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE) + await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() + }, 60_000) + it('stores the key under the derived reference and keeps the route live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) @@ -163,6 +179,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/native-delete.expected.md b/apps/web/tests/snapshots/models-settings/native-delete.expected.md new file mode 100644 index 0000000000..6ff480db12 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/native-delete.expected.md @@ -0,0 +1,7 @@ +- dialog "删除 minimax-cn?": + - heading "删除 minimax-cn?" [level=2] + - button "关闭": + - img + - paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。 + - button "取消" + - button "删除 minimax-cn" diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 46350a145d..30f5c376e0 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -133,9 +133,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) - // A settings success becomes the next retry baseline immediately. If the - // following credential write fails, retry sends only the credential instead - // of replaying the already-committed settings write with a stale revision. + // A settings success advances both retry baselines immediately. Keeping the + // derived fields in the draft prevents a pushed namespace refresh from + // turning them into deletions when the following credential write is retried. const [committedOriginal, setCommittedOriginal] = useState( () => getPath(namespace.user, settingsPath), ) @@ -215,6 +215,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } setCommittedOriginal(getPath(response.result.value.user, settingsPath)) setExpectedRevision(response.result.value.revision) + setDraft(next) } if (normalizedKey.length > 0) { const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 4fc76695e4..d85a3dd964 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -10,7 +10,7 @@ export const en = { remove: 'Delete', removeProvider: 'Delete {provider}', deleteTitle: 'Delete {provider}?', - deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.', + deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.', deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', deleteConfirm: 'Delete {provider}', deleting: 'Deleting {provider}…', @@ -75,7 +75,7 @@ export const zh: typeof en = { remove: '删除', removeProvider: '删除 {provider}', deleteTitle: '删除 {provider}?', - deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。', + deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。', deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', deleteConfirm: '删除 {provider}', deleting: '正在删除 {provider}…', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index e28df6564d..29600642a2 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -816,7 +816,7 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('retries only the credential after settings already committed', async () => { + it('retries only the credential after refreshed settings already committed', async () => { const committed = wireNamespaces()[2]! const afterSettings: SettingsNamespaceView = { ...committed, @@ -834,7 +834,7 @@ describe('ModelsSection', () => { const set = vi.fn() .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) .mockResolvedValueOnce(ok({})) - await mountSection({ mutate, set }) + const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText(en.keyInput) @@ -842,6 +842,13 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() + face.settings.describe.mockResolvedValue(ok({ + writable: true, + hasDocument: false, + namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace), + })) + await act(async () => { await controller.load() }) + expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce()