dsh-context-compression-improved 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.gitattributes +1 -1
  2. package/.github/workflows/ci.yml +1 -7
  3. package/CHANGELOG.ja.md +18 -21
  4. package/CHANGELOG.ko.md +17 -21
  5. package/CHANGELOG.md +16 -17
  6. package/CHANGELOG.zh.md +15 -18
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.ja.md +1 -20
  9. package/README.ko.md +12 -30
  10. package/README.md +1 -19
  11. package/README.zh.md +5 -21
  12. package/THIRD_PARTY_NOTICES.md +2 -2
  13. package/docs/compat-0.1.5-report.md +77 -0
  14. package/docs/installation.ja.md +6 -4
  15. package/docs/installation.ko.md +9 -7
  16. package/docs/installation.md +3 -1
  17. package/docs/installation.zh.md +3 -2
  18. package/docs/repair-log.md +54 -33
  19. package/eslint.config.js +2 -2
  20. package/package.json +56 -36
  21. package/packages/selector/README.md +7 -12
  22. package/packages/selector/README.zh.md +6 -13
  23. package/packages/selector/THIRD_PARTY_NOTICES.md +2 -8
  24. package/packages/selector/cordis.patch.yml +7 -5
  25. package/packages/selector/dsh.plugin.json +2 -2
  26. package/packages/selector/lib/client.d.ts +31 -5
  27. package/packages/selector/lib/client.js +100 -82
  28. package/packages/selector/lib/index.d.ts +8 -20
  29. package/packages/selector/lib/index.js +9 -14
  30. package/packages/selector/lib/invariant.js +4 -4
  31. package/packages/selector/lib/pruner.d.ts +13 -4
  32. package/packages/selector/lib/pruner.js +91 -46
  33. package/packages/selector/lib/tail-trim.js +13 -4
  34. package/packages/selector/package.json +23 -23
  35. package/packages/selector/screenshots.json +1 -4
  36. package/packages/selector/src/client/CompressionProfileControls.tsx +5 -8
  37. package/packages/selector/src/client/CompressionProfileSelector.tsx +9 -5
  38. package/packages/selector/src/client/EstimatorControls.tsx +28 -32
  39. package/packages/selector/src/client/index.ts +101 -63
  40. package/packages/selector/src/client/locales.ts +0 -2
  41. package/packages/selector/src/client/preset-options.ts +49 -35
  42. package/packages/selector/src/client/settings-section.tsx +4 -6
  43. package/packages/selector/src/deepseek-v4-tokenizer.ts +0 -5
  44. package/packages/selector/src/index.ts +41 -46
  45. package/packages/selector/src/invariant.ts +5 -5
  46. package/packages/selector/src/pruner.ts +67 -36
  47. package/packages/selector/src/runtime/measurement.ts +6 -2
  48. package/packages/selector/src/runtime/session-events.ts +14 -1
  49. package/packages/selector/src/runtime/tail-trim.ts +4 -4
  50. package/packages/selector/src/runtime/tokenpilot/estimator.ts +34 -3
  51. package/packages/selector/src/runtime/types.ts +1 -1
  52. package/packages/selector/tests/auto-compact.client.spec.tsx +0 -13
  53. package/packages/selector/tests/built/client-artifact.spec.ts +5 -1
  54. package/packages/selector/tests/code-skeleton.client.spec.ts +2 -1
  55. package/packages/selector/tests/custom-contract.client.spec.ts +2 -1
  56. package/packages/selector/tests/estimator-channel.client.spec.tsx +16 -11
  57. package/packages/selector/tests/estimator-route-registration.host.spec.ts +12 -0
  58. package/packages/selector/tests/host-preset-overlay.host.spec.ts +6 -4
  59. package/packages/selector/tests/preset-options-write.client.spec.ts +87 -79
  60. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +17 -12
  61. package/packages/selector/tests/preset-overlay.host.spec.ts +5 -2
  62. package/packages/selector/tests/profiles.client.spec.tsx +3 -3
  63. package/packages/selector/tests/public/package-contract.client.spec.ts +27 -3
  64. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +103 -81
  65. package/packages/selector/tests/runtime/session-events.spec.ts +1 -1
  66. package/packages/selector/tests/settings-seat.client.spec.ts +86 -0
  67. package/packages/selector/tests/standing-generation.host.spec.ts +88 -71
  68. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +24 -21
  69. package/pnpm-workspace.yaml +52 -5
  70. package/scripts/capture-profile-baseline.mjs +42 -0
  71. package/scripts/generate-tokenizer-fixtures.py +5 -5
  72. package/scripts/generate-vision-fixtures.py +2 -2
  73. package/scripts/{packed-components-smoke.ts → packed-components-smoke.mjs} +116 -119
  74. package/scripts/{packed-install-e2e.ts → packed-install-e2e.mjs} +188 -160
  75. package/scripts/{verify-release.ts → verify-release.mjs} +70 -94
  76. package/tests/TEST_INVENTORY.md +6 -7
  77. package/scripts/capture-profile-baseline.ts +0 -80
  78. package/tsconfig.scripts.json +0 -13
  79. /package/{packages/selector/assets/screenshots → docs/assets}/context-compression-selector-profiles.jpg +0 -0
  80. /package/{packages/selector/assets/screenshots → docs/assets}/context-compression-selector-settings.png +0 -0
@@ -6,69 +6,82 @@ import type { CompressionSelectorInjected } from '../src/client/CompressionProfi
6
6
  import type { ContextCompressionSettings } from '../src/profiles.ts'
7
7
 
8
8
  /**
9
- * `settingsScope.set(field, value)` replaces the value at that field, so the
10
- * write path is asserted through the document it stores: writing the bare
11
- * patch is what silently deleted `estimatorMode` (and every sibling override)
12
- * whenever the user touched a second field of the TokenPilot-inspired card.
9
+ * The settings transport applies a `set` op AT its path, so the write path is
10
+ * asserted through the ops it submits: a whole-section write is what silently
11
+ * deleted `estimatorMode` (and every sibling override) whenever the user
12
+ * touched a second field of the TokenPilot-inspired card.
13
13
  */
14
14
 
15
- type ScopeFace = {
16
- getSnapshot: () => unknown
17
- subscribe: (listener: () => void) => () => void
18
- set: (field: string, value: unknown) => Promise<void>
19
- unset: (field: string) => Promise<void>
15
+ type PathOp = { op: 'set', path: string[], value: unknown } | { op: 'unset', path: string[] }
16
+
17
+ interface ScopeStub {
18
+ readonly writes: PathOp[][]
19
+ readonly snapshot: () => Record<string, unknown>
20
20
  }
21
21
 
22
- function createScopeStub(initial: ContextCompressionSettings, commit = true): {
23
- scope: ScopeFace
24
- writes: { field: string, value: unknown }[]
25
- section: () => Record<string, unknown>
26
- sectionOf: (key: string) => unknown
27
- } {
22
+ /** Emulate the Host settings service: apply ops at their path, then bump the revision. */
23
+ function createScopeStub(initial: ContextCompressionSettings, commit = true): ScopeStub {
28
24
  let revision = 1
29
25
  let value: Record<string, unknown> = structuredClone(initial) as unknown as Record<string, unknown>
30
- const writes: { field: string, value: unknown }[] = []
26
+ const writes: PathOp[][] = []
31
27
  const listeners = new Set<() => void>()
32
- const scope: ScopeFace = {
33
- getSnapshot: () => ({
34
- status: 'ready',
35
- value: value as unknown as ContextCompressionSettings,
36
- base: undefined,
37
- user: undefined,
38
- revision,
39
- writable: true,
40
- mode: 'host',
41
- }),
42
- subscribe: (listener: () => void) => {
43
- listeners.add(listener)
44
- return () => { listeners.delete(listener) }
45
- },
46
- set: (field: string, fieldValue: unknown) => {
47
- writes.push({ field, value: fieldValue })
48
- // The Host applies `set` AT the path: the section value is replaced.
49
- if (commit) {
50
- value = { ...value, [field]: structuredClone(fieldValue) }
51
- revision += 1
52
- for (const listener of listeners) listener()
53
- }
54
- return Promise.resolve()
55
- },
56
- unset: (field: string) => {
57
- writes.push({ field, value: undefined })
58
- if (commit) {
59
- delete value[field]
60
- revision += 1
61
- for (const listener of listeners) listener()
28
+ const applyOps = (ops: readonly PathOp[]): void => {
29
+ for (const op of ops) {
30
+ const [head, ...rest] = op.path
31
+ if (head === undefined) continue
32
+ if (rest.length === 0) {
33
+ if (op.op === 'set') value = { ...value, [head]: op.value }
34
+ else delete value[head]
35
+ continue
62
36
  }
63
- return Promise.resolve()
64
- },
37
+ const child = { ...(value[head] as Record<string, unknown> | undefined) }
38
+ const leaf = rest[rest.length - 1] as string
39
+ if (op.op === 'set') child[leaf] = op.value
40
+ else delete child[leaf]
41
+ value = { ...value, [head]: child }
42
+ }
43
+ revision += 1
44
+ for (const listener of listeners) listener()
65
45
  }
66
46
  return {
67
- scope,
68
47
  writes,
69
- section: () => value,
70
- sectionOf: (key: string) => value[key],
71
- }
48
+ snapshot: () => value,
49
+ // The scope face the injection factory binds.
50
+ scope: {
51
+ getSnapshot: () => ({
52
+ status: 'ready',
53
+ value: value as unknown as ContextCompressionSettings,
54
+ base: undefined,
55
+ user: undefined,
56
+ revision,
57
+ writable: true,
58
+ mode: 'host',
59
+ }),
60
+ subscribe: (listener: () => void) => {
61
+ listeners.add(listener)
62
+ return () => { listeners.delete(listener) }
63
+ },
64
+ set: (field: string, fieldValue: unknown) => {
65
+ const ops: PathOp[] = [{ op: 'set', path: [field], value: fieldValue }]
66
+ writes.push(ops)
67
+ applyOps(ops)
68
+ return Promise.resolve()
69
+ },
70
+ unset: (field: string) => {
71
+ const ops: PathOp[] = [{ op: 'unset', path: [field] }]
72
+ writes.push(ops)
73
+ applyOps(ops)
74
+ return Promise.resolve()
75
+ },
76
+ mutate: (ops: readonly PathOp[]) => {
77
+ writes.push([...ops])
78
+ // A refused or lost write changes no document, so the revision the
79
+ // confirmation read fences on never moves.
80
+ if (commit) applyOps(ops)
81
+ return Promise.resolve()
82
+ },
83
+ },
84
+ } as ScopeStub & { scope: unknown }
72
85
  }
73
86
 
74
87
  const DEFAULT_CUSTOM = {
@@ -91,8 +104,8 @@ function bindInjected(
91
104
  commit = true,
92
105
  ): {
93
106
  injected: CompressionSelectorInjected
94
- writes: { field: string, value: unknown }[]
95
- sectionOf: (key: string) => unknown
107
+ writes: PathOp[][]
108
+ snapshot: () => Record<string, unknown>
96
109
  } {
97
110
  const settings: ContextCompressionSettings = {
98
111
  profile: 'tokenpilot-inspired',
@@ -104,9 +117,6 @@ function bindInjected(
104
117
  const stub = createScopeStub(settings, commit)
105
118
  let options: Record<string, unknown> | undefined
106
119
  const ctx = {
107
- effect: (factory: () => unknown) => { factory(); return () => {} },
108
- locale: { register: () => {}, bind: () => (key: string) => key },
109
- settingsScope: { bind: () => stub.scope },
110
120
  slots: {
111
121
  inject: (_slot: string, factory: () => (() => void) | Generator<() => void>) => {
112
122
  const result = factory()
@@ -117,54 +127,52 @@ function bindInjected(
117
127
  return () => {}
118
128
  },
119
129
  },
130
+ // apply consumes the services through their declarative-inject faces
131
+ // (ctx.locale / ctx.settingsScope), the same shape cordis binds on the host.
132
+ locale: { bind: () => (key: string) => key, register: () => {} },
133
+ settingsScope: { bind: () => (stub as unknown as { scope: unknown }).scope },
120
134
  }
121
135
  apply(ctx as never)
122
136
  if (options === undefined) throw new Error('the settings card never registered')
123
137
  const factory = options['inject'] as () => CompressionSelectorInjected
124
- return { injected: factory(), writes: stub.writes, sectionOf: stub.sectionOf }
138
+ return { injected: factory(), writes: stub.writes, snapshot: stub.snapshot }
125
139
  }
126
140
 
127
- describe('presetOptions writes preserve sibling fields', () => {
141
+ describe('presetOptions writes are path-addressed', () => {
128
142
  it('keeps the estimator channel when a provider is chosen afterwards', async () => {
129
- const { injected, sectionOf } = bindInjected({ estimatorMode: 'host' })
143
+ const { injected, writes, snapshot } = bindInjected({ estimatorMode: 'host' })
130
144
  await injected.savePresetOptions({ estimatorProvider: 'local-35b' })
131
- expect(sectionOf('presetOptions')).toEqual({ estimatorMode: 'host', estimatorProvider: 'local-35b' })
132
- })
133
-
134
- it('writes the merged document rather than the bare patch', async () => {
135
- const { injected, writes } = bindInjected({ estimatorMode: 'direct', estimatorBaseUrl: 'http://192.168.100.242:8200' })
136
- await injected.savePresetOptions({ estimatorModel: 'Qwen3.6-35B-A3B' })
137
- expect(writes).toEqual([{
138
- field: 'presetOptions',
139
- value: {
140
- estimatorMode: 'direct',
141
- estimatorBaseUrl: 'http://192.168.100.242:8200',
142
- estimatorModel: 'Qwen3.6-35B-A3B',
143
- },
144
- }])
145
+ expect(snapshot()['presetOptions']).toEqual({ estimatorMode: 'host', estimatorProvider: 'local-35b' })
146
+ expect(writes).toEqual([[{ op: 'set', path: ['presetOptions', 'estimatorProvider'], value: 'local-35b' }]])
145
147
  })
146
148
 
147
- it('keeps every unrelated override across successive edits', async () => {
148
- const { injected, sectionOf } = bindInjected({
149
+ it('never replaces the whole section, so sibling overrides survive', async () => {
150
+ const { injected, writes, snapshot } = bindInjected({
149
151
  estimatorMode: 'direct',
152
+ estimatorBaseUrl: 'http://192.168.100.242:8200',
150
153
  dedupeToolResults: false,
151
154
  readState: false,
152
155
  })
153
156
  await injected.savePresetOptions({ estimatorModel: 'Qwen3.6-35B-A3B' })
154
157
  await injected.savePresetOptions({ estimatorProvider: 'local-35b' })
155
- expect(sectionOf('presetOptions')).toEqual({
158
+ expect(snapshot()['presetOptions']).toEqual({
156
159
  estimatorMode: 'direct',
160
+ estimatorBaseUrl: 'http://192.168.100.242:8200',
157
161
  dedupeToolResults: false,
158
162
  readState: false,
159
163
  estimatorModel: 'Qwen3.6-35B-A3B',
160
164
  estimatorProvider: 'local-35b',
161
165
  })
166
+ for (const batch of writes) {
167
+ for (const op of batch) expect(op.path.length).toBe(2)
168
+ }
162
169
  })
163
170
 
164
171
  it('clears exactly the field a patch names with undefined', async () => {
165
- const { injected, sectionOf } = bindInjected({ estimatorMode: 'direct', estimatorApiKey: 'sk-stored' })
172
+ const { injected, writes, snapshot } = bindInjected({ estimatorMode: 'direct', estimatorApiKey: 'sk-stored' })
166
173
  await injected.savePresetOptions({ estimatorApiKey: undefined })
167
- expect(sectionOf('presetOptions')).toEqual({ estimatorMode: 'direct' })
174
+ expect(snapshot()['presetOptions']).toEqual({ estimatorMode: 'direct' })
175
+ expect(writes).toEqual([[{ op: 'unset', path: ['presetOptions', 'estimatorApiKey'] }]])
168
176
  })
169
177
 
170
178
  it('writes nothing when the patch already matches the stored section', async () => {
@@ -15,15 +15,18 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
15
15
  import { scopeOf } from '@deepseek-ai/dsh-scope'
16
16
  import {
17
17
  SettingsProvider,
18
- settingsNamespace,
19
18
  type SettingsNamespace,
20
19
  } from '@deepseek-ai/dsh-settings'
21
20
  import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
21
+ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
22
22
  import TokenMeter from '@deepseek-ai/dsh-token-meter'
23
23
  import ToolRuntime from '@deepseek-ai/dsh-tools'
24
24
  import { afterEach, describe, expect, it } from 'vitest'
25
25
  import { apply } from '../src/index.ts'
26
26
 
27
+ // 0.1.5 removed the settingsNamespace() wrapper; namespaces are validated at runtime.
28
+ const nsBrand = (value: string): SettingsNamespace => value as unknown as SettingsNamespace
29
+
27
30
  const CORDIS_ORIGINAL = Symbol.for('cordis.original')
28
31
  type Traceable = { [CORDIS_ORIGINAL]?: unknown }
29
32
 
@@ -74,21 +77,23 @@ async function harness(): Promise<Context> {
74
77
  const presets = await createPresetRoot()
75
78
  const runtime = new Context()
76
79
  runtime.baseUrl = `${pathToFileURL(presets).href}/`
77
- await runtime.plugin(Loader)
80
+ await runtime.plugin(Loader).await()
78
81
  runtime.loader.builtins.include = Include
79
82
  runtime.loader.builtins.group = Group
80
- await runtime.plugin(LlmRuntime)
81
- await runtime.plugin(SessionStore)
82
- await runtime.plugin(SystemPrompt, { persona: '' })
83
- await runtime.plugin(ToolRuntime)
84
- await runtime.plugin(AgentRegistry)
85
- await runtime.plugin(AgentLoop, { agents: [] })
86
- await runtime.plugin(CommandRuntime)
87
- await runtime.plugin(TokenMeter)
88
- await runtime.plugin(MemorySettings)
83
+ await runtime.plugin(LlmRuntime).await()
84
+ await runtime.plugin(SessionStore).await()
85
+ await runtime.plugin(SystemPrompt).await()
86
+ await runtime.plugin(ToolRuntime).await()
87
+ await runtime.plugin(AgentRegistry).await()
88
+ await runtime.plugin(AgentLoop).await()
89
+ await runtime.plugin(CommandRuntime).await()
90
+ await runtime.plugin(SessionProjectionRegistry).await()
91
+ await runtime.plugin(TokenMeter).await()
92
+ await runtime.plugin(MemorySettings).await()
89
93
  await runtime.plugin(AgentPresets, {
90
94
  default: 'standard',
91
95
  roots: [{ path: presets, trust: 'system' }],
96
+ includeShippedRoot: false,
92
97
  includeUserRoot: false,
93
98
  })
94
99
  await runtime.plugin({
@@ -187,7 +192,7 @@ describe('standalone selector Bundle through the real preset Loader', () => {
187
192
 
188
193
  it('publishes the daily Custom defaults from the plugin-owned settings row', async () => {
189
194
  const runtime = await harness()
190
- const namespace = settingsNamespace('context-compression')
195
+ const namespace = nsBrand('context-compression')
191
196
  const settings = runtime.settings.get(namespace) as CustomSettings
192
197
 
193
198
  expect(settings.custom.history.trigger).toBe(500_000)
@@ -164,8 +164,11 @@ describe('plugin-owned preset overlay decorator', () => {
164
164
  const first = await presets.mount({}, 'standard')
165
165
  const same = await presets.mount({}, 'standard')
166
166
  expect(same.path).toBe(first.path)
167
- expect((await stat(dirname(first.path))).mode & 0o777).toBe(0o700)
168
- expect((await stat(first.path)).mode & 0o777).toBe(0o600)
167
+ // Windows (NTFS) ignores POSIX permission bits: mode stays 0o666.
168
+ if (process.platform !== 'win32') {
169
+ expect((await stat(dirname(first.path))).mode & 0o777).toBe(0o700)
170
+ expect((await stat(first.path)).mode & 0o777).toBe(0o600)
171
+ }
169
172
 
170
173
  await writeFile(standard.path, `${await readFile(standard.path, 'utf8')}\n- id: later\n name: '/opt/preset/later.js'\n`)
171
174
  const changed = await presets.mount({}, 'standard')
@@ -244,14 +244,14 @@ describe('context compression profiles', () => {
244
244
  },
245
245
  )
246
246
 
247
- it('explains that Minimal keeps the selector effectively off without overwriting the saved profile', () => {
247
+ it('keeps the selector selectable under a Minimal preset: 0.1.5 no longer exposes agentPreset client-side, so availability cannot gate the UI', () => {
248
248
  const { state } = renderReady(
249
249
  vi.fn(() => Promise.resolve()), false, key => COPY[key] ?? key,
250
250
  { agentPreset: 'minimal' },
251
251
  )
252
252
 
253
- expect(screen.getByText(/Minimal mode does not load context compression.*effectively off.*native behavior remains/)).not.toBeNull()
254
- expect(screen.getByRole<HTMLButtonElement>('button', { name: /Context compression/ }).disabled).toBe(true)
253
+ expect(screen.queryByText(/Minimal mode does not load context compression.*effectively off.*native behavior remains/)).toBeNull()
254
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: /Context compression/ }).disabled).toBe(false)
255
255
  expect(state.getSnapshot().value?.profile).toBe('native')
256
256
  })
257
257
 
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
@@ -19,10 +19,34 @@ describe('standalone package contract', () => {
19
19
  expect(Object.keys(selector.peerDependencies ?? {})).not.toContain('dsh-context-compression-improved-runtime')
20
20
  expect(Object.keys(selector.peerDependencies ?? {})).not.toContain('@deepseek-ai/dsh-compaction-tool-result-pruner')
21
21
  expect(selector.peerDependencies?.['@deepseek-ai/dsh-compaction-basic'])
22
- .toBe('>=0.1.1-rc.2 <0.2.0')
22
+ .toBe('>=0.1.5-rc.2 <0.2.0-0')
23
23
  expect(selector.peerDependencies?.['@deepseek-ai/dsh-command-compact'])
24
- .toBe('>=0.1.1-rc.2 <0.2.0')
24
+ .toBe('>=0.1.5-rc.2 <0.2.0-0')
25
25
  expect(selector.publishConfig?.tag).toBe('latest')
26
+ expect(existsSync(resolve(root, '../runtime/package.json'))).toBe(false)
27
+ })
28
+
29
+ it('makes the repository root the one git install surface', () => {
30
+ const rootManifest = JSON.parse(readFileSync(resolve(root, '../package.json'), 'utf8')) as {
31
+ name: string
32
+ exports?: Record<string, unknown>
33
+ dependencies?: Record<string, string>
34
+ engines?: Record<string, string>
35
+ dsh?: { bundle?: { patch?: string }, client?: { inject?: string[] } }
36
+ }
37
+ expect(rootManifest.name).toBe('dsh-context-compression-improved')
38
+ for (const specifier of ['.', './invariant', './pruner', './client']) {
39
+ expect(rootManifest.exports?.[specifier]).toBeDefined()
40
+ }
41
+ expect(rootManifest.dependencies?.['@huggingface/tokenizers']).toBe('0.1.3')
42
+ expect(rootManifest.dependencies?.['js-yaml']).toBe('^4.3.2')
43
+ expect(rootManifest.dsh?.bundle?.patch).toBe('./packages/selector/cordis.patch.yml')
44
+ expect(rootManifest.dsh?.client?.inject).toEqual([
45
+ '@deepseek-ai/dsh-client-locale',
46
+ '@deepseek-ai/dsh-client-ui-slots',
47
+ '@deepseek-ai/dsh-client-ui-settings',
48
+ ])
49
+ expect(rootManifest.engines?.dsh).toBe('>=0.1.5-alpha.1 <0.2.0-0')
26
50
  })
27
51
 
28
52
  it('uses the community package in the one Bundle patch', () => {