Merge branch 'master' into worktree/locale-browser-default
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(64px, 9vh, 104px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback {
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.feedback strong {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 120px;
|
||||
transition: transform 140ms var(--welcome-ease-out);
|
||||
}
|
||||
|
||||
.primary:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: welcome-enter 280ms var(--welcome-ease-out) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.opening { animation-delay: 80ms; }
|
||||
.status { animation-delay: 120ms; }
|
||||
.reflection { animation-delay: 160ms; }
|
||||
.feedback { animation-delay: 200ms; }
|
||||
.footer { animation-delay: 240ms; }
|
||||
|
||||
@keyframes welcome-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.primary {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Product-wide, versioned first-run welcome step. */
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import css from './WelcomeNotice.module.css'
|
||||
|
||||
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
|
||||
const index = paragraph.indexOf(emphasis)
|
||||
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
|
||||
if (index < 0) return paragraph
|
||||
return (
|
||||
<>
|
||||
{paragraph.slice(0, index)}
|
||||
<strong>{emphasis}</strong>
|
||||
{paragraph.slice(index + emphasis.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
|
||||
export interface WelcomeNoticeInjected {
|
||||
controller: WelcomeNoticeStore
|
||||
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
|
||||
}
|
||||
|
||||
/** Coordinator owner props plus the welcome step's injected face. */
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
|
||||
|
||||
/** Render the mandatory notice until its current version commits durably. */
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const finished = useRef(false)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
const finish = useCallback((): void => {
|
||||
if (finished.current) return
|
||||
finished.current = true
|
||||
complete()
|
||||
}, [complete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.acknowledged) finish()
|
||||
}, [finish, state.acknowledged])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
|
||||
}, [state.acknowledged, state.status])
|
||||
|
||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
||||
|
||||
const acknowledge = async (): Promise<void> => {
|
||||
if (await controller.acknowledge()) finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
|
||||
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
|
||||
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
|
||||
<p className={css.status}>{t('welcome.paragraph.1')}</p>
|
||||
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
|
||||
<p className={css.feedback}>
|
||||
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
|
||||
</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
|
||||
<div className={css.footer}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.primary}
|
||||
disabled={state.status === 'saving'}
|
||||
onClick={() => { void acknowledge() }}
|
||||
>
|
||||
{t('welcome.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Durable welcome-notice state over the Host settings document. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
export interface WelcomeNoticeState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
acknowledged: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates welcome acknowledgement reads and the sole durable write. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/** @param api - settings wire face used for durable reads and writes. */
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
|
||||
/** Load the current acknowledgement from the Host settings document. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version. The path mutation is idempotent across tabs and
|
||||
* preserves every sibling setting; failure leaves the step unacknowledged.
|
||||
* @returns true only when the Host committed the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after the welcome step has begun reading durable state.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
/** Field storing the last welcome notice version the user acknowledged. */
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
|
||||
/**
|
||||
* Bump only when the notice changes materially and every user should see it
|
||||
* again. The acknowledgement is compared for exact equality.
|
||||
*/
|
||||
export const WELCOME_NOTICE_VERSION = '2026-07-30.5'
|
||||
|
||||
/** The complete editable welcome notice in both supported GUI locales. */
|
||||
export const WELCOME_NOTICE_COPY = {
|
||||
zh: {
|
||||
title: '内测声明',
|
||||
paragraphs: [
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。',
|
||||
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
],
|
||||
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
en: {
|
||||
title: 'Internal Testing Notice',
|
||||
paragraphs: [
|
||||
'Thank you for taking the time to try DeepSeek Harness.',
|
||||
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
|
||||
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.',
|
||||
'We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
|
||||
],
|
||||
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
} as const
|
||||
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-settings-general host', () => {
|
||||
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
const mutate = vi.fn(mutateImpl)
|
||||
const api = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
stepId: 'welcome-notice',
|
||||
complete,
|
||||
openSection: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => key in zh ? zh[key as keyof typeof zh] : key,
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
it('renders the owner copy with one primary action and no dismissal control', async () => {
|
||||
const h = mount()
|
||||
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
|
||||
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
|
||||
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
|
||||
const buttons = page.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('region')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('completes only after the acknowledgement write commits', async () => {
|
||||
const h = mount()
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(h.mutate).toHaveBeenCalledOnce()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the sole action disabled while saving and reports a refused write', async () => {
|
||||
let resolveWrite!: (value: unknown) => void
|
||||
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
|
||||
const h = mount(undefined, () => write)
|
||||
await screen.findByRole('region')
|
||||
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
|
||||
fireEvent.click(action)
|
||||
expect(action.disabled).toBe(true)
|
||||
resolveWrite({
|
||||
rpcId: 'welcome-refused' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'read only', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
})
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('暂时无法保存确认状态,请重试。')
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
let rpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges only the exact current copy version', async () => {
|
||||
for (const [version, acknowledged] of [
|
||||
[undefined, false],
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
settings: { describe: () => Promise.reject('offline string') },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
|
||||
* one `card: 'search'` render intent ({@link SearchResultView}) with two
|
||||
* `shape`-discriminated variants: `grep` projects its matches grouped by file
|
||||
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
|
||||
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
|
||||
* projection each tool declares and the defensive `meta`→view narrowing each
|
||||
* tool's `presentResult` reads back on replay.
|
||||
*
|
||||
* The canonical value never crosses the wire — only the model-facing render text
|
||||
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
|
||||
* `meta`. Each projection consumes the SAME retained matches/paths the
|
||||
* model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core}
|
||||
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
|
||||
* results survived the inline cap, and reports `total` (every result found) and
|
||||
* `truncated`, so a UI never presents a capped result as complete.
|
||||
*
|
||||
* A second, independent cap bounds the JSON `meta` itself: the retained matches
|
||||
* of a broad search (hundreds of long lines) can still serialize to hundreds of
|
||||
* kilobytes, and `meta` is persisted with the session log and re-sent on every
|
||||
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
|
||||
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
|
||||
* final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`,
|
||||
* so this projection owns keeping `meta` bounded.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/presentation
|
||||
*/
|
||||
|
||||
import type {
|
||||
SearchFileMatches,
|
||||
SearchLineMatch,
|
||||
SearchResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
|
||||
/**
|
||||
* The retention fields a meta projection reads: the retained page, whether the
|
||||
* complete result was capped, and the pre-cap total. Both a full
|
||||
* {@link RetainedItems} (from `retainGrepMatches`) and `glob`'s sampled page
|
||||
* satisfy this structural subset, so a projection consumes either without a fake
|
||||
* `kept`/`omitted`.
|
||||
*/
|
||||
type RetainedPage<T> = Pick<RetainedItems<T>, 'items' | 'truncated' | 'seen'>
|
||||
|
||||
/**
|
||||
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
|
||||
* structured search result. Attached opaquely (as `JsonValue`) on the tool result
|
||||
* and persisted with the session log, so `presentResult` reproduces the search
|
||||
* card on replay. The `matches` shape carries the by-file groups; the `paths`
|
||||
* shape carries the flat list. Both carry the pre-cap `total` and the `truncated`
|
||||
* flag. The producing tool owns and narrows this opaque shape.
|
||||
*
|
||||
* The member shapes use object-literal `type` aliases rather than the
|
||||
* {@link SearchFileMatches}/{@link SearchLineMatch} interfaces because only a type
|
||||
* alias is assignable to the `JsonValue` index signature `presentationMeta`
|
||||
* returns; the two are structurally identical, so the projected value still reads
|
||||
* back as a {@link SearchResultView}.
|
||||
*/
|
||||
export type SearchMeta =
|
||||
| { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
|
||||
| { shape: 'paths'; paths: string[]; truncated: boolean; total: number }
|
||||
|
||||
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
|
||||
type MetaLineMatch = { lineNumber: number; line: string }
|
||||
|
||||
/** One file's grouped matches in {@link SearchMeta} (the JSON-assignable form of {@link SearchFileMatches}). */
|
||||
type MetaFileMatches = { path: string; matches: MetaLineMatch[] }
|
||||
|
||||
/**
|
||||
* Group flat matches by file (first-seen order) into the structured by-file shape
|
||||
* a UI renders as expandable per-file groups. The grouping matches the
|
||||
* model-facing text grouping
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `formatGrepMatches`), so
|
||||
* card and text agree about file order and membership.
|
||||
*
|
||||
* @param matches - the retained matches to group, in output order.
|
||||
* @returns one entry per file, in first-seen order.
|
||||
*/
|
||||
export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
|
||||
const byFile = new Map<string, MetaLineMatch[]>()
|
||||
for (const match of matches) {
|
||||
const entry: MetaLineMatch = { lineNumber: match.lineNumber, line: match.line }
|
||||
const group = byFile.get(match.path)
|
||||
if (group !== undefined) group.push(entry)
|
||||
else byFile.set(match.path, [entry])
|
||||
}
|
||||
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
|
||||
}
|
||||
|
||||
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
|
||||
function metaBytes(meta: SearchMeta): number {
|
||||
return Buffer.byteLength(JSON.stringify(meta), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop trailing top-level items (file groups or paths) until the serialized meta
|
||||
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
|
||||
* `total` is preserved (it counts what the search found, not what meta retains).
|
||||
* A single item too large to fit on its own is kept: the invariant is a bounded
|
||||
* payload wherever droppable, never an empty card that hides a real result.
|
||||
*
|
||||
* @param meta - the projected meta, already capped to the inline item count.
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
|
||||
*/
|
||||
function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta {
|
||||
if (metaBytes(meta) <= maxMetaBytes) return meta
|
||||
if (meta.shape === 'matches') {
|
||||
const files = [...meta.files]
|
||||
while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()
|
||||
return { ...meta, files, truncated: true }
|
||||
}
|
||||
const paths = [...meta.paths]
|
||||
while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()
|
||||
return { ...meta, paths, truncated: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `grep` matches into {@link SearchMeta} for the search
|
||||
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
|
||||
* (preview budget and inline match cap already applied), groups the retained
|
||||
* matches by file, reports `total` (every parsed match) and `truncated`, then
|
||||
* bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match (previewed, capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `matches`-shaped search metadata.
|
||||
*/
|
||||
export function grepSearchMeta(retained: RetainedPage<GrepMatch>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'matches',
|
||||
files: groupMatchesByFile(retained.items),
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
|
||||
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
|
||||
* path cap already applied), reports `total` (every discovered path) and
|
||||
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path (capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `paths`-shaped search metadata.
|
||||
*/
|
||||
export function globSearchMeta(retained: RetainedPage<string>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'paths',
|
||||
paths: retained.items,
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchLineMatch(value: unknown): value is SearchLineMatch {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { lineNumber, line } = value as Record<string, unknown>
|
||||
return typeof lineNumber === 'number' && typeof line === 'string'
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchFileMatches(value: unknown): value is SearchFileMatches {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { path, matches } = value as Record<string, unknown>
|
||||
return typeof path === 'string' && Array.isArray(matches) && matches.every(isSearchLineMatch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
|
||||
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
|
||||
* generic card instead of throwing during replay of an older or hand-edited log.
|
||||
* The view carries no result text: a UI without a search card falls back to the
|
||||
* raw `tool/result` content.
|
||||
*
|
||||
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
|
||||
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
|
||||
* zero-match grep is a legitimate result a UI shows as "no matches", not an
|
||||
* absent projection.
|
||||
*
|
||||
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
|
||||
* @returns the search view, or `undefined` for absent or malformed metadata.
|
||||
*/
|
||||
export function searchViewFromMeta(meta: unknown): SearchResultView | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const record = meta as Record<string, unknown>
|
||||
const { truncated, total } = record
|
||||
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
|
||||
if (record.shape === 'matches') {
|
||||
const { files } = record
|
||||
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
|
||||
return { card: 'search', shape: 'matches', files: files, truncated, total }
|
||||
}
|
||||
if (record.shape === 'paths') {
|
||||
const { paths } = record
|
||||
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
|
||||
return { card: 'search', shape: 'paths', paths, truncated, total }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Unit tests for the search-card presentation layer (`src/presentation.ts`): the
|
||||
* canonical value → `presentationMeta` projections (`grepSearchMeta`,
|
||||
* `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view
|
||||
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the
|
||||
* `truncated`/`total` honesty over already-retained input, the serialized-meta
|
||||
* byte cap, and the malformed-metadata fallback a replayed or hand-edited log can
|
||||
* deliver.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
globSearchMeta,
|
||||
grepSearchMeta,
|
||||
groupMatchesByFile,
|
||||
searchViewFromMeta,
|
||||
} from '../src/presentation.ts'
|
||||
import type { GrepMatch } from '../src/search-core.ts'
|
||||
import { retainGlobPaths, retainGrepMatches } from '../src/search-core.ts'
|
||||
|
||||
const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line })
|
||||
|
||||
/** A byte cap large enough that no test payload here is meta-capped. */
|
||||
const WIDE = 1_000_000
|
||||
|
||||
describe('groupMatchesByFile', () => {
|
||||
it('groups matches by first-seen file order, keeping line/lineNumber only', () => {
|
||||
expect(groupMatchesByFile([
|
||||
match('b.ts', 2, 'x'),
|
||||
match('a.ts', 1, 'y'),
|
||||
match('b.ts', 5, 'z'),
|
||||
])).toEqual([
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'x' }, { lineNumber: 5, line: 'z' }] },
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'y' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for no matches', () => {
|
||||
expect(groupMatchesByFile([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grepSearchMeta', () => {
|
||||
it('projects grouped matches with total and a false truncation flag within the cap', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: false,
|
||||
total: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the per-line preview budget (UTF-8 boundary) the retention pass applied', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.txt', 1, 'aéaéaéaé')], 10, 7), WIDE)
|
||||
expect(meta).toMatchObject({ shape: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
|
||||
})
|
||||
|
||||
it('drops trailing file groups until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGrepMatches(
|
||||
[match('a.ts', 1, 'x'.repeat(60)), match('b.ts', 2, 'y'.repeat(60)), match('c.ts', 3, 'z'.repeat(60))],
|
||||
10,
|
||||
2000,
|
||||
)
|
||||
// One 60-byte group serializes to ~110 bytes; a 260-byte cap holds two, not three.
|
||||
const meta = grepSearchMeta(retained, 260)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.files.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(260)
|
||||
})
|
||||
|
||||
it('keeps a single oversized group rather than emit an empty card', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'x'.repeat(500))], 10, 2000), 50)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.files).toHaveLength(1)
|
||||
expect(meta.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('globSearchMeta', () => {
|
||||
it('projects the path list with total and a false truncation flag within the cap', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts'], 10), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts', 'c.ts'], 2), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
})
|
||||
|
||||
it('drops trailing paths until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGlobPaths([`${'a'.repeat(100)}.ts`, `${'b'.repeat(100)}.ts`, `${'c'.repeat(100)}.ts`], 10)
|
||||
const meta = globSearchMeta(retained, 180)
|
||||
expect(meta.shape).toBe('paths')
|
||||
if (meta.shape !== 'paths') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.paths.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(180)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchViewFromMeta (defensive narrowing)', () => {
|
||||
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
|
||||
// statically-valid JsonValue, so route every case through one cast helper that
|
||||
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
|
||||
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
|
||||
|
||||
it('narrows a well-formed matches payload into a matches view', () => {
|
||||
const meta = { shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a well-formed paths payload into a paths view', () => {
|
||||
const meta = { shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a zero-result payload into a valid empty card (not a rejected projection)', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'matches', files: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'matches', files: [], truncated: false, total: 0 })
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'paths', paths: [], truncated: false, total: 0 })
|
||||
})
|
||||
|
||||
it('rejects undefined / non-object / array meta', () => {
|
||||
expect(searchViewFromMeta(undefined)).toBeUndefined()
|
||||
expect(searchViewFromMeta(null)).toBeUndefined()
|
||||
expect(searchViewFromMeta(m('nope'))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m([]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a payload with a missing / mistyped truncated or total field', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown or missing shape discriminant', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'other', truncated: false, total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a matches payload with a malformed files array', () => {
|
||||
const base = { shape: 'matches', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [[]] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 1, matches: [] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: 'x' }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [null] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: '1', line: 'x' }] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: 1, line: 2 }] }] }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a paths payload with a non-array or non-string-element paths field', () => {
|
||||
const base = { shape: 'paths', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user