-
-
+
+
+
{{ apiConnectionIssue }}
+
{{ connectionFeedback }}
+
diff --git a/apps/client-webext/logic/api-connections.ts b/apps/client-webext/logic/api-connections.ts
new file mode 100644
index 00000000..d4e9f6a9
--- /dev/null
+++ b/apps/client-webext/logic/api-connections.ts
@@ -0,0 +1,93 @@
+import { computed, ref } from 'vue'
+import { storage } from '@wxt-dev/storage'
+import { z } from 'zod'
+
+const ConnectionSchema = z.object({
+ id: z.uuid(),
+ name: z.string().trim().min(1),
+ url: z.url().refine((value) => {
+ const url = new URL(value)
+ return (
+ ['http:', 'https:'].includes(url.protocol) &&
+ !url.username &&
+ !url.password &&
+ !url.search &&
+ !url.hash
+ )
+ }, '请输入不含凭据、查询参数或片段的 HTTP(S) 地址。'),
+})
+const SettingsSchema = z
+ .object({
+ connections: z.array(ConnectionSchema),
+ activeId: z.uuid().nullable(),
+ })
+ .superRefine((value, context) => {
+ const ids = new Set(value.connections.map((item) => item.id))
+ if (
+ ids.size !== value.connections.length ||
+ (value.activeId !== null && !ids.has(value.activeId))
+ ) {
+ context.addIssue({ code: 'custom', message: '保存的连接列表无效。' })
+ }
+ })
+type Settings = z.infer
+const key = 'local:inkcreApiConnections' as const
+export const apiConnections = ref({ connections: [], activeId: null })
+export const apiConnectionIssue = ref('')
+export const inkcreApi = computed(
+ () =>
+ apiConnections.value.connections.find((item) => item.id === apiConnections.value.activeId)
+ ?.url ?? ''
+)
+
+export const apiConnectionsReady = (async () => {
+ try {
+ const stored = await storage.getItem(key)
+ if (stored !== null) {
+ apiConnections.value = SettingsSchema.parse(
+ typeof stored === 'string' ? JSON.parse(stored) : stored
+ )
+ return
+ }
+ const previous = await storage.getItem('local:inkcreApi')
+ if (!previous) return
+ const id = crypto.randomUUID()
+ await saveApiConnections({
+ activeId: id,
+ connections: [{ id, name: new URL(previous).host, url: previous }],
+ })
+ } catch {
+ apiConnectionIssue.value = '无法读取保存的连接。'
+ }
+})()
+
+storage.watch(key, (stored) => {
+ try {
+ apiConnections.value = SettingsSchema.parse(
+ typeof stored === 'string' ? JSON.parse(stored) : stored
+ )
+ apiConnectionIssue.value = ''
+ } catch {
+ apiConnectionIssue.value = '保存的连接列表无效。'
+ }
+})
+
+async function saveApiConnections(candidate: Settings) {
+ const settings = SettingsSchema.parse(candidate)
+ await storage.setItem(key, JSON.stringify(settings))
+ apiConnections.value = settings
+ apiConnectionIssue.value = ''
+}
+
+export async function saveApiConnection(id: string, name: string, url: string) {
+ await apiConnectionsReady
+ const connection = ConnectionSchema.parse({ id, name: name.trim() || new URL(url).host, url })
+ const connections = apiConnections.value.connections.filter((item) => item.id !== id)
+ connections.push(connection)
+ await saveApiConnections({ connections, activeId: id })
+}
+
+export async function selectApiConnection(id: string) {
+ await apiConnectionsReady
+ await saveApiConnections({ ...apiConnections.value, activeId: id })
+}
diff --git a/apps/client-webext/logic/info-base/block.ts b/apps/client-webext/logic/info-base/block.ts
index c9c6e811..32ba06b6 100644
--- a/apps/client-webext/logic/info-base/block.ts
+++ b/apps/client-webext/logic/info-base/block.ts
@@ -5,7 +5,7 @@
*/
import { Block as CoreBlock } from '@inkcre/core'
-import { inkcreApi } from '~/logic/storage'
+import { inkcreApi, inkcreApiReady } from '~/logic/storage'
/**
* Extended Block class with vector search capability
@@ -28,6 +28,7 @@ export class Block extends CoreBlock {
distanceThreshold?: number
num?: number
}): Promise {
+ await inkcreApiReady
const url = new URL('/blocks/query/by_embedding', inkcreApi.value)
if (params.query !== undefined) {
url.searchParams.set('query', params.query)
diff --git a/apps/client-webext/logic/info-base/root.ts b/apps/client-webext/logic/info-base/root.ts
index 0344a1fb..b18062fa 100644
--- a/apps/client-webext/logic/info-base/root.ts
+++ b/apps/client-webext/logic/info-base/root.ts
@@ -1,6 +1,6 @@
// (Information) Base Root module index
-import { inkcreApi } from '~/logic/storage'
+import { inkcreApi, inkcreApiReady } from '~/logic/storage'
import type { BlockForm } from './block'
export interface RelationInput {
@@ -22,7 +22,8 @@ export class StarGraphForm {
public in_relations: ArcForm[] = []
) {}
- create() {
+ async create() {
+ await inkcreApiReady
return fetch(new URL('/graph', inkcreApi.value), {
method: 'PUT',
headers: {
@@ -51,6 +52,7 @@ export class Root {
retrieve_mode?: string
context_blocks?: string
}): Promise {
+ await inkcreApiReady
const url = new URL('/sink/rag', inkcreApi.value)
url.searchParams.set('query', encodeURIComponent(params.query))
if (params.retrieve_mode) {
diff --git a/apps/client-webext/logic/storage.ts b/apps/client-webext/logic/storage.ts
index 35206b40..d452256d 100644
--- a/apps/client-webext/logic/storage.ts
+++ b/apps/client-webext/logic/storage.ts
@@ -37,14 +37,7 @@ export const { data: stopwords, dataReady: stopwordsReady } = useWebExtensionSto
DEFAULT_STOPWORDS
)
-/**
- * InKCre API base URL (extension-specific, not in core).
- * Used for info-base API calls.
- */
-export const { data: inkcreApi, dataReady: inkcreApiReady } = useWebExtensionStorage(
- 'inkcreApi',
- ''
-)
+export { inkcreApi, apiConnectionsReady as inkcreApiReady } from './api-connections'
/**
* LLM provider credentials and model lists.
diff --git a/docs/30-unit-tdd/client-runtime-and-delegation.md b/docs/30-unit-tdd/client-runtime-and-delegation.md
index aa8a6673..9a6d6b86 100644
--- a/docs/30-unit-tdd/client-runtime-and-delegation.md
+++ b/docs/30-unit-tdd/client-runtime-and-delegation.md
@@ -74,6 +74,12 @@ origin:
- current technical Peer UUID;
- user-owned JWT signing secret.
+每个 Web origin 在本地保存命名连接列表和当前选择。一个条目包含 PostgREST 地址、JWT 签名密钥、稳定的 Peer UUID 和该连接的运行设置。添加连接生成新的身份;切回已保存连接复用原身份。设置中的“保存并使用连接”验证目标后才提交列表与当前选择;连接失败或存储失败不改变已保存的选择。切换前收束旧 Job worker、扩展 Host 和遥测,并清除 Resolver 缓存及连接相关的搜索、侧栏状态。
+
+旧单连接会成为列表首项,保留凭据与身份。存储中的扁平 MetaConfig 字段是活动项的兼容投影;重新加载以列表的活动项为准。导出格式 v3 包含全部连接、当前选择和语言,包括连接凭据;导入接受旧 v2 单连接和 v3,并替换本地列表。各浏览器 origin 独立保存,不跨设备同步。
+
+浏览器扩展的旧 API 入口独立保存命名 API 地址及当前选择,沿用现有请求协议;其他打开的扩展页面通过 extension storage 接收选择变化。原单一 `inkcreApi` 地址保留为首项。它不把 API 地址冒充 Web 的 PostgREST/JWT 连接,亦不迁移旧协议。
+
The application shell mounts immediately with its header and menu. A configured browser reads its
complete canonical Peer row once before business views mount; a new identity must first be
inserted. Business pages then remain available while runtime-owned version/schema registration and
diff --git a/packages/core/src/config/connections.ts b/packages/core/src/config/connections.ts
new file mode 100644
index 00000000..5bae865d
--- /dev/null
+++ b/packages/core/src/config/connections.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod'
+import { MetaConfigSchema, type MetaConfig } from './schema'
+
+export const SavedConnectionSchema = z.object({
+ id: z.uuid(),
+ name: z.string().trim().min(1),
+ config: MetaConfigSchema,
+})
+export type SavedConnection = z.infer
+
+export const ConnectionSettingsSchema = z
+ .object({
+ connections: z.array(SavedConnectionSchema),
+ activeConnectionId: z.uuid().nullable(),
+ })
+ .superRefine((settings, context) => {
+ const ids = new Set(settings.connections.map((connection) => connection.id))
+ if (
+ ids.size !== settings.connections.length ||
+ (settings.activeConnectionId !== null && !ids.has(settings.activeConnectionId))
+ ) {
+ context.addIssue({ code: 'custom', message: 'Invalid connection selection' })
+ }
+ })
+export type ConnectionSettings = z.infer
+
+/** Import the previous single-connection format without changing its Peer identity. */
+export function readConnectionSettings(input: unknown, current: MetaConfig): ConnectionSettings {
+ if (typeof input === 'object' && input !== null && 'connections' in input) {
+ return ConnectionSettingsSchema.parse(input)
+ }
+ if (!current.INKCRE_PGREST_URL || !current.INKCRE_JWT_SECRET) {
+ return { connections: [], activeConnectionId: null }
+ }
+ const id = crypto.randomUUID()
+ return {
+ activeConnectionId: id,
+ connections: [{ id, name: new URL(current.INKCRE_PGREST_URL).host, config: current }],
+ }
+}
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
index ba75ad6a..7b0c3f4b 100644
--- a/packages/core/src/config/index.ts
+++ b/packages/core/src/config/index.ts
@@ -26,3 +26,5 @@ export { useConfigStore, configStore } from './store'
export * from './schema'
export * from './types'
export * from './adapters'
+
+export * from './connections'
diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts
index c3632db0..7ff1806b 100644
--- a/packages/core/src/config/store.ts
+++ b/packages/core/src/config/store.ts
@@ -3,7 +3,11 @@ import { ref, shallowRef } from 'vue'
import { store } from '../store'
import type { ConfigAdapterWithWrite } from './types'
import { MetaConfigSchema, PeerConfigSchema, type MetaConfig, type PeerConfig } from './schema'
-import { loadConfig as zodLoadConfig } from 'zod-config'
+import {
+ ConnectionSettingsSchema,
+ readConnectionSettings,
+ type ConnectionSettings,
+} from './connections'
import type { WebPeerIdentity } from '../peer/runtime'
// Lazy import Peer to avoid circular imports
@@ -25,6 +29,7 @@ export const useConfigStore = defineStore('inkcre-config', () => {
const metaAdapter = shallowRef(unconfiguredAdapter)
const metaConfig = ref(MetaConfigSchema.parse({}))
const peerConfig = ref(PeerConfigSchema.parse({}))
+ const connectionSettings = ref({ connections: [], activeConnectionId: null })
const isLoading = ref(false)
const error = ref(null)
@@ -34,14 +39,17 @@ export const useConfigStore = defineStore('inkcre-config', () => {
error.value = null
try {
- metaConfig.value = await zodLoadConfig({
- schema: MetaConfigSchema,
- adapters: [adapter],
- })
+ const stored = await adapter.read()
+ const current = MetaConfigSchema.parse(stored)
+ const settings = readConnectionSettings(stored, current)
+ connectionSettings.value = settings
+ metaConfig.value =
+ settings.connections.find((item) => item.id === settings.activeConnectionId)?.config ??
+ current
} catch (err) {
error.value = err as Error
- metaConfig.value = MetaConfigSchema.parse({})
console.error('[Config] Failed to initialize meta config.')
+ throw err
} finally {
isLoading.value = false
}
@@ -66,7 +74,14 @@ export const useConfigStore = defineStore('inkcre-config', () => {
async function saveMeta(): Promise {
error.value = null
try {
- await metaAdapter.value.write(metaConfig.value)
+ const settings = ConnectionSettingsSchema.parse(connectionSettings.value)
+ settings.connections = settings.connections.map((item) =>
+ item.id === settings.activeConnectionId
+ ? { ...item, config: MetaConfigSchema.parse(metaConfig.value) }
+ : item
+ )
+ await metaAdapter.value.write({ ...metaConfig.value, ...settings })
+ connectionSettings.value = settings
} catch (err) {
error.value = err as Error
console.error('[Config] Failed to save meta config:', err)
@@ -74,16 +89,35 @@ export const useConfigStore = defineStore('inkcre-config', () => {
}
}
- async function connectAndSave(metaCandidate: MetaConfig, identity: WebPeerIdentity) {
+ async function connectAndSave(
+ metaCandidate: MetaConfig,
+ identity: WebPeerIdentity,
+ selection?: { id: string; name: string },
+ imported?: ConnectionSettings
+ ) {
error.value = null
const nextMeta = MetaConfigSchema.parse(metaCandidate)
+ const settings = ConnectionSettingsSchema.parse(imported ?? connectionSettings.value)
+ const existing = settings.connections.find((item) => item.id === settings.activeConnectionId)
+ const selected = selection ??
+ existing ?? {
+ id: crypto.randomUUID(),
+ name: new URL(nextMeta.INKCRE_PGREST_URL).host,
+ }
+ const entry = { id: selected.id, name: selected.name, config: nextMeta }
+ const index = settings.connections.findIndex((item) => item.id === selected.id)
+ if (index < 0) settings.connections.push(entry)
+ else settings.connections[index] = entry
+ settings.activeConnectionId = selected.id
+ const nextSettings = ConnectionSettingsSchema.parse(settings)
let candidateRuntime: InstanceType>> | null = null
try {
const WebPeerRuntime = await lazyWebPeerRuntime()
const connection = await WebPeerRuntime.connect(nextMeta, identity)
candidateRuntime = connection.runtime
- await metaAdapter.value.write(nextMeta)
+ await metaAdapter.value.write({ ...nextMeta, ...nextSettings })
+ connectionSettings.value = nextSettings
metaConfig.value = nextMeta
peerConfig.value = PeerConfigSchema.parse(connection.peer.config)
return candidateRuntime
@@ -99,7 +133,9 @@ export const useConfigStore = defineStore('inkcre-config', () => {
const resetPeerConfig = PeerConfigSchema.parse({})
error.value = null
try {
- await metaAdapter.value.write(resetMetaConfig)
+ const settings: ConnectionSettings = { connections: [], activeConnectionId: null }
+ await metaAdapter.value.write({ ...resetMetaConfig, ...settings })
+ connectionSettings.value = settings
metaConfig.value = resetMetaConfig
peerConfig.value = resetPeerConfig
} catch (err) {
@@ -110,6 +146,7 @@ export const useConfigStore = defineStore('inkcre-config', () => {
return {
metaConfig,
+ connectionSettings,
peerConfig,
metaAdapter,
isLoading,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index fbc01c4e..abb6d6b1 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -223,6 +223,10 @@ export {
configStore,
PeerConfigSchema,
MetaConfigSchema,
+ ConnectionSettingsSchema,
+ readConnectionSettings,
+ type ConnectionSettings,
+ type SavedConnection,
AIConfigSchema,
LLMProviderConfigSchema,
localStorageAdapter,
diff --git a/scripts/tests/saved-connections.test.mjs b/scripts/tests/saved-connections.test.mjs
new file mode 100644
index 00000000..7f001e19
--- /dev/null
+++ b/scripts/tests/saved-connections.test.mjs
@@ -0,0 +1,89 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import {
+ configStore,
+ MetaConfigSchema,
+ WebPeerRuntime,
+ readConnectionSettings,
+} from '../../packages/core/dist/index.js'
+
+void test('saved connections preserve identity, switch atomically, and survive restart/import', async () => {
+ const first = MetaConfigSchema.parse({
+ INKCRE_PGREST_URL: 'https://one.example',
+ INKCRE_JWT_SECRET: 'fixture-one',
+ })
+ let persisted = structuredClone(first)
+ let rejectWrite = false
+ const adapter = {
+ name: 'memory',
+ read: async () => persisted,
+ write: async (value) => {
+ if (rejectWrite) throw new Error('disk full')
+ persisted = structuredClone(value)
+ },
+ }
+ const connect = WebPeerRuntime.connect.bind(WebPeerRuntime)
+ let stopped = 0
+ let rejectConnection = false
+ WebPeerRuntime.connect = async () => {
+ if (rejectConnection) throw new Error('unreachable')
+ return {
+ runtime: {
+ stop() {
+ stopped += 1
+ },
+ },
+ peer: { config: {} },
+ }
+ }
+ try {
+ await configStore.initializeMeta(adapter)
+ const firstId = configStore.connectionSettings.activeConnectionId
+ assert.equal(configStore.connectionSettings.connections.length, 1)
+ assert.equal(configStore.metaConfig.INKCRE_PEER_ID, first.INKCRE_PEER_ID)
+ await configStore.saveMeta()
+ const second = MetaConfigSchema.parse({
+ INKCRE_PGREST_URL: 'https://two.example',
+ INKCRE_JWT_SECRET: 'fixture-two',
+ })
+ const secondId = crypto.randomUUID()
+ await configStore.connectAndSave(second, {}, { id: secondId, name: '工作' })
+ assert.equal(persisted.connections.length, 2)
+ await configStore.connectAndSave(first, {}, { id: firstId, name: '个人' })
+ await configStore.initializeMeta(adapter)
+ assert.equal(configStore.connectionSettings.activeConnectionId, firstId)
+ assert.equal(configStore.metaConfig.INKCRE_PEER_ID, first.INKCRE_PEER_ID)
+ assert.equal(
+ configStore.connectionSettings.connections[1].config.INKCRE_PEER_ID,
+ second.INKCRE_PEER_ID
+ )
+ const snapshot = JSON.stringify(persisted)
+ rejectConnection = true
+ await assert.rejects(configStore.connectAndSave(second, {}, { id: secondId, name: '工作' }))
+ assert.equal(JSON.stringify(persisted), snapshot)
+ assert.equal(configStore.connectionSettings.activeConnectionId, firstId)
+ rejectConnection = false
+ rejectWrite = true
+ await assert.rejects(configStore.connectAndSave(second, {}, { id: secondId, name: '工作' }))
+ assert.equal(stopped, 1)
+ assert.equal(configStore.connectionSettings.activeConnectionId, firstId)
+ rejectWrite = false
+ const exported = JSON.parse(JSON.stringify(configStore.connectionSettings))
+ await configStore.resetMeta()
+ await configStore.connectAndSave(first, {}, exported.connections[0], exported)
+ assert.deepEqual(JSON.parse(JSON.stringify(configStore.connectionSettings)), exported)
+ assert.equal(
+ readConnectionSettings(first, first).connections[0].config.INKCRE_PEER_ID,
+ first.INKCRE_PEER_ID
+ )
+ persisted = {
+ ...first,
+ connections: exported.connections,
+ activeConnectionId: crypto.randomUUID(),
+ }
+ await assert.rejects(configStore.initializeMeta(adapter))
+ assert.equal(persisted.connections.length, 2)
+ } finally {
+ WebPeerRuntime.connect = connect
+ }
+})
diff --git a/tests/e2e/browser-extension.spec.ts b/tests/e2e/browser-extension.spec.ts
index 30e9db30..ccc3371a 100644
--- a/tests/e2e/browser-extension.spec.ts
+++ b/tests/e2e/browser-extension.spec.ts
@@ -41,3 +41,34 @@ test('built Chromium extension loads its real popup', async ({ context, page, ex
await expect(page.getByText('Popup', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Open Options' })).toBeVisible()
})
+
+test('saved API connections survive reload and switch across open option pages', async ({
+ context,
+ page,
+ extensionId,
+}) => {
+ const [worker] = context.serviceWorkers()
+ await worker.evaluate(async () => {
+ const chrome = (globalThis as any).chrome
+ await chrome.storage.local.set({ inkcreApi: 'https://first.example' })
+ })
+ await page.goto(`chrome-extension://${extensionId}/options.html`)
+ await expect(page.getByLabel('API 地址')).toHaveValue('https://first.example')
+ await page.getByRole('button', { name: '添加连接', exact: true }).click()
+ await page.getByLabel('连接名称', { exact: true }).fill('工作')
+ await page.getByLabel('API 地址').fill('https://second.example')
+ await page.getByRole('button', { name: '保存并使用连接' }).click()
+ await expect(page.getByRole('status')).toHaveText('已切换连接。')
+ await page.reload()
+ await expect(page.getByLabel('API 地址')).toHaveValue('https://second.example')
+ const other = await context.newPage()
+ await other.goto(`chrome-extension://${extensionId}/options.html`)
+ await expect(other.getByLabel('API 地址')).toHaveValue('https://second.example')
+ await page.getByLabel('当前连接').selectOption({ label: 'first.example' })
+ await expect(page.getByLabel('API 地址')).toHaveValue('https://first.example')
+ await expect(other.getByLabel('API 地址')).toHaveValue('https://first.example')
+ await other.reload()
+ await expect(other.getByLabel('API 地址')).toHaveValue('https://first.example')
+ await expect(other.getByLabel('当前连接').locator('option')).toHaveCount(2)
+ await other.close()
+})
diff --git a/tests/e2e/github-native.ts b/tests/e2e/github-native.ts
index 88f76fe2..e0e1c9b9 100644
--- a/tests/e2e/github-native.ts
+++ b/tests/e2e/github-native.ts
@@ -361,7 +361,7 @@ export function registerGitHubNativeTests(): void {
await page
.getByLabel('Lease renewal and job check interval (seconds; 0 disables)')
.fill(String(seconds))
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect(
page.getByText('Configuration saved successfully', { exact: true }).first()
).toBeVisible()
diff --git a/tests/e2e/peer-database.spec.ts b/tests/e2e/peer-database.spec.ts
index b82ae771..d207dfe6 100644
--- a/tests/e2e/peer-database.spec.ts
+++ b/tests/e2e/peer-database.spec.ts
@@ -421,7 +421,7 @@ test('switching connections cannot renew a pending old registration against eith
await openSettings(page)
await page.getByLabel('PostgREST URL').fill(alternateUrl)
await page.getByLabel('JWT signing secret').fill(alternateSecret)
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect.poll(() => newLeaseRequests).toBe(1)
release()
await expect.poll(() => registrationFinished).toBe(true)
@@ -550,14 +550,14 @@ test('zero participation persists, stays idle, and a custom interval resumes lea
page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)')
).toHaveValue('0')
await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
expect(leaseTtls).toEqual([])
const intervalInput = page.getByLabel(
'Lease renewal and job check interval (seconds; 0 disables)'
)
await intervalInput.fill('2')
await expect(intervalInput).toHaveValue('2')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect(intervalInput).toHaveValue('2')
await expect.poll(() => scans).toBeGreaterThan(0)
expect(leaseTtls.length).toBeGreaterThan(0)
@@ -574,10 +574,12 @@ test('zero participation persists, stays idle, and a custom interval resumes lea
const exported = JSON.parse(readFileSync(path!, 'utf8'))
expect(exported.metaConfig.peer_lease_renew_interval_seconds).toBe(2)
await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect(page.getByRole('status').first()).toBeVisible()
// The import handler bails while the save still owns the form; wait for it to finish.
- await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled()
+ await expect(
+ page.getByRole('button', { name: 'Save and use connection', exact: true })
+ ).toBeEnabled()
const stoppedScans = scans,
stoppedLeases = leaseTtls.length
await page.clock.fastForward(35_000)
@@ -624,7 +626,7 @@ test('disabling while registration is pending prevents its later lease or worker
await expect(page.getByRole('main')).toBeVisible()
await openSettings(page)
await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect(page.getByRole('status').first()).toBeVisible()
release()
await expect.poll(() => firstPatchFinished).toBe(true)
@@ -744,8 +746,10 @@ test('disabling participation drains a dispatched claim before saving configurat
expect(stoppedBeforeClaimReturned).toBe(false)
await openSettings(page)
await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
- await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
+ await expect(
+ page.getByRole('button', { name: 'Save and use connection', exact: true })
+ ).toBeDisabled()
expect(
await page.evaluate(
() =>
@@ -1275,7 +1279,13 @@ test('Settings export restores the complete browser experience', async ({ page }
expect(path).not.toBeNull()
const exported = JSON.parse(readFileSync(path!, 'utf8'))
expect(exported).toEqual({
- version: 2,
+ version: 3,
+ connections: {
+ activeConnectionId: expect.any(String),
+ connections: [
+ { id: expect.any(String), name: new URL(postgrestUrl).host, config: exported.metaConfig },
+ ],
+ },
metaConfig: {
INKCRE_PGREST_URL: postgrestUrl,
INKCRE_JWT_SECRET: jwtSecret,
@@ -1312,7 +1322,7 @@ test('Settings export restores the complete browser experience', async ({ page }
locale: localStorage.getItem('inkcre-locale'),
}))
)
- .toEqual({ metaConfig: exported.metaConfig, locale: 'en' })
+ .toEqual({ metaConfig: { ...exported.metaConfig, ...exported.connections }, locale: 'en' })
await expect(
page.getByRole('switch', { name: 'Enable telemetry', exact: true })
).not.toBeChecked()
@@ -1730,7 +1740,7 @@ test('an empty browser saves its connection across refresh and reads Peers', asy
await page.getByLabel('JWT signing secret').fill(jwtSecret)
await expect(page.getByLabel('Device readiness wait timeout (seconds)')).toHaveValue('5')
await page.getByLabel('Device readiness wait timeout (seconds)').fill('45')
- await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.getByRole('button', { name: 'Save and use connection', exact: true }).click()
await expect
.poll(() =>
page.evaluate(