@owlmeans/client-planning 0.1.18-rc.0
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 +128 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/client-planning/SKILL.md +212 -0
- package/build/client.d.ts +26 -0
- package/build/client.d.ts.map +1 -0
- package/build/client.js +120 -0
- package/build/client.js.map +1 -0
- package/build/commits.d.ts +17 -0
- package/build/commits.d.ts.map +1 -0
- package/build/commits.js +205 -0
- package/build/commits.js.map +1 -0
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +29 -0
- package/build/consts.js.map +1 -0
- package/build/events.d.ts +27 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +109 -0
- package/build/events.js.map +1 -0
- package/build/facade.d.ts +19 -0
- package/build/facade.d.ts.map +1 -0
- package/build/facade.js +130 -0
- package/build/facade.js.map +1 -0
- package/build/feed.d.ts +18 -0
- package/build/feed.d.ts.map +1 -0
- package/build/feed.js +123 -0
- package/build/feed.js.map +1 -0
- package/build/helper.d.ts +11 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +10 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/stores.d.ts +28 -0
- package/build/stores.d.ts.map +1 -0
- package/build/stores.js +61 -0
- package/build/stores.js.map +1 -0
- package/build/types.d.ts +147 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/record.d.ts +25 -0
- package/build/utils/record.d.ts.map +1 -0
- package/build/utils/record.js +72 -0
- package/build/utils/record.js.map +1 -0
- package/package.json +55 -0
- package/src/client.ts +153 -0
- package/src/commits.ts +247 -0
- package/src/consts.ts +38 -0
- package/src/events.ts +121 -0
- package/src/facade.ts +169 -0
- package/src/feed.ts +131 -0
- package/src/helper.ts +18 -0
- package/src/index.ts +10 -0
- package/src/stores.ts +81 -0
- package/src/types.ts +163 -0
- package/src/utils/record.ts +91 -0
- package/tests/commits.spec.ts +67 -0
- package/tests/context.ts +301 -0
- package/tests/events.spec.ts +95 -0
- package/tests/facade.spec.ts +83 -0
- package/tests/feed.spec.ts +39 -0
- package/tests/stores.spec.ts +67 -0
- package/tests/tsconfig.json +17 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitState, CommitTimeout, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import { STORY, makeSuite, protocols, tick } from './context.js'
|
|
4
|
+
import type { Suite } from './context.js'
|
|
5
|
+
|
|
6
|
+
const draft = (suite: Suite, parent: string) => suite.planning.execute({
|
|
7
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent, title: 'Waited for' },
|
|
8
|
+
action: TransitionAction.Create,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('@owlmeans/client-planning — waiting for a commit', () => {
|
|
12
|
+
test('wait subscribes before its first read, so a commit landing right after that read is caught', async () => {
|
|
13
|
+
let transition: string | undefined
|
|
14
|
+
let landed = false
|
|
15
|
+
const suite: Suite = await makeSuite({
|
|
16
|
+
sync: false,
|
|
17
|
+
socket: true,
|
|
18
|
+
poll: 20,
|
|
19
|
+
// The server folds the moment it has answered the first status read — after the read, before
|
|
20
|
+
// anything else the client does. Only a subscription made BEFORE the read can hear it.
|
|
21
|
+
after: async call => {
|
|
22
|
+
if (!landed && call.alias === protocols.commit.get.alias && transition != null) {
|
|
23
|
+
landed = true
|
|
24
|
+
await suite.store.flush()
|
|
25
|
+
await tick(10)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
const project = await suite.project()
|
|
30
|
+
const receipt = await draft(suite, project.id!)
|
|
31
|
+
transition = receipt.transition.id
|
|
32
|
+
const before = suite.calls.length
|
|
33
|
+
|
|
34
|
+
const card = await suite.planning.commits.wait(transition!, { timeout: 5_000 })
|
|
35
|
+
|
|
36
|
+
expect(card?.title).toBe('Waited for')
|
|
37
|
+
expect(suite.sockets()).toBe(1)
|
|
38
|
+
// One status read and nothing else: the frame answered, not a long poll.
|
|
39
|
+
expect(suite.calls.slice(before).filter(call => call.alias === protocols.commit.get.alias)).toHaveLength(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('without a socket the long poll alone brings the commit', async () => {
|
|
43
|
+
const suite = await makeSuite({ sync: false, poll: 2 })
|
|
44
|
+
const project = await suite.project()
|
|
45
|
+
const receipt = await draft(suite, project.id!)
|
|
46
|
+
|
|
47
|
+
setTimeout(() => { void suite.store.flush() }, 300)
|
|
48
|
+
const card = await suite.planning.commits.wait(receipt.transition.id!, { timeout: 5_000 })
|
|
49
|
+
|
|
50
|
+
expect(card?.title).toBe('Waited for')
|
|
51
|
+
expect(suite.client.planning().commits.connected()).toBe(false)
|
|
52
|
+
expect(suite.calls.some(call => call.alias === protocols.commit.get.alias && Number(call.query.wait) > 0)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('the deadline throws CommitTimeout and leaves the transition pending', async () => {
|
|
56
|
+
const suite = await makeSuite({ sync: false, poll: 1 })
|
|
57
|
+
const project = await suite.project()
|
|
58
|
+
const receipt = await draft(suite, project.id!)
|
|
59
|
+
|
|
60
|
+
const started = Date.now()
|
|
61
|
+
await expect(suite.planning.commits.wait(receipt.transition.id!, { timeout: 1_200 }))
|
|
62
|
+
.rejects.toBeInstanceOf(CommitTimeout)
|
|
63
|
+
expect(Date.now() - started).toBeLessThan(3_000)
|
|
64
|
+
|
|
65
|
+
expect((await suite.planning.commits.status(receipt.transition.id!)).state).toBe(CommitState.Pending)
|
|
66
|
+
})
|
|
67
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { Auth } from '@owlmeans/auth'
|
|
2
|
+
import { makeClientContext } from '@owlmeans/client-context'
|
|
3
|
+
import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
|
|
4
|
+
import { AppType, createService, makeBasicContext } from '@owlmeans/context'
|
|
5
|
+
import type { BasicConfig, BasicContext, BasicEntrypoint } from '@owlmeans/context'
|
|
6
|
+
import { EntrypointOutcome, provideResponse, transportAlias } from '@owlmeans/entrypoint'
|
|
7
|
+
import type {
|
|
8
|
+
AbstractRequest, AbstractResponse, EntrypointHandler, EntrypointTransport, ResolvedEntity,
|
|
9
|
+
} from '@owlmeans/entrypoint'
|
|
10
|
+
import { ResilientError } from '@owlmeans/error'
|
|
11
|
+
import {
|
|
12
|
+
CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
|
|
13
|
+
WorkcardKind,
|
|
14
|
+
} from '@owlmeans/planning'
|
|
15
|
+
import type {
|
|
16
|
+
AnyTypeSchema, PlanningFacade, PlanningSchemaBundle, PlanningService, Project, StatusFlowSchema,
|
|
17
|
+
TransitionReceipt, Workcard,
|
|
18
|
+
} from '@owlmeans/planning'
|
|
19
|
+
import { RouteProtocols } from '@owlmeans/route'
|
|
20
|
+
import { appendPlanningService, servePlanningEntrypoints } from '@owlmeans/server-planning'
|
|
21
|
+
import { makeMemoryPlanningStore } from '@owlmeans/server-planning/store'
|
|
22
|
+
import type { MemoryPlanningStore } from '@owlmeans/server-planning/store'
|
|
23
|
+
import { createBasicConnection } from '@owlmeans/socket'
|
|
24
|
+
import type { Connection } from '@owlmeans/socket'
|
|
25
|
+
import { appendPlanningClient, appendPlanningStores } from '../src/index.js'
|
|
26
|
+
import type { PlanningClientOptions, WithPlanningClient, WithPlanningStores } from '../src/index.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The whole suite runs a REAL `@owlmeans/server-planning` behind the client, with no network.
|
|
30
|
+
*
|
|
31
|
+
* Two contexts are built, as two processes would build them: a server context with
|
|
32
|
+
* `appendPlanningService` over the memory store and the handlers `servePlanningEntrypoints` binds,
|
|
33
|
+
* and a client context with `appendPlanningClient`. What connects them is the framework's own
|
|
34
|
+
* transport seam — a service under `transportAlias('http')` takes every client call — and it does
|
|
35
|
+
* what the HTTP hop does to a call, no more: the request crosses as JSON, a query value that is not a
|
|
36
|
+
* scalar is refused (axios would write `key[]=` and fastify would read that key literally), the
|
|
37
|
+
* request reaches the SERVER handler with the authenticated entity and profile the HTTP boundary
|
|
38
|
+
* would attach, and a refusal crosses as the marshalled text the API client unmarshals. The commit
|
|
39
|
+
* socket is the server's own `commit.events` handler over an in-memory socket pair: the server side
|
|
40
|
+
* is `@owlmeans/server-socket`'s connection over a WebSocket-shaped pipe, the client side a
|
|
41
|
+
* `@owlmeans/socket` connection reading the same frames.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const ENTITY_ID = 'entity-acme'
|
|
45
|
+
export const OTHER_ENTITY_ID = 'entity-other'
|
|
46
|
+
export const PROFILE_ID = 'profile-1'
|
|
47
|
+
|
|
48
|
+
export const STORY_FLOW = 'test:story-flow'
|
|
49
|
+
export const PROJECT_FLOW = 'test:project-flow'
|
|
50
|
+
export const PROJECT = 'test:project'
|
|
51
|
+
export const STORY = 'test:story'
|
|
52
|
+
export const SPEC = 'test:spec'
|
|
53
|
+
|
|
54
|
+
const flows: StatusFlowSchema[] = [
|
|
55
|
+
{
|
|
56
|
+
id: STORY_FLOW,
|
|
57
|
+
version: 1,
|
|
58
|
+
statuses: [
|
|
59
|
+
{ key: 'todo', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
60
|
+
{ key: 'doing', intrinsic: IntrinsicStatus.InProgress },
|
|
61
|
+
{ key: 'done', intrinsic: IntrinsicStatus.Closed, terminal: true },
|
|
62
|
+
],
|
|
63
|
+
transitions: [
|
|
64
|
+
{ name: 'start', from: ['todo'], to: 'doing', explicit: true },
|
|
65
|
+
{ name: 'finish', from: ['doing'], to: 'done', explicit: true },
|
|
66
|
+
{ name: 'reset', from: '*', to: 'todo' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: PROJECT_FLOW,
|
|
71
|
+
version: 1,
|
|
72
|
+
statuses: [
|
|
73
|
+
{ key: 'open', intrinsic: IntrinsicStatus.InProgress, initial: true },
|
|
74
|
+
{ key: 'archived', intrinsic: IntrinsicStatus.Closed, terminal: true },
|
|
75
|
+
],
|
|
76
|
+
transitions: [{ name: 'archive', from: ['open'], to: 'archived' }],
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
const types: AnyTypeSchema[] = [
|
|
81
|
+
{
|
|
82
|
+
type: PROJECT,
|
|
83
|
+
kind: WorkcardKind.Project,
|
|
84
|
+
version: 1,
|
|
85
|
+
fields: { type: 'object', additionalProperties: true },
|
|
86
|
+
flows: [PROJECT_FLOW],
|
|
87
|
+
specifications: [{ category: 'brief', format: SpecificationFormat.Markdown, revisioned: true, type: SPEC }],
|
|
88
|
+
cardTypes: [STORY],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: STORY,
|
|
92
|
+
kind: WorkcardKind.Card,
|
|
93
|
+
version: 1,
|
|
94
|
+
fields: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { area: { type: 'string' } },
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
},
|
|
99
|
+
flows: [STORY_FLOW],
|
|
100
|
+
specifications: [],
|
|
101
|
+
relationships: [{ name: 'follows' }],
|
|
102
|
+
code: { prefix: 'ST-', style: CodeStyle.Random, length: 5, uppercase: true, uniqueWithin: CodeScope.Parent },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: SPEC,
|
|
106
|
+
kind: WorkcardKind.Specification,
|
|
107
|
+
version: 1,
|
|
108
|
+
fields: { type: 'object', additionalProperties: true },
|
|
109
|
+
flows: [STORY_FLOW],
|
|
110
|
+
specifications: [],
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
export const SCHEMAS: PlanningSchemaBundle = { version: 1, types, flows }
|
|
115
|
+
|
|
116
|
+
export const protocols = makePlanningProtocols({
|
|
117
|
+
base: { alias: 'test:planning', path: '/planning' },
|
|
118
|
+
guards: [],
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const AUTH = { profileId: PROFILE_ID, userId: 'user-1', entityId: 'acme' } as unknown as Auth
|
|
122
|
+
const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
|
|
123
|
+
|
|
124
|
+
/** What crossed the transport, per call — the specs read it to prove which route answered. */
|
|
125
|
+
export interface Call {
|
|
126
|
+
alias: string
|
|
127
|
+
query: Record<string, unknown>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas'>> {
|
|
131
|
+
/** The server folds inside `execute` (default) or only on `store.flush()`. */
|
|
132
|
+
sync?: boolean
|
|
133
|
+
/** Give the client a commit socket. */
|
|
134
|
+
socket?: boolean
|
|
135
|
+
/** Register the state mirror on the client. */
|
|
136
|
+
stores?: boolean
|
|
137
|
+
/** The entity the transport authenticates as. */
|
|
138
|
+
entityId?: string
|
|
139
|
+
/** Runs after the server answered a call and before the client sees the answer. */
|
|
140
|
+
after?: (call: Call) => Promise<void>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type ClientCtx = ClientContext<ClientConfig> & WithPlanningClient & Partial<WithPlanningStores>
|
|
144
|
+
|
|
145
|
+
export interface Suite {
|
|
146
|
+
server: BasicContext<BasicConfig>
|
|
147
|
+
store: MemoryPlanningStore
|
|
148
|
+
/** The server's own facade for the transport's entity — the ground truth a spec compares with. */
|
|
149
|
+
local: PlanningFacade
|
|
150
|
+
client: ClientCtx
|
|
151
|
+
/** The client's facade. */
|
|
152
|
+
planning: PlanningFacade
|
|
153
|
+
calls: Call[]
|
|
154
|
+
sockets: () => number
|
|
155
|
+
project: (title?: string) => Promise<Project>
|
|
156
|
+
story: (project: string, title?: string, fields?: Record<string, unknown>) => Promise<Workcard>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const wire = <T>(value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)) as T
|
|
160
|
+
|
|
161
|
+
type Handled = { handle?: EntrypointHandler }
|
|
162
|
+
|
|
163
|
+
/** A WebSocket-shaped pipe for the server handler, wired to a client connection reading its frames. */
|
|
164
|
+
const socketPair = (): { server: unknown, client: Connection } => {
|
|
165
|
+
const handlers = new Map<string, Set<(...args: unknown[]) => unknown>>()
|
|
166
|
+
const emit = async (event: string, ...args: unknown[]): Promise<void> => {
|
|
167
|
+
for (const handler of [...(handlers.get(event) ?? [])]) {
|
|
168
|
+
await handler(...args)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const client = createBasicConnection()
|
|
172
|
+
const server = {
|
|
173
|
+
send: async (data: string) => { await client.receive(data) },
|
|
174
|
+
close: async () => { await emit('close', 1000) },
|
|
175
|
+
on: (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
176
|
+
handlers.set(event, (handlers.get(event) ?? new Set()).add(handler))
|
|
177
|
+
},
|
|
178
|
+
off: (event: string, handler: (...args: unknown[]) => unknown) => { handlers.get(event)?.delete(handler) },
|
|
179
|
+
ping: () => { },
|
|
180
|
+
pong: () => { },
|
|
181
|
+
}
|
|
182
|
+
client.send = async message => {
|
|
183
|
+
await emit('message', Buffer.from(typeof message === 'string' ? message : JSON.stringify(message)))
|
|
184
|
+
}
|
|
185
|
+
client.close = async () => { await emit('close', 1000) }
|
|
186
|
+
|
|
187
|
+
return { server, client }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
191
|
+
const entityId = opts.entityId ?? ENTITY_ID
|
|
192
|
+
|
|
193
|
+
const server = makeBasicContext<BasicConfig>({
|
|
194
|
+
ready: false, service: 'client-planning-tests-server', type: AppType.Backend,
|
|
195
|
+
} as BasicConfig)
|
|
196
|
+
const store = makeMemoryPlanningStore({ sync: opts.sync !== false })
|
|
197
|
+
appendPlanningService(server, { store, schemas: { types, flows } })
|
|
198
|
+
server.registerEntrypoints(servePlanningEntrypoints(protocols))
|
|
199
|
+
await server.configure().init()
|
|
200
|
+
|
|
201
|
+
const calls: Call[] = []
|
|
202
|
+
let opened = 0
|
|
203
|
+
|
|
204
|
+
const requestOf = (req: Partial<AbstractRequest>, body?: unknown): AbstractRequest => ({
|
|
205
|
+
alias: req.alias ?? '',
|
|
206
|
+
params: wire(req.params ?? {}),
|
|
207
|
+
query: wire(req.query ?? {}),
|
|
208
|
+
body: body as AbstractRequest['body'],
|
|
209
|
+
headers: {},
|
|
210
|
+
path: req.path ?? '',
|
|
211
|
+
auth: AUTH,
|
|
212
|
+
entity: entityOf(entityId),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const client = makeClientContext({
|
|
216
|
+
ready: false, service: 'client-planning-tests', type: AppType.Frontend, layer: undefined, services: {},
|
|
217
|
+
} as unknown as ClientConfig) as ClientCtx
|
|
218
|
+
|
|
219
|
+
client.registerService(createService<EntrypointTransport>(transportAlias(RouteProtocols.WEB), {
|
|
220
|
+
protocol: RouteProtocols.WEB,
|
|
221
|
+
handle: (async (req: AbstractRequest, res: AbstractResponse<unknown>) => {
|
|
222
|
+
for (const [key, value] of Object.entries(req.query ?? {})) {
|
|
223
|
+
if (value != null && typeof value === 'object') {
|
|
224
|
+
throw new SyntaxError(`Query value ${key} does not survive a URL`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const request = requestOf(req, wire(req.body))
|
|
228
|
+
const reply = provideResponse<unknown>()
|
|
229
|
+
try {
|
|
230
|
+
await server.entrypoint<BasicEntrypoint & Handled>(req.alias).handle!(request, reply)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
reply.reject(e as Error)
|
|
233
|
+
}
|
|
234
|
+
const call = { alias: req.alias, query: request.query as Record<string, unknown> }
|
|
235
|
+
calls.push(call)
|
|
236
|
+
await opts.after?.(call)
|
|
237
|
+
if (reply.error != null) {
|
|
238
|
+
// What the server writes as the error body, and what the API client makes of it.
|
|
239
|
+
const text = ResilientError.marshal(ResilientError.ensure(reply.error)).message
|
|
240
|
+
res.reject(ResilientError.ensure(text, true))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
res.resolve(wire(reply.value), reply.outcome ?? EntrypointOutcome.Ok)
|
|
244
|
+
}) as EntrypointHandler,
|
|
245
|
+
}))
|
|
246
|
+
|
|
247
|
+
appendPlanningClient(client, {
|
|
248
|
+
protocols,
|
|
249
|
+
poll: opts.poll ?? 2,
|
|
250
|
+
schemas: opts.schemas,
|
|
251
|
+
...(opts.socket === true
|
|
252
|
+
? {
|
|
253
|
+
socket: async (protocol, request) => {
|
|
254
|
+
const pair = socketPair()
|
|
255
|
+
await server.entrypoint<BasicEntrypoint & Handled>(protocol.alias).handle!(
|
|
256
|
+
requestOf({ alias: protocol.alias, query: request?.query ?? {} }, pair.server), provideResponse()
|
|
257
|
+
)
|
|
258
|
+
opened += 1
|
|
259
|
+
return pair.client
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
})
|
|
264
|
+
if (opts.stores !== false) {
|
|
265
|
+
appendPlanningStores(client)
|
|
266
|
+
}
|
|
267
|
+
await client.configure().init()
|
|
268
|
+
|
|
269
|
+
/** A write the server itself makes, folded whichever way the store folds. */
|
|
270
|
+
const committed = async (pending: Promise<TransitionReceipt>): Promise<Workcard> => {
|
|
271
|
+
const receipt = await pending
|
|
272
|
+
if (opts.sync === false) {
|
|
273
|
+
await store.flush()
|
|
274
|
+
}
|
|
275
|
+
return await receipt.committed() as Workcard
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const service = (server as unknown as { planning: () => PlanningService }).planning()
|
|
279
|
+
const local = service.for({ entityId, profileId: PROFILE_ID })
|
|
280
|
+
const planning = client.planning().for()
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
server,
|
|
284
|
+
store,
|
|
285
|
+
local,
|
|
286
|
+
client,
|
|
287
|
+
planning,
|
|
288
|
+
calls,
|
|
289
|
+
sockets: () => opened,
|
|
290
|
+
project: async (title = 'Board') => await committed(local.execute({
|
|
291
|
+
card: { kind: WorkcardKind.Project, type: PROJECT, title },
|
|
292
|
+
action: TransitionAction.Create,
|
|
293
|
+
})) as Project,
|
|
294
|
+
story: async (project, title = 'A story', fields) => await committed(local.execute({
|
|
295
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project, title, ...(fields != null ? { fields } : {}) },
|
|
296
|
+
action: TransitionAction.Create,
|
|
297
|
+
})),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const tick = async (ms = 0): Promise<void> => await new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import type { CommitEvent, Transition, Workcard } from '@owlmeans/planning'
|
|
4
|
+
import { applyCards, applyCommitEvent, syncLinks } from '../src/index.js'
|
|
5
|
+
import { STORY, makeSuite } from './context.js'
|
|
6
|
+
|
|
7
|
+
/** The frame a bus publishes for a transition — ids only unless a record is given. */
|
|
8
|
+
const frameOf = (transition: Transition, record?: Workcard | null): CommitEvent => ({
|
|
9
|
+
transition: transition.id!,
|
|
10
|
+
card: transition.card,
|
|
11
|
+
entityId: transition.entityId,
|
|
12
|
+
project: transition.project,
|
|
13
|
+
kind: transition.kind,
|
|
14
|
+
type: transition.type,
|
|
15
|
+
seq: transition.seq,
|
|
16
|
+
action: transition.action,
|
|
17
|
+
state: CommitState.Committed,
|
|
18
|
+
at: transition.at,
|
|
19
|
+
...(record !== undefined ? { record } : {}),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('@owlmeans/client-planning — commit folds', () => {
|
|
23
|
+
test('a committed frame without a record writes the card re-read through the facade', async () => {
|
|
24
|
+
const suite = await makeSuite()
|
|
25
|
+
const stores = suite.client.planningStores!()
|
|
26
|
+
const project = await suite.project()
|
|
27
|
+
const receipt = await suite.local.execute({
|
|
28
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Folded' },
|
|
29
|
+
action: TransitionAction.Create,
|
|
30
|
+
}, { wait: true })
|
|
31
|
+
|
|
32
|
+
await applyCommitEvent(stores, frameOf(receipt.transition), suite.planning)
|
|
33
|
+
|
|
34
|
+
expect(await stores.cards.get(receipt.transition.card)).toEqual(receipt.card!)
|
|
35
|
+
expect((await stores.commits.get(receipt.transition.id!)).state).toBe(CommitState.Committed)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('a committed delete removes the row and every link touching it', async () => {
|
|
39
|
+
const suite = await makeSuite()
|
|
40
|
+
const stores = suite.client.planningStores!()
|
|
41
|
+
const project = await suite.project()
|
|
42
|
+
const first = await suite.story(project.id!, 'First')
|
|
43
|
+
const second = (await suite.local.execute({
|
|
44
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Second' },
|
|
45
|
+
action: TransitionAction.Create,
|
|
46
|
+
links: [{ type: 'follows', to: first.id! }],
|
|
47
|
+
}, { wait: true })).card!
|
|
48
|
+
await applyCards(stores, [first, second])
|
|
49
|
+
const links = await suite.planning.relationships.list({ from: second.id })
|
|
50
|
+
await syncLinks(stores.links, links.items)
|
|
51
|
+
expect(await stores.links.count()).toBe(1)
|
|
52
|
+
|
|
53
|
+
const removal = await suite.local.execute({ card: first.id!, action: TransitionAction.Delete }, { wait: true })
|
|
54
|
+
await applyCommitEvent(stores, frameOf(removal.transition, null), suite.planning)
|
|
55
|
+
|
|
56
|
+
expect(await stores.cards.load(first.id!)).toBeNull()
|
|
57
|
+
expect(await stores.cards.load(second.id!)).not.toBeNull()
|
|
58
|
+
expect(await stores.links.count()).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a frame for an id the store never saw still writes a row', async () => {
|
|
62
|
+
const suite = await makeSuite()
|
|
63
|
+
const stores = suite.client.planningStores!()
|
|
64
|
+
const project = await suite.project()
|
|
65
|
+
const receipt = await suite.local.execute({
|
|
66
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'From another tab' },
|
|
67
|
+
action: TransitionAction.Create,
|
|
68
|
+
}, { wait: true })
|
|
69
|
+
expect(await stores.cards.count()).toBe(0)
|
|
70
|
+
|
|
71
|
+
await applyCommitEvent(stores, frameOf(receipt.transition, receipt.card))
|
|
72
|
+
|
|
73
|
+
expect((await stores.cards.get(receipt.transition.card)).title).toBe('From another tab')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('an older seq never overwrites a newer record', async () => {
|
|
77
|
+
const suite = await makeSuite()
|
|
78
|
+
const stores = suite.client.planningStores!()
|
|
79
|
+
const project = await suite.project()
|
|
80
|
+
const created = await suite.local.execute({
|
|
81
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Racing' },
|
|
82
|
+
action: TransitionAction.Create,
|
|
83
|
+
}, { wait: true })
|
|
84
|
+
const started = await suite.local.execute({
|
|
85
|
+
card: created.transition.card, action: TransitionAction.Transit, transition: 'start',
|
|
86
|
+
}, { wait: true })
|
|
87
|
+
await applyCards(stores, [started.card!])
|
|
88
|
+
|
|
89
|
+
await applyCommitEvent(stores, frameOf(created.transition, created.card))
|
|
90
|
+
|
|
91
|
+
const stored = await stores.cards.get(created.transition.card)
|
|
92
|
+
expect(stored.seq).toBe(2)
|
|
93
|
+
expect(stored.status).toBe('doing')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
IllegalTransition, TransitionAction, WorkcardKind, WorkcardNotFound,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type { Specification, WorkcardQuery } from '@owlmeans/planning'
|
|
6
|
+
import { OTHER_ENTITY_ID, PROFILE_ID, PROJECT, STORY, makeSuite } from './context.js'
|
|
7
|
+
|
|
8
|
+
describe('@owlmeans/client-planning — remote facade', () => {
|
|
9
|
+
test('a rich query means on the client exactly what it means on the server', async () => {
|
|
10
|
+
const suite = await makeSuite()
|
|
11
|
+
const project = await suite.project()
|
|
12
|
+
await suite.story(project.id!, 'Alpha login', { area: 'auth' })
|
|
13
|
+
await suite.story(project.id!, 'Beta login', { area: 'billing' })
|
|
14
|
+
await suite.story(project.id!, 'Gamma search', { area: 'auth' })
|
|
15
|
+
|
|
16
|
+
const query: WorkcardQuery = {
|
|
17
|
+
kind: WorkcardKind.Card, type: [STORY], parent: project.id, status: ['todo', 'doing'],
|
|
18
|
+
fields: { area: 'auth' }, q: 'login', sort: ['title'], size: 0,
|
|
19
|
+
}
|
|
20
|
+
const remote = await suite.planning.cards.list(query)
|
|
21
|
+
|
|
22
|
+
expect(remote).toEqual(await suite.local.cards.list(query))
|
|
23
|
+
expect(remote.items.map(card => card.title)).toEqual(['Alpha login'])
|
|
24
|
+
expect(await suite.planning.cards.count({ parent: project.id })).toBe(3)
|
|
25
|
+
expect(await suite.planning.cards.summary([project.id!])).toEqual(await suite.local.cards.summary([project.id!]))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('a server refusal arrives as its own class', async () => {
|
|
29
|
+
const suite = await makeSuite()
|
|
30
|
+
const project = await suite.project()
|
|
31
|
+
const story = await suite.story(project.id!)
|
|
32
|
+
|
|
33
|
+
const illegal = suite.planning.execute({ card: story.id!, action: TransitionAction.Transit, transition: 'finish' })
|
|
34
|
+
await expect(illegal).rejects.toBeInstanceOf(IllegalTransition)
|
|
35
|
+
|
|
36
|
+
await expect(suite.planning.cards.get('missing')).rejects.toBeInstanceOf(WorkcardNotFound)
|
|
37
|
+
expect(await suite.planning.cards.load('missing')).toBeNull()
|
|
38
|
+
|
|
39
|
+
// Another entity's card is answered exactly like a missing one.
|
|
40
|
+
const foreign = await (suite.server as any).planning().for({ entityId: OTHER_ENTITY_ID }).execute({
|
|
41
|
+
card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Theirs' }, action: TransitionAction.Create,
|
|
42
|
+
}, { wait: true })
|
|
43
|
+
await expect(suite.planning.cards.get(foreign.card.id)).rejects.toBeInstanceOf(WorkcardNotFound)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('execute answers a receipt whose committed() resolves with the folded card', async () => {
|
|
47
|
+
const suite = await makeSuite({ sync: false })
|
|
48
|
+
const project = await suite.project()
|
|
49
|
+
|
|
50
|
+
const receipt = await suite.planning.execute({
|
|
51
|
+
card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Remote story' },
|
|
52
|
+
action: TransitionAction.Create,
|
|
53
|
+
// The server decides who wrote; whatever the wire says is ignored.
|
|
54
|
+
actor: { profileId: 'forged' },
|
|
55
|
+
})
|
|
56
|
+
expect(receipt.transition.actor.profileId).toBe(PROFILE_ID)
|
|
57
|
+
|
|
58
|
+
setTimeout(() => { void suite.store.flush() }, 50)
|
|
59
|
+
const card = await receipt.committed({ timeout: 5_000 })
|
|
60
|
+
expect(card?.title).toBe('Remote story')
|
|
61
|
+
expect(card?.code).toMatch(/^ST-/)
|
|
62
|
+
|
|
63
|
+
const model = await suite.planning.model(card!)
|
|
64
|
+
expect(model.can('start')).toBe(true)
|
|
65
|
+
setTimeout(() => { void suite.store.flush() }, 50)
|
|
66
|
+
const started = await model.transit('start', undefined, { wait: true, timeout: 5_000 })
|
|
67
|
+
expect(started.card?.status).toBe('doing')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('a model writes a specification, then revises the same record', async () => {
|
|
71
|
+
const suite = await makeSuite()
|
|
72
|
+
const project = await suite.planning.model(await suite.project())
|
|
73
|
+
|
|
74
|
+
await project.write('brief', '# First', { wait: true })
|
|
75
|
+
await project.write('brief', '# Second', { wait: true })
|
|
76
|
+
|
|
77
|
+
const current = await suite.planning.specifications.current(project.id, 'brief') as Specification
|
|
78
|
+
expect(current.body).toBe('# Second')
|
|
79
|
+
expect(current.revision).toBe(2)
|
|
80
|
+
const history = await suite.planning.specifications.revisions(current.id!)
|
|
81
|
+
expect(history.map(revision => revision.body)).toContain('# First')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makePlanningFeed } from '../src/index.js'
|
|
3
|
+
import { makeSuite, tick } from './context.js'
|
|
4
|
+
|
|
5
|
+
describe('@owlmeans/client-planning — the feed', () => {
|
|
6
|
+
test('seeds the mirror from the list, then folds every commit the socket pushes', async () => {
|
|
7
|
+
const suite = await makeSuite({ socket: true })
|
|
8
|
+
const stores = suite.client.planningStores!()
|
|
9
|
+
const project = await suite.project()
|
|
10
|
+
const first = await suite.story(project.id!, 'Seeded')
|
|
11
|
+
|
|
12
|
+
const feed = makePlanningFeed(suite.client, { query: { parent: project.id }, filter: { project: project.id } })
|
|
13
|
+
await feed.ready
|
|
14
|
+
expect(feed.seeded).toBe(true)
|
|
15
|
+
expect(feed.connected).toBe(true)
|
|
16
|
+
expect((await stores.cards.get(first.id!)).title).toBe('Seeded')
|
|
17
|
+
|
|
18
|
+
const second = await suite.story(project.id!, 'Pushed')
|
|
19
|
+
await tick(20)
|
|
20
|
+
expect((await stores.cards.get(second.id!)).title).toBe('Pushed')
|
|
21
|
+
|
|
22
|
+
await feed.stop()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('stop() ends the folding and leaves the shared socket open', async () => {
|
|
26
|
+
const suite = await makeSuite({ socket: true })
|
|
27
|
+
const stores = suite.client.planningStores!()
|
|
28
|
+
const project = await suite.project()
|
|
29
|
+
|
|
30
|
+
const feed = makePlanningFeed(suite.client, { query: { parent: project.id } })
|
|
31
|
+
await feed.ready
|
|
32
|
+
await feed.stop()
|
|
33
|
+
|
|
34
|
+
const late = await suite.story(project.id!, 'After stop')
|
|
35
|
+
await tick(20)
|
|
36
|
+
expect(await stores.cards.load(late.id!)).toBeNull()
|
|
37
|
+
expect(suite.client.planning().commits.connected()).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { isPending, modelOf, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import { applyCards, syncCards } from '../src/index.js'
|
|
4
|
+
import { makeSuite } from './context.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/client-planning — the state mirror', () => {
|
|
7
|
+
test('syncCards drops only what matches `where` and the list does not name', async () => {
|
|
8
|
+
const suite = await makeSuite()
|
|
9
|
+
const stores = suite.client.planningStores!()
|
|
10
|
+
const one = await suite.project('One')
|
|
11
|
+
const two = await suite.project('Two')
|
|
12
|
+
const kept = await suite.story(one.id!, 'Kept')
|
|
13
|
+
const deleted = await suite.story(one.id!, 'Deleted elsewhere')
|
|
14
|
+
const elsewhere = await suite.story(two.id!, 'Another project')
|
|
15
|
+
await applyCards(stores, [one, two, kept, deleted, elsewhere])
|
|
16
|
+
|
|
17
|
+
await syncCards(stores.cards, [kept], { parent: one.id })
|
|
18
|
+
|
|
19
|
+
const ids = (await stores.cards.list()).items.map(card => card.id).sort()
|
|
20
|
+
expect(ids).toEqual([one.id, two.id, kept.id, elsewhere.id].sort())
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('projects, cards and specifications share one store and one id space', async () => {
|
|
24
|
+
const suite = await makeSuite()
|
|
25
|
+
const stores = suite.client.planningStores!()
|
|
26
|
+
const project = await suite.project()
|
|
27
|
+
await (await suite.planning.model(project)).write('brief', '# Brief', { wait: true })
|
|
28
|
+
await suite.story(project.id!)
|
|
29
|
+
|
|
30
|
+
const [projects, specs, cards] = await Promise.all([
|
|
31
|
+
suite.planning.cards.list({ kind: WorkcardKind.Project }),
|
|
32
|
+
suite.planning.specifications.list(project.id!),
|
|
33
|
+
suite.planning.cards.list({ kind: WorkcardKind.Card, parent: project.id }),
|
|
34
|
+
])
|
|
35
|
+
await syncCards(stores.cards, projects.items, { kind: WorkcardKind.Project })
|
|
36
|
+
await syncCards(stores.cards, specs.items, { kind: WorkcardKind.Specification, parent: project.id })
|
|
37
|
+
// A card list reloading with nothing in it must not take the project or its brief with it.
|
|
38
|
+
await syncCards(stores.cards, [], { kind: WorkcardKind.Card, parent: project.id })
|
|
39
|
+
await syncCards(stores.cards, cards.items, { kind: WorkcardKind.Card, parent: project.id })
|
|
40
|
+
|
|
41
|
+
const kinds = (await stores.cards.list()).items.map(card => card.kind).sort()
|
|
42
|
+
expect(kinds).toEqual([WorkcardKind.Card, WorkcardKind.Project, WorkcardKind.Specification])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a receipt marks the card pending (head > seq) and its commit clears the mark', async () => {
|
|
46
|
+
const suite = await makeSuite({ sync: false })
|
|
47
|
+
const stores = suite.client.planningStores!()
|
|
48
|
+
const project = await suite.project()
|
|
49
|
+
const story = await suite.story(project.id!)
|
|
50
|
+
await applyCards(stores, [story])
|
|
51
|
+
|
|
52
|
+
const receipt = await suite.planning.execute({
|
|
53
|
+
card: story.id!, action: TransitionAction.Transit, transition: 'start', expectSeq: story.seq,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const marked = await stores.cards.get(story.id!)
|
|
57
|
+
expect(isPending(marked)).toBe(true)
|
|
58
|
+
expect(modelOf(marked, suite.planning).pending()).toBe(true)
|
|
59
|
+
|
|
60
|
+
await suite.store.flush()
|
|
61
|
+
await receipt.committed({ timeout: 5_000 })
|
|
62
|
+
|
|
63
|
+
const folded = await stores.cards.get(story.id!)
|
|
64
|
+
expect(isPending(folded)).toBe(false)
|
|
65
|
+
expect(folded.status).toBe('doing')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"types": [
|
|
8
|
+
"bun"
|
|
9
|
+
],
|
|
10
|
+
"rootDir": "../",
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"./**/*",
|
|
15
|
+
"../src/**/*"
|
|
16
|
+
]
|
|
17
|
+
}
|