ui(data-mode): migrate legacy harness storage to v2 (#1040)

* centralize E2E data mode storage key
* use E2E alias for shared data mode key
This commit is contained in:
Nick DiZazzo 2026-07-21 22:54:56 -04:00 committed by GitHub
parent cf2d6addad
commit a1649daaa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 101 additions and 12 deletions

View file

@ -1,7 +1,7 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test, type Page, type TestInfo } from '@playwright/test'
import { DATA_MODE_STORAGE_KEY } from '@e2e/support/data-mode'
const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v1'
const FEATURE_FLAGS_STORAGE_KEY = 'mesh-llm-ui-preview:feature-flags:v1'
type JsonRecord = Record<string, unknown>

View file

@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test, type APIRequestContext, type Page } from '@playwright/test'
import { DATA_MODE_STORAGE_KEY } from '@e2e/support/data-mode'
const pluginName = 'web-ui-exemplar'
const pluginApi = `/api/plugins/${pluginName}`
@ -94,10 +95,13 @@ test.describe('installed plugin web UI exemplar @plugin', () => {
const browserApiResponses = collectBrowserApiResponses(page)
await mkdir(evidenceDirectory, { recursive: true })
await page.addInitScript((preferences) => {
window.localStorage.setItem('mesh-llm-ui-preview:preferences:v1', JSON.stringify(preferences))
window.localStorage.setItem('mesh-llm-ui-preview:data-mode:v1', 'live')
}, darkUiPreferences)
await page.addInitScript(
({ dataModeStorageKey, preferences }) => {
window.localStorage.setItem('mesh-llm-ui-preview:preferences:v1', JSON.stringify(preferences))
window.localStorage.setItem(dataModeStorageKey, 'live')
},
{ dataModeStorageKey: DATA_MODE_STORAGE_KEY, preferences: darkUiPreferences }
)
try {
const consoleResponse = await request.get('/')

View file

@ -1,6 +1,6 @@
import { devices, expect, test, type Page } from '@playwright/test'
import { DATA_MODE_STORAGE_KEY } from '@e2e/support/data-mode'
const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v1'
const API_ORIGIN = 'http://127.0.0.1:3131'
const IPHONE_KEYBOARD_VIEWPORT = { width: 390, height: 520 }
const IPHONE_14 = devices['iPhone 14']

View file

@ -1,6 +1,6 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test'
import { DATA_MODE_STORAGE_KEY } from '@e2e/support/data-mode'
const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v1'
const API_ORIGIN = 'http://127.0.0.1:3131'
const CLIP_FIXTURE_PATH = decodeURIComponent(new URL('../fixtures/clip.mp3', import.meta.url).pathname)

View file

@ -0,0 +1 @@
export const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v2'

View file

@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DATA_MODE_STORAGE_KEY, DataModeProvider } from '@/lib/data-mode/DataModeContext'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DATA_MODE_STORAGE_KEY, DataModeProvider, LEGACY_DATA_MODE_STORAGE_KEY } from '@/lib/data-mode/DataModeContext'
import { useDataMode } from '@/lib/data-mode/useDataMode'
import { env } from '@/lib/env'
@ -20,6 +20,7 @@ describe('DataModeProvider', () => {
afterEach(() => {
env.isDevelopment = originalIsDevelopment
vi.restoreAllMocks()
})
it('defaults to live mode in production builds and persists under the preview namespace', async () => {
@ -70,6 +71,54 @@ describe('DataModeProvider', () => {
expect(result.current.mode).toBe('live')
})
it('migrates a legacy harness default to the production default without deleting v1', () => {
env.isDevelopment = false
window.localStorage.setItem(LEGACY_DATA_MODE_STORAGE_KEY, 'harness')
const { result } = renderHook(() => useDataMode(), { wrapper: providerWrapper() })
expect(result.current.mode).toBe('live')
expect(window.localStorage.getItem(DATA_MODE_STORAGE_KEY)).toBe('live')
expect(window.localStorage.getItem(LEGACY_DATA_MODE_STORAGE_KEY)).toBe('harness')
})
it('does not repeat migration when a valid v2 choice already exists', () => {
window.localStorage.setItem(LEGACY_DATA_MODE_STORAGE_KEY, 'live')
window.localStorage.setItem(DATA_MODE_STORAGE_KEY, 'harness')
const setItem = vi.spyOn(Storage.prototype, 'setItem')
const { result } = renderHook(() => useDataMode(), { wrapper: providerWrapper({ initialMode: 'live' }) })
expect(result.current.mode).toBe('harness')
expect(setItem).not.toHaveBeenCalled()
})
it('repairs a malformed v2 value without restoring stale v1 state', async () => {
window.localStorage.setItem(LEGACY_DATA_MODE_STORAGE_KEY, 'harness')
window.localStorage.setItem(DATA_MODE_STORAGE_KEY, 'not-a-data-mode')
const { result } = renderHook(() => useDataMode(), { wrapper: providerWrapper({ initialMode: 'live' }) })
expect(result.current.mode).toBe('live')
await waitFor(() => {
expect(window.localStorage.getItem(DATA_MODE_STORAGE_KEY)).toBe('live')
})
expect(window.localStorage.getItem(LEGACY_DATA_MODE_STORAGE_KEY)).toBe('harness')
})
it('keeps legacy state intact when the v2 migration write fails', () => {
window.localStorage.setItem(LEGACY_DATA_MODE_STORAGE_KEY, 'harness')
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('Storage unavailable', 'QuotaExceededError')
})
const { result } = renderHook(() => useDataMode(), { wrapper: providerWrapper({ initialMode: 'live' }) })
expect(result.current.mode).toBe('live')
expect(window.localStorage.getItem(DATA_MODE_STORAGE_KEY)).toBeNull()
expect(window.localStorage.getItem(LEGACY_DATA_MODE_STORAGE_KEY)).toBe('harness')
})
it('persists data mode updates', async () => {
const { result } = renderHook(() => useDataMode(), { wrapper: providerWrapper() })
@ -93,4 +142,20 @@ describe('DataModeProvider', () => {
expect(result.current.mode).toBe('live')
expect(window.localStorage.getItem(storageKey)).toBeNull()
})
it('does not run the app upgrade migration for a host-owned storage key', async () => {
const storageKey = 'host-owned:data-mode'
window.localStorage.setItem(LEGACY_DATA_MODE_STORAGE_KEY, 'harness')
const { result } = renderHook(() => useDataMode(), {
wrapper: providerWrapper({ initialMode: 'live', storageKey })
})
expect(result.current.mode).toBe('live')
await waitFor(() => {
expect(window.localStorage.getItem(storageKey)).toBe('live')
})
expect(window.localStorage.getItem(DATA_MODE_STORAGE_KEY)).toBeNull()
expect(window.localStorage.getItem(LEGACY_DATA_MODE_STORAGE_KEY)).toBe('harness')
})
})

View file

@ -2,7 +2,8 @@ import { useEffect, useState, type ReactNode, type SetStateAction } from 'react'
import { env } from '@/lib/env'
import { DataModeContext, type DataMode } from '@/lib/data-mode/data-mode-context'
export const DATA_MODE_STORAGE_KEY = `${env.storageNamespace}:data-mode:v1`
export const DATA_MODE_STORAGE_KEY = `${env.storageNamespace}:data-mode:v2`
export const LEGACY_DATA_MODE_STORAGE_KEY = `${env.storageNamespace}:data-mode:v1`
export type DataModeProviderProps = {
children: ReactNode
@ -20,7 +21,21 @@ function readStoredDataMode(storageKey: string, fallbackMode: DataMode, persist:
try {
const storedValue = window.localStorage.getItem(storageKey)
return isDataMode(storedValue) ? storedValue : fallbackMode
if (isDataMode(storedValue)) return storedValue
// v1 may contain the old production default (`harness`), so its value
// cannot safely be interpreted as an explicit choice. A valid legacy key
// only marks this as an upgrade: seed v2 from today's environment default
// and retain v1 so a downgraded console can still start normally.
if (
storedValue === null &&
storageKey === DATA_MODE_STORAGE_KEY &&
isDataMode(window.localStorage.getItem(LEGACY_DATA_MODE_STORAGE_KEY))
) {
writeStoredDataMode(storageKey, fallbackMode, persist)
}
return fallbackMode
} catch {
return fallbackMode
}
@ -30,6 +45,7 @@ function writeStoredDataMode(storageKey: string, mode: DataMode, persist: boolea
if (!persist || typeof window === 'undefined') return
try {
if (window.localStorage.getItem(storageKey) === mode) return
window.localStorage.setItem(storageKey, mode)
} catch {
return

View file

@ -4,6 +4,9 @@
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
"paths": {
"@/*": ["./src/*"],
"@e2e/*": ["./e2e/*"]
}
}
}