@owlmeans/client-planning 0.1.18-rc.8 → 0.1.18
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.
- package/README.md +94 -11
- package/agent-meta/manifest.json +62 -4
- package/agent-meta/skills/client-planning/SKILL.md +60 -78
- package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
- package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
- package/agent-meta/skills/client-planning/parts/validation.md +60 -0
- package/build/client.d.ts +1 -1
- package/build/client.d.ts.map +1 -1
- package/build/client.js +66 -14
- package/build/client.js.map +1 -1
- package/build/commits.d.ts +2 -13
- package/build/commits.d.ts.map +1 -1
- package/build/commits.js +190 -132
- package/build/commits.js.map +1 -1
- package/build/consts.d.ts +4 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -4
- package/build/consts.js.map +1 -1
- package/build/definitions.d.ts +13 -0
- package/build/definitions.d.ts.map +1 -0
- package/build/definitions.js +70 -0
- package/build/definitions.js.map +1 -0
- package/build/events/types.d.ts +34 -0
- package/build/events/types.d.ts.map +1 -0
- package/build/events/types.js +2 -0
- package/build/events/types.js.map +1 -0
- package/build/events.d.ts +10 -21
- package/build/events.d.ts.map +1 -1
- package/build/events.js +104 -102
- package/build/events.js.map +1 -1
- package/build/facade.d.ts.map +1 -1
- package/build/facade.js +86 -49
- package/build/facade.js.map +1 -1
- package/build/feed.d.ts +4 -4
- package/build/feed.d.ts.map +1 -1
- package/build/feed.js +50 -27
- package/build/feed.js.map +1 -1
- package/build/helper/types.d.ts +15 -0
- package/build/helper/types.d.ts.map +1 -0
- package/build/helper/types.js +2 -0
- package/build/helper/types.js.map +1 -0
- package/build/helper.d.ts +7 -7
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +16 -8
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -0
- package/build/index.js.map +1 -1
- package/build/lifecycle/types.d.ts +27 -0
- package/build/lifecycle/types.d.ts.map +1 -0
- package/build/lifecycle/types.js +2 -0
- package/build/lifecycle/types.js.map +1 -0
- package/build/lifecycle.d.ts +3 -0
- package/build/lifecycle.d.ts.map +1 -0
- package/build/lifecycle.js +157 -0
- package/build/lifecycle.js.map +1 -0
- package/build/resource-feed.d.ts +5 -0
- package/build/resource-feed.d.ts.map +1 -0
- package/build/resource-feed.js +97 -0
- package/build/resource-feed.js.map +1 -0
- package/build/resources.d.ts +6 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +93 -0
- package/build/resources.js.map +1 -0
- package/build/stores.d.ts +4 -11
- package/build/stores.d.ts.map +1 -1
- package/build/stores.js +11 -35
- package/build/stores.js.map +1 -1
- package/build/sync/types.d.ts +18 -0
- package/build/sync/types.d.ts.map +1 -0
- package/build/sync/types.js +2 -0
- package/build/sync/types.js.map +1 -0
- package/build/sync.d.ts +4 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +26 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +48 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +13 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/record/types.d.ts +28 -0
- package/build/utils/record/types.d.ts.map +1 -0
- package/build/utils/record/types.js +2 -0
- package/build/utils/record/types.js.map +1 -0
- package/build/utils/record.d.ts +3 -24
- package/build/utils/record.d.ts.map +1 -1
- package/build/utils/record.js +64 -70
- package/build/utils/record.js.map +1 -1
- package/build/validation/consts.d.ts +23 -0
- package/build/validation/consts.d.ts.map +1 -0
- package/build/validation/consts.js +289 -0
- package/build/validation/consts.js.map +1 -0
- package/build/validation/types.d.ts +17 -0
- package/build/validation/types.d.ts.map +1 -0
- package/build/validation/types.js +2 -0
- package/build/validation/types.js.map +1 -0
- package/build/validation.d.ts +10 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +171 -0
- package/build/validation.js.map +1 -0
- package/package.json +20 -18
- package/src/client.ts +58 -15
- package/src/commits.ts +152 -184
- package/src/consts.ts +12 -4
- package/src/definitions.ts +93 -0
- package/src/events/types.ts +35 -0
- package/src/events.ts +118 -108
- package/src/facade.ts +87 -55
- package/src/feed.ts +47 -28
- package/src/helper/types.ts +15 -0
- package/src/helper.ts +27 -10
- package/src/index.ts +12 -0
- package/src/lifecycle/types.ts +28 -0
- package/src/lifecycle.ts +123 -0
- package/src/resource-feed.ts +79 -0
- package/src/resources.ts +84 -0
- package/src/stores.ts +11 -40
- package/src/sync/types.ts +18 -0
- package/src/sync.ts +40 -0
- package/src/types.local.ts +14 -0
- package/src/types.ts +53 -5
- package/src/utils/record/types.ts +27 -0
- package/src/utils/record.ts +68 -71
- package/src/validation/consts.ts +289 -0
- package/src/validation/types.ts +18 -0
- package/src/validation.ts +140 -0
- package/tests/app.spec.ts +72 -0
- package/tests/context.ts +46 -15
- package/tests/csp.spec.ts +119 -0
- package/tests/definitions.spec.ts +74 -0
- package/tests/draft7.spec.ts +13 -0
- package/tests/events.spec.ts +13 -9
- package/tests/facade.spec.ts +2 -1
- package/tests/lifecycle/context.ts +29 -0
- package/tests/lifecycle-close.spec.ts +103 -0
- package/tests/lifecycle-commits.spec.ts +92 -0
- package/tests/lifecycle-definitions.spec.ts +28 -0
- package/tests/lifecycle-feeds.spec.ts +92 -0
- package/tests/lifecycle-sockets.spec.ts +76 -0
- package/tests/replies.spec.ts +81 -0
- package/tests/resources.spec.ts +116 -0
- package/tests/stores.spec.ts +13 -11
- package/tests/validation.spec.ts +127 -0
package/tests/context.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
} from '@owlmeans/entrypoint'
|
|
10
10
|
import { ResilientError } from '@owlmeans/error'
|
|
11
11
|
import {
|
|
12
|
-
CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
|
|
12
|
+
AssigneeKind, CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
|
|
13
13
|
WorkcardKind,
|
|
14
14
|
} from '@owlmeans/planning'
|
|
15
15
|
import type {
|
|
@@ -86,6 +86,7 @@ const types: AnyTypeSchema[] = [
|
|
|
86
86
|
flows: [PROJECT_FLOW],
|
|
87
87
|
specifications: [{ category: 'brief', format: SpecificationFormat.Markdown, revisioned: true, type: SPEC }],
|
|
88
88
|
cardTypes: [STORY],
|
|
89
|
+
scopedCardTypes: true,
|
|
89
90
|
},
|
|
90
91
|
{
|
|
91
92
|
type: STORY,
|
|
@@ -111,13 +112,23 @@ const types: AnyTypeSchema[] = [
|
|
|
111
112
|
},
|
|
112
113
|
]
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
const assigneeTypes = [{ type: 'test:participant', version: 1, kind: AssigneeKind.Human, fields: { type: 'object', additionalProperties: false } }]
|
|
116
|
+
export const SCHEMAS: PlanningSchemaBundle = { version: 1, types, flows, assigneeTypes }
|
|
115
117
|
|
|
116
118
|
export const protocols = makePlanningProtocols({
|
|
117
119
|
base: { alias: 'test:planning', path: '/planning' },
|
|
118
120
|
guards: [],
|
|
119
121
|
})
|
|
120
122
|
|
|
123
|
+
/** The same mount with the data-defined schema surface. */
|
|
124
|
+
export const definitionProtocols = makePlanningProtocols({
|
|
125
|
+
base: { alias: 'test:planning', path: '/planning' },
|
|
126
|
+
guards: [],
|
|
127
|
+
definitions: true,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
export const resourceProtocols = makePlanningProtocols({ base: { alias: 'test:planning', path: '/planning' }, guards: [], definitions: true, resources: true })
|
|
131
|
+
|
|
121
132
|
const AUTH = { profileId: PROFILE_ID, userId: 'user-1', entityId: 'acme' } as unknown as Auth
|
|
122
133
|
const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
|
|
123
134
|
|
|
@@ -125,19 +136,28 @@ const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
|
|
|
125
136
|
export interface Call {
|
|
126
137
|
alias: string
|
|
127
138
|
query: Record<string, unknown>
|
|
139
|
+
reply?: unknown
|
|
140
|
+
signal?: AbortSignal
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas'>> {
|
|
143
|
+
export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas' | 'scopeKey'>> {
|
|
131
144
|
/** The server folds inside `execute` (default) or only on `store.flush()`. */
|
|
145
|
+
resources?: boolean
|
|
132
146
|
sync?: boolean
|
|
133
147
|
/** Give the client a commit socket. */
|
|
134
148
|
socket?: boolean
|
|
135
149
|
/** Register the state mirror on the client. */
|
|
136
150
|
stores?: boolean
|
|
137
151
|
/** The entity the transport authenticates as. */
|
|
138
|
-
entityId?: string
|
|
152
|
+
entityId?: string | (() => string)
|
|
153
|
+
/** Mount the tree with `definitions: true` over a store holding data-defined schemas. */
|
|
154
|
+
definitions?: boolean
|
|
139
155
|
/** Runs after the server answered a call and before the client sees the answer. */
|
|
140
156
|
after?: (call: Call) => Promise<void>
|
|
157
|
+
/** A barrier at the actual transport boundary, before the real server handler runs. */
|
|
158
|
+
before?: (request: AbstractRequest) => Promise<void>
|
|
159
|
+
/** A barrier after the real socket handler connected but before its opener returns. */
|
|
160
|
+
opening?: (connection: Connection, attempt: number) => Promise<void>
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
export type ClientCtx = ClientContext<ClientConfig> & WithPlanningClient & Partial<WithPlanningStores>
|
|
@@ -151,6 +171,7 @@ export interface Suite {
|
|
|
151
171
|
/** The client's facade. */
|
|
152
172
|
planning: PlanningFacade
|
|
153
173
|
calls: Call[]
|
|
174
|
+
frames: unknown[]
|
|
154
175
|
sockets: () => number
|
|
155
176
|
project: (title?: string) => Promise<Project>
|
|
156
177
|
story: (project: string, title?: string, fields?: Record<string, unknown>) => Promise<Workcard>
|
|
@@ -161,7 +182,7 @@ const wire = <T>(value: T): T => value === undefined ? value : JSON.parse(JSON.s
|
|
|
161
182
|
type Handled = { handle?: EntrypointHandler }
|
|
162
183
|
|
|
163
184
|
/** A WebSocket-shaped pipe for the server handler, wired to a client connection reading its frames. */
|
|
164
|
-
const socketPair = (): { server: unknown, client: Connection } => {
|
|
185
|
+
const socketPair = (frames: unknown[]): { server: unknown, client: Connection } => {
|
|
165
186
|
const handlers = new Map<string, Set<(...args: unknown[]) => unknown>>()
|
|
166
187
|
const emit = async (event: string, ...args: unknown[]): Promise<void> => {
|
|
167
188
|
for (const handler of [...(handlers.get(event) ?? [])]) {
|
|
@@ -170,7 +191,7 @@ const socketPair = (): { server: unknown, client: Connection } => {
|
|
|
170
191
|
}
|
|
171
192
|
const client = createBasicConnection()
|
|
172
193
|
const server = {
|
|
173
|
-
send: async (data: string) => { await client.receive(data) },
|
|
194
|
+
send: async (data: string) => { frames.push(JSON.parse(data)); await client.receive(data) },
|
|
174
195
|
close: async () => { await emit('close', 1000) },
|
|
175
196
|
on: (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
176
197
|
handlers.set(event, (handlers.get(event) ?? new Set()).add(handler))
|
|
@@ -188,17 +209,23 @@ const socketPair = (): { server: unknown, client: Connection } => {
|
|
|
188
209
|
}
|
|
189
210
|
|
|
190
211
|
export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
191
|
-
const
|
|
212
|
+
const entityKey = () => typeof opts.entityId === 'function' ? opts.entityId() : opts.entityId ?? ENTITY_ID
|
|
213
|
+
const entityId = entityKey()
|
|
192
214
|
|
|
193
215
|
const server = makeBasicContext<BasicConfig>({
|
|
194
216
|
ready: false, service: 'client-planning-tests-server', type: AppType.Backend,
|
|
195
217
|
} as BasicConfig)
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
server
|
|
218
|
+
const tree = opts.resources === true ? resourceProtocols : opts.definitions === true ? definitionProtocols : protocols
|
|
219
|
+
const store = makeMemoryPlanningStore({ sync: opts.sync !== false, schemas: opts.definitions === true || opts.resources === true })
|
|
220
|
+
appendPlanningService(server, { store, schemas: { types, flows, assigneeTypes } })
|
|
221
|
+
let author: string | undefined
|
|
222
|
+
server.registerEntrypoints(servePlanningEntrypoints(tree, { scope: () => ({ assigneeId: author }) }))
|
|
199
223
|
await server.configure().init()
|
|
200
224
|
|
|
225
|
+
if (opts.resources) author = (await (server as unknown as { planning: () => PlanningService }).planning().for({ entityId }).assignees.create({ nickname: 'commenter', type: 'test:participant', kind: AssigneeKind.Human, authentication: { provider: 'tests', externalId: PROFILE_ID } })).id
|
|
226
|
+
|
|
201
227
|
const calls: Call[] = []
|
|
228
|
+
const frames: unknown[] = []
|
|
202
229
|
let opened = 0
|
|
203
230
|
|
|
204
231
|
const requestOf = (req: Partial<AbstractRequest>, body?: unknown): AbstractRequest => ({
|
|
@@ -209,7 +236,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
209
236
|
headers: {},
|
|
210
237
|
path: req.path ?? '',
|
|
211
238
|
auth: AUTH,
|
|
212
|
-
entity: entityOf(
|
|
239
|
+
entity: entityOf(entityKey()),
|
|
213
240
|
})
|
|
214
241
|
|
|
215
242
|
const client = makeClientContext({
|
|
@@ -225,13 +252,14 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
225
252
|
}
|
|
226
253
|
}
|
|
227
254
|
const request = requestOf(req, wire(req.body))
|
|
255
|
+
await opts.before?.(req)
|
|
228
256
|
const reply = provideResponse<unknown>()
|
|
229
257
|
try {
|
|
230
258
|
await server.entrypoint<BasicEntrypoint & Handled>(req.alias).handle!(request, reply)
|
|
231
259
|
} catch (e) {
|
|
232
260
|
reply.reject(e as Error)
|
|
233
261
|
}
|
|
234
|
-
const call = { alias: req.alias, query: request.query as Record<string, unknown
|
|
262
|
+
const call = { alias: req.alias, query: request.query as Record<string, unknown>, reply: wire(reply.value), signal: req.signal }
|
|
235
263
|
calls.push(call)
|
|
236
264
|
await opts.after?.(call)
|
|
237
265
|
if (reply.error != null) {
|
|
@@ -245,17 +273,19 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
245
273
|
}))
|
|
246
274
|
|
|
247
275
|
appendPlanningClient(client, {
|
|
248
|
-
protocols,
|
|
276
|
+
protocols: tree,
|
|
277
|
+
scopeKey: opts.scopeKey,
|
|
249
278
|
poll: opts.poll ?? 2,
|
|
250
279
|
schemas: opts.schemas,
|
|
251
280
|
...(opts.socket === true
|
|
252
281
|
? {
|
|
253
282
|
socket: async (protocol, request) => {
|
|
254
|
-
const pair = socketPair()
|
|
283
|
+
const pair = socketPair(frames)
|
|
255
284
|
await server.entrypoint<BasicEntrypoint & Handled>(protocol.alias).handle!(
|
|
256
285
|
requestOf({ alias: protocol.alias, query: request?.query ?? {} }, pair.server), provideResponse()
|
|
257
286
|
)
|
|
258
287
|
opened += 1
|
|
288
|
+
await opts.opening?.(pair.client, opened)
|
|
259
289
|
return pair.client
|
|
260
290
|
},
|
|
261
291
|
}
|
|
@@ -276,7 +306,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
276
306
|
}
|
|
277
307
|
|
|
278
308
|
const service = (server as unknown as { planning: () => PlanningService }).planning()
|
|
279
|
-
const local = service.for({ entityId, profileId: PROFILE_ID })
|
|
309
|
+
const local = service.for({ entityId, profileId: PROFILE_ID, assigneeId: author })
|
|
280
310
|
const planning = client.planning().for()
|
|
281
311
|
|
|
282
312
|
return {
|
|
@@ -286,6 +316,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
286
316
|
client,
|
|
287
317
|
planning,
|
|
288
318
|
calls,
|
|
319
|
+
frames,
|
|
289
320
|
sockets: () => opened,
|
|
290
321
|
project: async (title = 'Board') => await committed(local.execute({
|
|
291
322
|
card: { kind: WorkcardKind.Project, type: PROJECT, title },
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { appSchemaHelper, makeSchemaRegistry } from '@owlmeans/planning'
|
|
3
|
+
import type { PlanningAppDeclarations, PlanningService } from '@owlmeans/planning'
|
|
4
|
+
import { makePlanningFieldValidator } from '../src/index.js'
|
|
5
|
+
import { makeSuite, STORY } from './context.js'
|
|
6
|
+
import type { Suite, SuiteOptions } from './context.js'
|
|
7
|
+
|
|
8
|
+
/** What a browser says when a page's `script-src` lacks `'unsafe-eval'`. */
|
|
9
|
+
const REFUSAL = "Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script"
|
|
10
|
+
|
|
11
|
+
/** The work-management seed's declarations: every type and flow may carry a description. */
|
|
12
|
+
const APP: PlanningAppDeclarations = {
|
|
13
|
+
type: { properties: { description: { type: 'string', maxLength: 2000 } } },
|
|
14
|
+
flow: { properties: { description: { type: 'string', maxLength: 2000 } } },
|
|
15
|
+
}
|
|
16
|
+
const SERVED = appSchemaHelper.merge([APP])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The CLIENT runs in a browser page under the publisher's policy — a `document`, and every code
|
|
20
|
+
* generation refused — while the in-process server it calls does not: the suite's transport leaves
|
|
21
|
+
* the page before the server handler runs (`before`) and re-enters it once the server answered
|
|
22
|
+
* (`after`). Armed only by a test, so the suite's own set-up runs outside it.
|
|
23
|
+
*/
|
|
24
|
+
const makeStrictPage = () => {
|
|
25
|
+
const scope = globalThis as unknown as Record<string, unknown>
|
|
26
|
+
const saved = { Function: scope.Function as FunctionConstructor, eval: scope.eval as typeof eval, error: console.error }
|
|
27
|
+
const counts = { generated: 0, logged: 0 }
|
|
28
|
+
const refuse = (): never => {
|
|
29
|
+
counts.generated++
|
|
30
|
+
throw new EvalError(REFUSAL)
|
|
31
|
+
}
|
|
32
|
+
const refusing = { Function: new Proxy(saved.Function, { construct: refuse, apply: refuse }), eval: new Proxy(saved.eval, { apply: refuse }) }
|
|
33
|
+
let armed = false
|
|
34
|
+
|
|
35
|
+
const enter = (): void => {
|
|
36
|
+
scope.document = {}
|
|
37
|
+
scope.Function = refusing.Function
|
|
38
|
+
scope.eval = refusing.eval
|
|
39
|
+
console.error = () => { counts.logged++ }
|
|
40
|
+
}
|
|
41
|
+
const leave = (): void => {
|
|
42
|
+
delete scope.document
|
|
43
|
+
scope.Function = saved.Function
|
|
44
|
+
scope.eval = saved.eval
|
|
45
|
+
console.error = saved.error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
counts,
|
|
50
|
+
arm: () => { armed = true; counts.generated = 0; counts.logged = 0; enter() },
|
|
51
|
+
disarm: () => { armed = false; leave() },
|
|
52
|
+
hooks: {
|
|
53
|
+
before: async () => { if (armed) leave() },
|
|
54
|
+
after: async () => { if (armed) enter() },
|
|
55
|
+
} satisfies Pick<SuiteOptions, 'before' | 'after'>,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const page = makeStrictPage()
|
|
60
|
+
|
|
61
|
+
/** A seed-like target: the application's declarations registered on the server, as its plugin does. */
|
|
62
|
+
const declared = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
63
|
+
const suite = await makeSuite({ schemas: false, ...opts, ...page.hooks })
|
|
64
|
+
;(suite.server as unknown as { planning: () => PlanningService }).planning().use({ name: 'seed', schemas: { app: APP } })
|
|
65
|
+
|
|
66
|
+
return suite
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => page.disarm())
|
|
70
|
+
|
|
71
|
+
describe('@owlmeans/client-planning — a page under a strict script-src', () => {
|
|
72
|
+
test('the served bundle loads, a model resolves and a form validates without generating code', async () => {
|
|
73
|
+
const suite = await declared()
|
|
74
|
+
const project = await suite.project('Spring')
|
|
75
|
+
const story = await suite.story(project.id!, 'Warm-up', { area: 'stage' })
|
|
76
|
+
const service = suite.client.planning()
|
|
77
|
+
|
|
78
|
+
page.arm()
|
|
79
|
+
const registry = await service.loadSchemas()
|
|
80
|
+
expect(registry.app()).toEqual(SERVED!)
|
|
81
|
+
const model = await suite.planning.model(story.id!)
|
|
82
|
+
expect(model.available().map(rule => rule.name)).toContain('start')
|
|
83
|
+
const form = makePlanningFieldValidator(registry.fieldsSchema(STORY))
|
|
84
|
+
expect(form.validateFields({ area: 'stage' }).valid).toBe(true)
|
|
85
|
+
expect(form.validateFields({ area: 2 }).valid).toBe(false)
|
|
86
|
+
page.disarm()
|
|
87
|
+
|
|
88
|
+
expect(page.counts).toEqual({ generated: 0, logged: 0 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('an organization layer and a project layer resolve through definitions without generating code', async () => {
|
|
92
|
+
const suite = await declared({ definitions: true })
|
|
93
|
+
const project = await suite.project('Spring')
|
|
94
|
+
const story = await suite.story(project.id!, 'Warm-up', { area: 'stage' })
|
|
95
|
+
const definitions = suite.planning.definitions!
|
|
96
|
+
|
|
97
|
+
page.arm()
|
|
98
|
+
const layer = await definitions.registry()
|
|
99
|
+
expect(layer.app()).toEqual(SERVED!)
|
|
100
|
+
expect((await definitions.bundle()).app).toEqual(SERVED!)
|
|
101
|
+
expect((await definitions.registry(project.id!)).app()).toEqual(SERVED!)
|
|
102
|
+
const model = await suite.planning.model(story.id!)
|
|
103
|
+
expect(model.schema().type).toBe(STORY)
|
|
104
|
+
page.disarm()
|
|
105
|
+
|
|
106
|
+
expect(page.counts).toEqual({ generated: 0, logged: 0 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('the page really refuses code generation: a native validator cannot be compiled in it', async () => {
|
|
110
|
+
const suite = await declared()
|
|
111
|
+
const bundle = (await suite.client.planning().loadSchemas()).bundle()
|
|
112
|
+
|
|
113
|
+
page.arm()
|
|
114
|
+
expect(() => makeSchemaRegistry(bundle).validator(STORY)).toThrow(EvalError)
|
|
115
|
+
page.disarm()
|
|
116
|
+
|
|
117
|
+
expect(page.counts.generated).toBeGreaterThan(0)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
PlanningSchemaKind, PlanningUnsupported, SchemaOrigin, TransitionAction, WorkcardKind,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type { WorkcardTypeSchema } from '@owlmeans/planning'
|
|
6
|
+
import { definitionProtocols, makeSuite, STORY_FLOW } from './context.js'
|
|
7
|
+
|
|
8
|
+
/** A choir's rehearsal card, defined as data for one project. */
|
|
9
|
+
const REHEARSAL: WorkcardTypeSchema = {
|
|
10
|
+
type: 'test:rehearsal', kind: WorkcardKind.Card, version: 1, fields: { type: 'object' }, flows: [STORY_FLOW], specifications: [],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('@owlmeans/client-planning — data-defined schemas', () => {
|
|
14
|
+
test('a layer is read once per project and read again after any write', async () => {
|
|
15
|
+
const suite = await makeSuite({ definitions: true })
|
|
16
|
+
const project = await suite.project('Spring concert')
|
|
17
|
+
const definitions = suite.planning.definitions!
|
|
18
|
+
const reads = () => suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).length
|
|
19
|
+
|
|
20
|
+
const written = await definitions.define({ types: [REHEARSAL] }, { project: project.id })
|
|
21
|
+
expect(written.map(record => [record.kind, record.key, record.version, record.project]))
|
|
22
|
+
.toEqual([[PlanningSchemaKind.Type, REHEARSAL.type, 1, project.id]])
|
|
23
|
+
|
|
24
|
+
const before = reads()
|
|
25
|
+
const layer = await definitions.registry(project.id)
|
|
26
|
+
await definitions.registry(project.id)
|
|
27
|
+
expect(reads()).toBe(before + 1)
|
|
28
|
+
expect(layer.originOf(PlanningSchemaKind.Type, REHEARSAL.type)).toBe(SchemaOrigin.Project)
|
|
29
|
+
expect((await definitions.bundle()).types.some(type => type.type === REHEARSAL.type)).toBe(false)
|
|
30
|
+
|
|
31
|
+
await definitions.putType({ ...REHEARSAL, version: 2, label: 'Rehearsal' }, { project: project.id })
|
|
32
|
+
expect((await definitions.registry(project.id)).type(REHEARSAL.type).label).toBe('Rehearsal')
|
|
33
|
+
expect(reads()).toBe(before + 3)
|
|
34
|
+
await expect(definitions.records()).rejects.toBeInstanceOf(PlanningUnsupported)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a model of a card resolves its type in its own project\'s layer', async () => {
|
|
38
|
+
const suite = await makeSuite({ definitions: true })
|
|
39
|
+
const project = await suite.project('Autumn concert')
|
|
40
|
+
await suite.planning.definitions!.define({ types: [REHEARSAL] }, { project: project.id })
|
|
41
|
+
const card = (await suite.local.execute({
|
|
42
|
+
card: { kind: WorkcardKind.Card, type: REHEARSAL.type, parent: project.id!, title: 'Sectionals' },
|
|
43
|
+
action: TransitionAction.Create,
|
|
44
|
+
}, { wait: true })).card!
|
|
45
|
+
|
|
46
|
+
const model = await suite.planning.model(card.id!)
|
|
47
|
+
expect(model.schema().type).toBe(REHEARSAL.type)
|
|
48
|
+
expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a nested card model resolves its data-defined schema through the primary ancestor chain', async () => {
|
|
52
|
+
const suite = await makeSuite({ definitions: true })
|
|
53
|
+
const project = await suite.project('Nested rehearsals')
|
|
54
|
+
await suite.planning.definitions!.define({ types: [{ ...REHEARSAL, children: { types: [REHEARSAL.type] } }] }, { project: project.id })
|
|
55
|
+
const create = async (parent: string) => (await suite.local.execute({ action: TransitionAction.Create,
|
|
56
|
+
card: { kind: WorkcardKind.Card, type: REHEARSAL.type, title: 'Section', parent },
|
|
57
|
+
}, { wait: true })).card!
|
|
58
|
+
const parent = await create(project.id!)
|
|
59
|
+
const child = await create(parent.id!)
|
|
60
|
+
const grandchild = await create(child.id!)
|
|
61
|
+
const model = await suite.planning.model(grandchild.id!)
|
|
62
|
+
expect(model.schema().type).toBe(REHEARSAL.type)
|
|
63
|
+
expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
|
|
64
|
+
const layers = suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).map(call => call.query?.project)
|
|
65
|
+
expect(layers).not.toContain(parent.id)
|
|
66
|
+
expect(layers).not.toContain(child.id)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('a tree declared without definitions has none', async () => {
|
|
70
|
+
const suite = await makeSuite()
|
|
71
|
+
expect(suite.planning.definitions).toBeUndefined()
|
|
72
|
+
expect(suite.client.planning().definitions).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { JSON_SCHEMA_DRAFT_07 } from '../src/validation/consts.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/client-planning — the draft-07 meta-schema', () => {
|
|
7
|
+
test('is ajv\'s own refs file, kept as data rather than imported as JSON', () => {
|
|
8
|
+
const file = createRequire(import.meta.url).resolve('ajv/dist/refs/json-schema-draft-07.json')
|
|
9
|
+
|
|
10
|
+
expect(JSON_SCHEMA_DRAFT_07).toEqual(JSON.parse(readFileSync(file, 'utf8')))
|
|
11
|
+
expect(readFileSync(new URL('../src/validation.ts', import.meta.url), 'utf8')).not.toContain('.json\'')
|
|
12
|
+
})
|
|
13
|
+
})
|
package/tests/events.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
3
|
import type { CommitEvent, Transition, Workcard } from '@owlmeans/planning'
|
|
4
|
-
import {
|
|
4
|
+
import { planningMirrorOf, syncHelper } from '../src/index.js'
|
|
5
5
|
import { STORY, makeSuite } from './context.js'
|
|
6
6
|
|
|
7
7
|
/** The frame a bus publishes for a transition — ids only unless a record is given. */
|
|
@@ -23,21 +23,23 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
23
23
|
test('a committed frame without a record writes the card re-read through the facade', async () => {
|
|
24
24
|
const suite = await makeSuite()
|
|
25
25
|
const stores = suite.client.planningStores!()
|
|
26
|
+
const mirror = planningMirrorOf(stores)
|
|
26
27
|
const project = await suite.project()
|
|
27
28
|
const receipt = await suite.local.execute({
|
|
28
29
|
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Folded' },
|
|
29
30
|
action: TransitionAction.Create,
|
|
30
31
|
}, { wait: true })
|
|
31
32
|
|
|
32
|
-
await applyCommitEvent(
|
|
33
|
+
await mirror.applyCommitEvent(frameOf(receipt.transition), suite.planning)
|
|
33
34
|
|
|
34
|
-
expect(await stores.cards.get(receipt.transition.card)).toEqual(receipt.card
|
|
35
|
+
expect(await stores.cards.get(receipt.transition.card)).toEqual({ ...receipt.card!, entityId: '' })
|
|
35
36
|
expect((await stores.commits.get(receipt.transition.id!)).state).toBe(CommitState.Committed)
|
|
36
37
|
})
|
|
37
38
|
|
|
38
39
|
test('a committed delete removes the row and every link touching it', async () => {
|
|
39
40
|
const suite = await makeSuite()
|
|
40
41
|
const stores = suite.client.planningStores!()
|
|
42
|
+
const mirror = planningMirrorOf(stores)
|
|
41
43
|
const project = await suite.project()
|
|
42
44
|
const first = await suite.story(project.id!, 'First')
|
|
43
45
|
const second = (await suite.local.execute({
|
|
@@ -45,13 +47,13 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
45
47
|
action: TransitionAction.Create,
|
|
46
48
|
links: [{ type: 'follows', to: first.id! }],
|
|
47
49
|
}, { wait: true })).card!
|
|
48
|
-
await applyCards(
|
|
50
|
+
await mirror.applyCards([first, second])
|
|
49
51
|
const links = await suite.planning.relationships.list({ from: second.id })
|
|
50
|
-
await syncLinks(stores.links, links.items)
|
|
52
|
+
await syncHelper.syncLinks(stores.links, links.items)
|
|
51
53
|
expect(await stores.links.count()).toBe(1)
|
|
52
54
|
|
|
53
55
|
const removal = await suite.local.execute({ card: first.id!, action: TransitionAction.Delete }, { wait: true })
|
|
54
|
-
await applyCommitEvent(
|
|
56
|
+
await mirror.applyCommitEvent(frameOf(removal.transition, null), suite.planning)
|
|
55
57
|
|
|
56
58
|
expect(await stores.cards.load(first.id!)).toBeNull()
|
|
57
59
|
expect(await stores.cards.load(second.id!)).not.toBeNull()
|
|
@@ -61,6 +63,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
61
63
|
test('a frame for an id the store never saw still writes a row', async () => {
|
|
62
64
|
const suite = await makeSuite()
|
|
63
65
|
const stores = suite.client.planningStores!()
|
|
66
|
+
const mirror = planningMirrorOf(stores)
|
|
64
67
|
const project = await suite.project()
|
|
65
68
|
const receipt = await suite.local.execute({
|
|
66
69
|
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'From another tab' },
|
|
@@ -68,7 +71,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
68
71
|
}, { wait: true })
|
|
69
72
|
expect(await stores.cards.count()).toBe(0)
|
|
70
73
|
|
|
71
|
-
await applyCommitEvent(
|
|
74
|
+
await mirror.applyCommitEvent(frameOf(receipt.transition, receipt.card))
|
|
72
75
|
|
|
73
76
|
expect((await stores.cards.get(receipt.transition.card)).title).toBe('From another tab')
|
|
74
77
|
})
|
|
@@ -76,6 +79,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
76
79
|
test('an older seq never overwrites a newer record', async () => {
|
|
77
80
|
const suite = await makeSuite()
|
|
78
81
|
const stores = suite.client.planningStores!()
|
|
82
|
+
const mirror = planningMirrorOf(stores)
|
|
79
83
|
const project = await suite.project()
|
|
80
84
|
const created = await suite.local.execute({
|
|
81
85
|
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Racing' },
|
|
@@ -84,9 +88,9 @@ describe('@owlmeans/client-planning — commit folds', () => {
|
|
|
84
88
|
const started = await suite.local.execute({
|
|
85
89
|
card: created.transition.card, action: TransitionAction.Transit, transition: 'start',
|
|
86
90
|
}, { wait: true })
|
|
87
|
-
await applyCards(
|
|
91
|
+
await mirror.applyCards([started.card!])
|
|
88
92
|
|
|
89
|
-
await applyCommitEvent(
|
|
93
|
+
await mirror.applyCommitEvent(frameOf(created.transition, created.card))
|
|
90
94
|
|
|
91
95
|
const stored = await stores.cards.get(created.transition.card)
|
|
92
96
|
expect(stored.seq).toBe(2)
|
package/tests/facade.spec.ts
CHANGED
|
@@ -19,7 +19,8 @@ describe('@owlmeans/client-planning — remote facade', () => {
|
|
|
19
19
|
}
|
|
20
20
|
const remote = await suite.planning.cards.list(query)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const native = await suite.local.cards.list(query)
|
|
23
|
+
expect(remote).toEqual({ ...native, items: native.items.map(card => ({ ...card, entityId: '' })) })
|
|
23
24
|
expect(remote.items.map(card => card.title)).toEqual(['Alpha login'])
|
|
24
25
|
expect(await suite.planning.cards.count({ parent: project.id })).toBe(3)
|
|
25
26
|
expect(await suite.planning.cards.summary([project.id!])).toEqual(await suite.local.cards.summary([project.id!]))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
import type { StateResource } from '@owlmeans/state'
|
|
3
|
+
|
|
4
|
+
export interface Barrier {
|
|
5
|
+
entered: Promise<void>
|
|
6
|
+
hold: () => Promise<void>
|
|
7
|
+
release: () => void
|
|
8
|
+
}
|
|
9
|
+
export interface StoreBarrier extends Barrier { restore: () => void }
|
|
10
|
+
|
|
11
|
+
/** Delay a real operation once; later calls continue through the actual transport/store. */
|
|
12
|
+
export const makeBarrier = (): Barrier => {
|
|
13
|
+
let entered!: () => void
|
|
14
|
+
let release!: () => void
|
|
15
|
+
let used = false
|
|
16
|
+
const pending = new Promise<void>(resolve => { release = resolve })
|
|
17
|
+
const arrived = new Promise<void>(resolve => { entered = resolve })
|
|
18
|
+
return { entered: arrived, release, hold: async () => {
|
|
19
|
+
if (used) return
|
|
20
|
+
used = true; entered(); await pending
|
|
21
|
+
} }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const pauseStoreRead = <R extends ResourceRecord>(store: StateResource<R>): StoreBarrier => {
|
|
25
|
+
const barrier = makeBarrier()
|
|
26
|
+
const load = store.load.bind(store)
|
|
27
|
+
store.load = async (...args) => { const row: R | null = await Reflect.apply(load, store, args); await barrier.hold(); return row }
|
|
28
|
+
return { ...barrier, restore: () => { store.load = load } }
|
|
29
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import type { TransitionReceipt } from '@owlmeans/planning'
|
|
4
|
+
import { makeSuite, STORY, tick } from './context.js'
|
|
5
|
+
import { makeBarrier, pauseStoreRead } from './lifecycle/context.js'
|
|
6
|
+
|
|
7
|
+
describe('@owlmeans/client-planning — draining an authenticated close', () => {
|
|
8
|
+
test('drains admitted receipt and auxiliary folds, rejects mid-close RPCs and leaves no late rows', async () => {
|
|
9
|
+
for (const kind of ['card', 'team', 'commit'] as const) {
|
|
10
|
+
const suite = await makeSuite({ schemas: false, resources: true, sync: kind !== 'commit' })
|
|
11
|
+
const stores = suite.client.planningStores!()
|
|
12
|
+
const project = await suite.project()
|
|
13
|
+
let pending: TransitionReceipt | undefined
|
|
14
|
+
if (kind === 'commit') {
|
|
15
|
+
pending = await suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Old commit' } })
|
|
16
|
+
await suite.store.flush()
|
|
17
|
+
}
|
|
18
|
+
const pause = kind === 'team' ? pauseStoreRead(stores.teams) : pauseStoreRead(stores.cards)
|
|
19
|
+
const writing = kind === 'commit' ? pending!.committed() : kind === 'card'
|
|
20
|
+
? suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Old card' } })
|
|
21
|
+
: suite.planning.teams.create({ name: 'Old team', fields: {} })
|
|
22
|
+
void writing.catch(() => {})
|
|
23
|
+
await pause.entered
|
|
24
|
+
let done = false
|
|
25
|
+
const closing = suite.client.planning().close().then(() => { done = true })
|
|
26
|
+
const again = suite.client.planning().close()
|
|
27
|
+
const calls = suite.calls.length
|
|
28
|
+
await expect(suite.planning.teams.create({ name: 'During close', fields: {} })).rejects.toThrow('scope-changed')
|
|
29
|
+
expect(suite.calls.length).toBe(calls)
|
|
30
|
+
await tick()
|
|
31
|
+
expect(done).toBe(false)
|
|
32
|
+
pause.release()
|
|
33
|
+
await Promise.all([closing, again])
|
|
34
|
+
await expect(writing).rejects.toThrow('scope-changed')
|
|
35
|
+
pause.restore()
|
|
36
|
+
for (const store of Object.values(stores)) expect((await store.list({}, { size: 0 })).items).toEqual([])
|
|
37
|
+
const fresh = await suite.planning.teams.create({ name: 'After close', fields: {} })
|
|
38
|
+
expect((await stores.teams.get(fresh.id!)).name).toBe('After close')
|
|
39
|
+
await suite.client.planning().close()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('a failed clear blocks fresh work until an explicit retry clears the actual stores', async () => {
|
|
44
|
+
let identity = 'a'
|
|
45
|
+
const suite = await makeSuite({ resources: true, schemas: false, scopeKey: () => identity })
|
|
46
|
+
const stores = suite.client.planningStores!()
|
|
47
|
+
const old = await suite.planning.teams.create({ name: 'Kept on failure', fields: {} })
|
|
48
|
+
const remove = stores.teams.delete.bind(stores.teams)
|
|
49
|
+
let fail = true
|
|
50
|
+
stores.teams.delete = async (...args) => { if (fail) throw new Error('Storage unavailable'); return await remove(...args) }
|
|
51
|
+
await expect(suite.client.planning().close()).rejects.toThrow('Storage unavailable')
|
|
52
|
+
const calls = suite.calls.length
|
|
53
|
+
await expect(suite.planning.teams.get(old.id!)).rejects.toThrow('scope-changed')
|
|
54
|
+
expect(suite.calls.length).toBe(calls)
|
|
55
|
+
expect((await stores.teams.get(old.id!)).name).toBe('Kept on failure')
|
|
56
|
+
identity = 'b'
|
|
57
|
+
await expect(suite.planning.teams.list()).rejects.toThrow('scope-changed')
|
|
58
|
+
expect(suite.calls.length).toBe(calls)
|
|
59
|
+
fail = false
|
|
60
|
+
await suite.client.planning().close()
|
|
61
|
+
expect(await stores.teams.load(old.id!)).toBeNull()
|
|
62
|
+
expect((await suite.planning.teams.create({ name: 'Recovered', fields: {} })).name).toBe('Recovered')
|
|
63
|
+
stores.teams.delete = remove
|
|
64
|
+
await suite.client.planning().close()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('a cached committed receipt is invalid after close while the server write remains committed', async () => {
|
|
68
|
+
let identity = 'a'
|
|
69
|
+
const suite = await makeSuite({ schemas: false, scopeKey: () => identity })
|
|
70
|
+
const project = await suite.project()
|
|
71
|
+
const receipt = await suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Committed' } })
|
|
72
|
+
await suite.client.planning().close()
|
|
73
|
+
identity = 'b'
|
|
74
|
+
await expect(receipt.committed()).rejects.toThrow('scope-changed')
|
|
75
|
+
expect((await suite.local.cards.get(receipt.card!.id!)).title).toBe('Committed')
|
|
76
|
+
await suite.client.planning().close()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('successive identities each clear after prior admitted writes without accepting the middle generation', async () => {
|
|
80
|
+
let identity = 'organization-a'
|
|
81
|
+
const suite = await makeSuite({ resources: true, schemas: false, scopeKey: () => identity, entityId: () => identity })
|
|
82
|
+
const stores = suite.client.planningStores!()
|
|
83
|
+
await suite.planning.teams.create({ name: 'A', fields: {} })
|
|
84
|
+
const pause = makeBarrier()
|
|
85
|
+
const remove = stores.teams.delete.bind(stores.teams)
|
|
86
|
+
stores.teams.delete = async (...args) => { await pause.hold(); return await remove(...args) }
|
|
87
|
+
identity = 'organization-b'
|
|
88
|
+
suite.client.planning().scopeKey()
|
|
89
|
+
await pause.entered
|
|
90
|
+
const middle = suite.planning.teams.create({ name: 'B', fields: {} })
|
|
91
|
+
void middle.catch(() => {})
|
|
92
|
+
await tick()
|
|
93
|
+
identity = 'organization-c'
|
|
94
|
+
const fresh = suite.planning.teams.create({ name: 'C', fields: {} })
|
|
95
|
+
pause.release()
|
|
96
|
+
await expect(middle).rejects.toThrow('scope-changed')
|
|
97
|
+
const team = await fresh
|
|
98
|
+
expect((await stores.teams.list({}, { size: 0 })).items.map(row => row.id)).toEqual([team.id])
|
|
99
|
+
expect(team.name).toBe('C')
|
|
100
|
+
stores.teams.delete = remove
|
|
101
|
+
await suite.client.planning().close()
|
|
102
|
+
})
|
|
103
|
+
})
|