model.client.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. ClientWorkspaceModel, type WorkspaceRemote,
  4. } from '../src/client/index.ts'
  5. import type {
  6. WorkspaceArchiveSessionRequest,
  7. WorkspaceArchiveValue,
  8. WorkspaceCreateRequest,
  9. WorkspaceCreateValue,
  10. WorkspaceDeleteRequest,
  11. WorkspaceDeleteValue,
  12. WorkspaceFollowFrame,
  13. WorkspaceInsertBeforeRequest,
  14. WorkspaceInsertSessionBeforeRequest,
  15. WorkspaceOrderValue,
  16. WorkspaceRenameRequest,
  17. WorkspaceUnarchiveSessionRequest,
  18. WorkspaceValue,
  19. WorkspaceId,
  20. WorkspaceView,
  21. } from '../src/types.ts'
  22. import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  23. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  24. const sid = (id: string): SessionId => id as SessionId
  25. const wid = (id: string): WorkspaceId => id as WorkspaceId
  26. function workspace(
  27. id: string,
  28. sessionIds: readonly SessionId[] = [],
  29. updatedAt = '2026-01-01T00:00:00.000Z',
  30. ): WorkspaceView {
  31. return {
  32. workspaceId: wid(id),
  33. path: `/w/${id}`,
  34. title: id,
  35. sessionIds,
  36. createdAt: '2026-01-01T00:00:00.000Z',
  37. updatedAt,
  38. }
  39. }
  40. function remoteOk<T>(value: T): RemoteResult<T> {
  41. return { ok: true, value }
  42. }
  43. function workspaceError(error: RemoteFailure): RemoteResult<never> {
  44. return { ok: false, error }
  45. }
  46. interface Deferred<T> {
  47. readonly promise: Promise<T>
  48. resolve(value: T): void
  49. reject(error: unknown): void
  50. }
  51. function deferred<T>(): Deferred<T> {
  52. let resolve!: (value: T) => void
  53. let reject!: (error: unknown) => void
  54. const promise = new Promise<T>((accept, fail) => {
  55. resolve = accept
  56. reject = fail
  57. })
  58. return { promise, reject, resolve }
  59. }
  60. class FakeWorkspaceRemote implements WorkspaceRemote {
  61. readonly calls: Array<{ readonly method: string; readonly request: unknown }> = []
  62. onCreate: (request: WorkspaceCreateRequest) => Promise<RemoteResult<WorkspaceCreateValue>> = request =>
  63. Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true }))
  64. onRename: (request: WorkspaceRenameRequest) => Promise<RemoteResult<WorkspaceValue>> = request =>
  65. Promise.resolve(remoteOk({ workspace: { ...workspace(String(request.workspaceId)), title: request.title } }))
  66. onDelete: (_request: WorkspaceDeleteRequest) => Promise<RemoteResult<WorkspaceDeleteValue>> = () =>
  67. Promise.resolve(remoteOk({ deleted: true }))
  68. onInsertBefore: (
  69. request: WorkspaceInsertBeforeRequest,
  70. ) => Promise<RemoteResult<WorkspaceOrderValue>> = request =>
  71. Promise.resolve(remoteOk({ workspaceIds: [request.workspaceId] }))
  72. onInsertSessionBefore: (
  73. request: WorkspaceInsertSessionBeforeRequest,
  74. ) => Promise<RemoteResult<WorkspaceValue>> = request => Promise.resolve(remoteOk({
  75. workspace: workspace(String(request.workspaceId), [request.sessionId]),
  76. }))
  77. onArchiveSession: (
  78. request: WorkspaceArchiveSessionRequest,
  79. ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
  80. Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
  81. onUnarchiveSession: (
  82. request: WorkspaceUnarchiveSessionRequest,
  83. ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
  84. Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
  85. create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
  86. this.record('create', request)
  87. return this.onCreate(request)
  88. }
  89. rename(request: WorkspaceRenameRequest): Promise<RemoteResult<WorkspaceValue>> {
  90. this.record('rename', request)
  91. return this.onRename(request)
  92. }
  93. delete(request: WorkspaceDeleteRequest): Promise<RemoteResult<WorkspaceDeleteValue>> {
  94. this.record('delete', request)
  95. return this.onDelete(request)
  96. }
  97. insertBefore(request: WorkspaceInsertBeforeRequest): Promise<RemoteResult<WorkspaceOrderValue>> {
  98. this.record('insertBefore', request)
  99. return this.onInsertBefore(request)
  100. }
  101. insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<RemoteResult<WorkspaceValue>> {
  102. this.record('insertSessionBefore', request)
  103. return this.onInsertSessionBefore(request)
  104. }
  105. archiveSession(request: WorkspaceArchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
  106. this.record('archiveSession', request)
  107. return this.onArchiveSession(request)
  108. }
  109. unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
  110. this.record('unarchiveSession', request)
  111. return this.onUnarchiveSession(request)
  112. }
  113. async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
  114. private record(method: string, request: unknown): void {
  115. this.calls.push({ method, request })
  116. }
  117. }
  118. function modelFor(remote = new FakeWorkspaceRemote()): ClientWorkspaceModel {
  119. return new ClientWorkspaceModel(remote)
  120. }
  121. function baseline(
  122. model: ClientWorkspaceModel,
  123. items: readonly WorkspaceView[] = [],
  124. archivedSessionIds: readonly SessionId[] = [],
  125. ): void {
  126. model.replaceBaseline({ items, archivedSessionIds })
  127. }
  128. describe('ClientWorkspaceModel', () => {
  129. it('replaces reconnect state and applies ordered increments', () => {
  130. const model = modelFor()
  131. expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' })
  132. baseline(model, [workspace('old'), workspace('kept')])
  133. model.upsertView(workspace('new'))
  134. model.replaceOrder([wid('kept'), wid('new'), wid('old')])
  135. model.replaceArchived([sid('hidden')])
  136. model.removeView(wid('old'))
  137. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', archivedSessionIds: ['hidden'] })
  138. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept', 'new'])
  139. baseline(model, [workspace('fresh')])
  140. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['fresh'])
  141. expect(model.getSnapshot().archivedSessionIds).toEqual([])
  142. })
  143. it('keeps the last baseline during retry and exposes a terminal stream failure', () => {
  144. const model = modelFor()
  145. baseline(model, [workspace('visible')])
  146. model.handleCarrierFailure()
  147. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null })
  148. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible'])
  149. model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {}))
  150. expect(model.getSnapshot()).toMatchObject({
  151. phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' },
  152. })
  153. // An unmarked value never crosses the stream boundary: it is a local fault.
  154. expect(() => { model.handleStreamFailure('plain failure') }).toThrow()
  155. baseline(model, [workspace('restored')])
  156. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null })
  157. })
  158. it('creates by path and prepends the returned row', async () => {
  159. const remote = new FakeWorkspaceRemote()
  160. const model = modelFor(remote)
  161. remote.onCreate = request => Promise.resolve(remoteOk({
  162. workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
  163. created: request.path === '/w/created',
  164. }))
  165. await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
  166. expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } })
  167. expect(model.getSnapshot().items[0]?.workspaceId).toBe('created')
  168. })
  169. it('lets newer stream order outrank unary echoes and rolls failures back', async () => {
  170. const remote = new FakeWorkspaceRemote()
  171. const model = modelFor(remote)
  172. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  173. const gate = deferred<RemoteResult<WorkspaceOrderValue>>()
  174. remote.onInsertBefore = () => gate.promise
  175. const pending = model.insertBefore(wid('three'), wid('one'))
  176. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
  177. model.replaceOrder([wid('one'), wid('three'), wid('two')])
  178. gate.resolve(remoteOk({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
  179. await pending
  180. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  181. remote.onInsertBefore = () => Promise.resolve(workspaceError(
  182. new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }),
  183. ))
  184. const rejected = model.insertBefore(wid('three'))
  185. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  186. await expect(rejected).resolves.toMatchObject({ ok: false })
  187. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  188. })
  189. it('keeps a newer optimistic reorder when an older refused call settles', async () => {
  190. const remote = new FakeWorkspaceRemote()
  191. const model = modelFor(remote)
  192. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  193. const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  194. const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  195. let request = 0
  196. remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
  197. const first = model.insertBefore(wid('three'), wid('one'))
  198. const second = model.insertBefore(wid('two'), wid('three'))
  199. firstGate.resolve(workspaceError(
  200. new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }),
  201. ))
  202. await expect(first).resolves.toMatchObject({ ok: false })
  203. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  204. secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] }))
  205. await expect(second).resolves.toMatchObject({ ok: true })
  206. })
  207. it('rolls overlapping rejected reorders back to the last Host order', async () => {
  208. const remote = new FakeWorkspaceRemote()
  209. const model = modelFor(remote)
  210. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  211. const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  212. const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  213. let request = 0
  214. remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
  215. const first = model.insertBefore(wid('three'), wid('one'))
  216. const second = model.insertBefore(wid('two'), wid('three'))
  217. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  218. firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') })))
  219. await expect(first).resolves.toMatchObject({ ok: false })
  220. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  221. secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') })))
  222. await expect(second).resolves.toMatchObject({ ok: false })
  223. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  224. })
  225. it('retains removal tombstones across later baselines', () => {
  226. const model = modelFor()
  227. baseline(model, [workspace('gone'), workspace('kept')])
  228. model.removeView(wid('gone'))
  229. model.removeView(wid('gone'))
  230. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  231. baseline(model, [workspace('gone')])
  232. expect(model.getSnapshot().items).toEqual([])
  233. })
  234. it('does not let delayed unary data resurrect a removed Workspace', async () => {
  235. const remote = new FakeWorkspaceRemote()
  236. const model = modelFor(remote)
  237. baseline(model, [workspace('gone')])
  238. const gate = deferred<RemoteResult<WorkspaceValue>>()
  239. remote.onRename = () => gate.promise
  240. const rename = model.rename(wid('gone'), 'late')
  241. model.removeView(wid('gone'))
  242. gate.resolve(remoteOk({ workspace: { ...workspace('gone'), title: 'late' } }))
  243. await expect(rename).resolves.toMatchObject({ ok: true })
  244. expect(model.getSnapshot().items).toEqual([])
  245. })
  246. it('applies Workspace mutation echoes and leaves failed results unchanged', async () => {
  247. const remote = new FakeWorkspaceRemote()
  248. const model = modelFor(remote)
  249. baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')])
  250. remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
  251. await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false })
  252. expect(model.getSnapshot().items[0]?.title).toBe('one')
  253. remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
  254. await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false })
  255. expect(model.getSnapshot().items).toHaveLength(1)
  256. remote.onInsertSessionBefore = request => Promise.resolve(remoteOk({
  257. workspace: workspace('one', [request.sessionId, sid('first')], '2026-02-01T00:00:00.000Z'),
  258. }))
  259. await expect(model.insertSessionBefore(wid('one'), sid('second'), sid('first')))
  260. .resolves.toMatchObject({ ok: true })
  261. expect(remote.calls).toContainEqual({
  262. method: 'insertSessionBefore',
  263. request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' },
  264. })
  265. remote.onInsertSessionBefore = () => Promise.resolve(workspaceError(
  266. new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }),
  267. ))
  268. await expect(model.insertSessionBefore(wid('one'), sid('second')))
  269. .resolves.toMatchObject({ ok: false })
  270. expect(remote.calls).toContainEqual({
  271. method: 'insertSessionBefore',
  272. request: { workspaceId: 'one', sessionId: 'second' },
  273. })
  274. remote.onArchiveSession = () => Promise.resolve(workspaceError(
  275. new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
  276. ))
  277. await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
  278. expect(model.getSnapshot().archivedSessionIds).toEqual(['archived'])
  279. remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
  280. await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
  281. expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
  282. remote.onUnarchiveSession = () => Promise.resolve(workspaceError(
  283. new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
  284. ))
  285. await expect(model.unarchiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
  286. expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
  287. remote.onUnarchiveSession = () => Promise.resolve(remoteOk({ archivedSessionIds: [] }))
  288. await expect(model.unarchiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
  289. expect(model.getSnapshot().archivedSessionIds).toEqual([])
  290. expect(remote.calls).toContainEqual({ method: 'unarchiveSession', request: { sessionId: 'fresh' } })
  291. })
  292. it('keeps the latest unarchive reply when overlapping requests settle out of order', async () => {
  293. const remote = new FakeWorkspaceRemote()
  294. const model = modelFor(remote)
  295. baseline(model, [], [sid('first'), sid('second')])
  296. const firstGate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  297. const secondGate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  298. let request = 0
  299. remote.onUnarchiveSession = () => request++ === 0 ? firstGate.promise : secondGate.promise
  300. const first = model.unarchiveSession(sid('first'))
  301. const second = model.unarchiveSession(sid('second'))
  302. secondGate.resolve(remoteOk({ archivedSessionIds: [] }))
  303. await expect(second).resolves.toMatchObject({ ok: true })
  304. expect(model.getSnapshot().archivedSessionIds).toEqual([])
  305. firstGate.resolve(remoteOk({ archivedSessionIds: [sid('second')] }))
  306. await expect(first).resolves.toMatchObject({ ok: true })
  307. expect(model.getSnapshot().archivedSessionIds).toEqual([])
  308. })
  309. it('keeps a pushed archive set when an unarchive reply lands later', async () => {
  310. const remote = new FakeWorkspaceRemote()
  311. const model = modelFor(remote)
  312. baseline(model, [], [sid('first')])
  313. const gate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  314. remote.onUnarchiveSession = () => gate.promise
  315. const pending = model.unarchiveSession(sid('first'))
  316. model.replaceArchived([sid('first'), sid('second')])
  317. gate.resolve(remoteOk({ archivedSessionIds: [] }))
  318. await expect(pending).resolves.toMatchObject({ ok: true })
  319. expect(model.getSnapshot().archivedSessionIds).toEqual(['first', 'second'])
  320. })
  321. it('keeps the latest archive reply when overlapping requests settle out of order', async () => {
  322. const remote = new FakeWorkspaceRemote()
  323. const model = modelFor(remote)
  324. const firstGate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  325. const secondGate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  326. let request = 0
  327. remote.onArchiveSession = () => request++ === 0 ? firstGate.promise : secondGate.promise
  328. const first = model.archiveSession(sid('first'))
  329. const second = model.archiveSession(sid('second'))
  330. secondGate.resolve(remoteOk({ archivedSessionIds: [sid('first'), sid('second')] }))
  331. await expect(second).resolves.toMatchObject({ ok: true })
  332. expect(model.getSnapshot().archivedSessionIds).toEqual(['first', 'second'])
  333. firstGate.resolve(remoteOk({ archivedSessionIds: [sid('first')] }))
  334. await expect(first).resolves.toMatchObject({ ok: true })
  335. expect(model.getSnapshot().archivedSessionIds).toEqual(['first', 'second'])
  336. })
  337. it('keeps a baseline archive set when an unarchive reply lands later', async () => {
  338. const remote = new FakeWorkspaceRemote()
  339. const model = modelFor(remote)
  340. baseline(model, [], [sid('first')])
  341. const gate = deferred<RemoteResult<WorkspaceArchiveValue>>()
  342. remote.onUnarchiveSession = () => gate.promise
  343. const pending = model.unarchiveSession(sid('first'))
  344. baseline(model, [], [sid('second')])
  345. gate.resolve(remoteOk({ archivedSessionIds: [] }))
  346. await expect(pending).resolves.toMatchObject({ ok: true })
  347. expect(model.getSnapshot().archivedSessionIds).toEqual(['second'])
  348. })
  349. it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
  350. const model = modelFor()
  351. baseline(model, [
  352. workspace('one', [], '2026-02-01T00:00:00.000Z'),
  353. workspace('two'),
  354. ])
  355. model.upsertView(workspace('one', [], '2025-12-01T00:00:00.000Z'))
  356. expect(model.getSnapshot().items[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z')
  357. model.upsertView(workspace('one', [sid('new')], '2026-03-01T00:00:00.000Z'))
  358. expect(model.getSnapshot().items[0]?.sessionIds).toEqual(['new'])
  359. model.replaceOrder([wid('one')])
  360. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  361. model.replaceOrder([wid('two')])
  362. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
  363. model.replaceOrder([wid('one')])
  364. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  365. await expect(model.insertBefore(wid('one'), wid('one'))).resolves.toMatchObject({ ok: true })
  366. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  367. })
  368. it('notifies subscribers and cancels a queued notification after an immediate delete echo', async () => {
  369. const remote = new FakeWorkspaceRemote()
  370. const model = modelFor(remote)
  371. baseline(model, [workspace('gone')])
  372. await Promise.resolve()
  373. const listener = vi.fn()
  374. const unsubscribe = model.subscribe(listener)
  375. const deletion = model.delete(wid('gone'))
  376. model.removeView(wid('gone'))
  377. await expect(deletion).resolves.toMatchObject({ ok: true })
  378. expect(listener).toHaveBeenCalledOnce()
  379. await Promise.resolve()
  380. expect(listener).toHaveBeenCalledOnce()
  381. unsubscribe()
  382. model.handleCarrierFailure()
  383. await Promise.resolve()
  384. expect(listener).toHaveBeenCalledOnce()
  385. })
  386. it('removes from a unary delete echo before the operation resolves', async () => {
  387. const remote = new FakeWorkspaceRemote()
  388. const model = modelFor(remote)
  389. baseline(model, [workspace('gone')])
  390. await expect(model.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
  391. expect(remote.calls).toContainEqual({ method: 'delete', request: { workspaceId: 'gone' } })
  392. expect(model.getSnapshot().items).toEqual([])
  393. model.removeView(wid('gone'))
  394. expect(model.getSnapshot().items).toEqual([])
  395. })
  396. })