@ossy/platform 1.39.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +2 -1
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +85 -7
- package/src/audit/action-invocation-service.js +143 -0
- package/src/audit/action-invocation.aggregate.js +57 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +76 -0
- package/src/audit/task-run-service.js +208 -0
- package/src/audit/task-run.aggregate.js +58 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +7 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +11 -0
- package/src/index.js +12 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +91 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +91 -0
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +20 -6
- package/src/server.js +281 -62
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +78 -11
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +61 -17
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative flow runner — single entry for test, guide, seed, and stress modes.
|
|
3
|
+
*
|
|
4
|
+
* User flows are UI-only. The only non-UI read path is the `email` step (dev inbox).
|
|
5
|
+
*
|
|
6
|
+
* import { runFlow, registerInstalledFlows } from '@ossy/platform/test/flow-runner.js'
|
|
7
|
+
* await registerInstalledFlows(import.meta.url)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
13
|
+
import { faker } from '@faker-js/faker'
|
|
14
|
+
import { Router } from '@ossy/router'
|
|
15
|
+
import { Schema } from '@ossy/schema'
|
|
16
|
+
import { test, expect } from '@playwright/test'
|
|
17
|
+
|
|
18
|
+
const FLOW_PATTERN = /\.flow\.(mjs|cjs|js)$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {'test' | 'guide' | 'seed' | 'stress'} FlowMode
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} FlowRunContext
|
|
24
|
+
* @property {Record<string, unknown>} fields Filled form values keyed by field name.
|
|
25
|
+
* @property {string} [email]
|
|
26
|
+
* @property {string} [userId]
|
|
27
|
+
* @property {string} [token]
|
|
28
|
+
* @property {string} [providerSlug]
|
|
29
|
+
* @property {string} [workspaceId]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load `build/manifest.json` from disk.
|
|
34
|
+
* @param {string} manifestPath
|
|
35
|
+
*/
|
|
36
|
+
export function loadManifest (manifestPath) {
|
|
37
|
+
if (!fs.existsSync(manifestPath)) {
|
|
38
|
+
const appRoot = path.dirname(path.dirname(manifestPath))
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Missing ${manifestPath}. Flow tests need a built app manifest — ` +
|
|
41
|
+
`run \`npm run build\` in ${appRoot} first (Playwright does not build for you).`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizePageId (pageRef) {
|
|
48
|
+
if (typeof pageRef !== 'string') return ''
|
|
49
|
+
return pageRef.startsWith('@') ? pageRef.slice(1) : pageRef
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function manifestPages (manifest) {
|
|
53
|
+
return (manifest.entries ?? []).filter(e => e.type === 'page')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildRouter (manifest, locale) {
|
|
57
|
+
const pages = manifestPages(manifest).map(p => ({ id: p.id, path: p.path }))
|
|
58
|
+
const config = manifest.config ?? {}
|
|
59
|
+
return Router.of({
|
|
60
|
+
pages,
|
|
61
|
+
defaultLanguage: locale ?? config.defaultLanguage ?? 'en',
|
|
62
|
+
supportedLanguages: config.supportedLanguages ?? [locale ?? 'en'],
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findSchema (manifest, schemaId) {
|
|
67
|
+
return (manifest.schemas ?? []).find(s => s.id === schemaId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function schemaEngineFromManifest (manifest) {
|
|
71
|
+
return Schema.of(manifest, {
|
|
72
|
+
actionIds: (manifest.actions ?? []).map(a => a.id),
|
|
73
|
+
taskIds: (manifest.tasks ?? []).map(t => t.id),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mock form field values via Schema.mock and update the run context.
|
|
79
|
+
*
|
|
80
|
+
* @param {import('@ossy/schema').Schema} engine
|
|
81
|
+
* @param {{ id: string, fields?: { name: string, type?: string }[] }} template
|
|
82
|
+
* @param {FlowRunContext} context
|
|
83
|
+
*/
|
|
84
|
+
function mockFormContent (engine, template, context) {
|
|
85
|
+
const content = engine.mock(template, { faker })
|
|
86
|
+
for (const field of template.fields ?? []) {
|
|
87
|
+
const value = content[field.name]
|
|
88
|
+
if (value !== undefined) context.fields[field.name] = value
|
|
89
|
+
if (field.type === 'email' || field.name?.toLowerCase?.().includes('email')) {
|
|
90
|
+
context.email = String(value)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return content
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveActionId (action) {
|
|
97
|
+
if (typeof action === 'string') return action
|
|
98
|
+
if (action && typeof action.id === 'string') return action.id
|
|
99
|
+
throw new Error('Flow action step requires an action POJO or id string')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function actionSelector (actionId, service) {
|
|
103
|
+
return service
|
|
104
|
+
? `[data-action="${actionId}"][data-service="${service}"]`
|
|
105
|
+
: `[data-action="${actionId}"]`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveActionService (action, fallback) {
|
|
109
|
+
if (typeof action === 'object' && action.service) return action.service
|
|
110
|
+
return fallback
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve `$token`-style placeholders from the run context. */
|
|
114
|
+
function resolveContextValue (value, context) {
|
|
115
|
+
if (typeof value !== 'string' || !value.startsWith('$')) return value
|
|
116
|
+
const key = value.slice(1)
|
|
117
|
+
return context[key] ?? context.fields?.[key]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveContextObject (obj, context) {
|
|
121
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
122
|
+
const out = {}
|
|
123
|
+
for (const [key, raw] of Object.entries(obj)) {
|
|
124
|
+
out[key] = resolveContextValue(raw, context)
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendSearch (pathname, search, context) {
|
|
130
|
+
if (!search || typeof search !== 'object') return pathname
|
|
131
|
+
const params = new URLSearchParams()
|
|
132
|
+
for (const [key, raw] of Object.entries(search)) {
|
|
133
|
+
const value = resolveContextValue(raw, context)
|
|
134
|
+
if (value != null && value !== '') params.set(key, String(value))
|
|
135
|
+
}
|
|
136
|
+
const qs = params.toString()
|
|
137
|
+
return qs ? `${pathname}?${qs}` : pathname
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function linkNamePattern (click) {
|
|
141
|
+
if (click instanceof RegExp) return click
|
|
142
|
+
return new RegExp(click, 'i')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Execute a declarative flow against a Playwright page.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ title?: string, description?: string, steps?: unknown[], metadata?: object }} flow
|
|
149
|
+
* @param {{
|
|
150
|
+
* mode?: FlowMode,
|
|
151
|
+
* page?: import('@playwright/test').Page,
|
|
152
|
+
* baseURL?: string,
|
|
153
|
+
* manifest?: object,
|
|
154
|
+
* manifestPath?: string,
|
|
155
|
+
* locale?: string,
|
|
156
|
+
* context?: FlowRunContext,
|
|
157
|
+
* expect?: import('@playwright/test').Expect,
|
|
158
|
+
* }} options
|
|
159
|
+
*/
|
|
160
|
+
export async function runFlow (flow, options = {}) {
|
|
161
|
+
const {
|
|
162
|
+
mode = 'test',
|
|
163
|
+
page,
|
|
164
|
+
baseURL = process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002',
|
|
165
|
+
manifest: manifestIn,
|
|
166
|
+
manifestPath,
|
|
167
|
+
locale: localeIn,
|
|
168
|
+
context: contextIn,
|
|
169
|
+
expect: expectFn = expect,
|
|
170
|
+
} = options
|
|
171
|
+
|
|
172
|
+
if (mode !== 'test' && mode !== 'guide' && mode !== 'seed' && mode !== 'stress') {
|
|
173
|
+
throw new Error(`Unknown flow mode "${mode}"`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (mode !== 'test') {
|
|
177
|
+
console.info(`[flow-runner] mode "${mode}" not fully implemented — running test steps`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const manifest = manifestIn ?? loadManifest(manifestPath)
|
|
181
|
+
const locale = localeIn ?? manifest.config?.defaultLanguage ?? 'en'
|
|
182
|
+
const router = buildRouter(manifest, locale)
|
|
183
|
+
const context = contextIn ?? { fields: {} }
|
|
184
|
+
const steps = flow.steps ?? []
|
|
185
|
+
|
|
186
|
+
for (const step of steps) {
|
|
187
|
+
if (!step || typeof step !== 'object') continue
|
|
188
|
+
|
|
189
|
+
if (step.page != null) {
|
|
190
|
+
if (!page) throw new Error('page step requires a Playwright page')
|
|
191
|
+
const pageId = normalizePageId(step.page)
|
|
192
|
+
const params = resolveContextObject(step.params, context)
|
|
193
|
+
const pathname = router.getPathname({ id: pageId, language: locale, params })
|
|
194
|
+
if (!pathname) throw new Error(`Page "${pageId}" not found in manifest`)
|
|
195
|
+
const target = appendSearch(pathname, step.search ?? step.query, context)
|
|
196
|
+
await page.goto(`${baseURL}${target}`)
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (step.form) {
|
|
201
|
+
if (!page) throw new Error('form step requires a Playwright page')
|
|
202
|
+
const formMeta = step.form
|
|
203
|
+
const schemaId = formMeta?.schemaId
|
|
204
|
+
if (!formMeta || typeof formMeta !== 'object' || typeof formMeta.id !== 'string' || typeof schemaId !== 'string') {
|
|
205
|
+
throw new Error('form step requires a FormMetadata POJO with id and schemaId — import metadata from *.form.js')
|
|
206
|
+
}
|
|
207
|
+
const { id: formId } = formMeta
|
|
208
|
+
const template = findSchema(manifest, schemaId)
|
|
209
|
+
if (!template?.fields?.length) {
|
|
210
|
+
throw new Error(`Resource schema "${schemaId}" not found or has no fields`)
|
|
211
|
+
}
|
|
212
|
+
const engine = schemaEngineFromManifest(manifest)
|
|
213
|
+
const content = mockFormContent(engine, template, context)
|
|
214
|
+
const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
|
|
215
|
+
for (const field of template.fields) {
|
|
216
|
+
const value = content[field.name]
|
|
217
|
+
const locator = formRoot.locator(`[name="${field.name}"]`)
|
|
218
|
+
const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
|
|
219
|
+
if (tag === 'select') {
|
|
220
|
+
await locator.selectOption(String(value))
|
|
221
|
+
} else if (tag === 'input') {
|
|
222
|
+
const inputType = await locator.getAttribute('type')
|
|
223
|
+
if (inputType === 'checkbox') {
|
|
224
|
+
if (value) await locator.check()
|
|
225
|
+
} else {
|
|
226
|
+
await locator.fill(String(value))
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
await locator.fill(String(value))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (step.action != null) {
|
|
236
|
+
if (!page) throw new Error('action step requires a Playwright page')
|
|
237
|
+
const actionId = resolveActionId(step.action)
|
|
238
|
+
const service = resolveActionService(step.action, step.service)
|
|
239
|
+
const locator = page.locator(actionSelector(actionId, service)).first()
|
|
240
|
+
const actionTimeout = step.timeout ?? 15000
|
|
241
|
+
await locator.waitFor({ state: 'visible', timeout: actionTimeout })
|
|
242
|
+
await locator.click()
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (step.capture != null) {
|
|
247
|
+
if (!page) throw new Error('capture step requires a Playwright page')
|
|
248
|
+
for (const [key, spec] of Object.entries(step.capture)) {
|
|
249
|
+
if (typeof spec === 'string' && spec.startsWith('$')) {
|
|
250
|
+
context[key] = resolveContextValue(spec, context)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (typeof spec === 'object' && spec.from === 'urlParam') {
|
|
254
|
+
const url = new URL(page.url())
|
|
255
|
+
context[key] = url.searchParams.get(spec.key ?? key)
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
const selector = typeof spec === 'string' ? spec : spec.selector
|
|
259
|
+
const attr = typeof spec === 'object' ? spec.attr : undefined
|
|
260
|
+
const locator = page.locator(selector).first()
|
|
261
|
+
await locator.waitFor({ state: 'attached', timeout: step.timeout ?? 15000 })
|
|
262
|
+
context[key] = attr
|
|
263
|
+
? await locator.getAttribute(attr)
|
|
264
|
+
: await locator.textContent()
|
|
265
|
+
}
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (step.email != null) {
|
|
270
|
+
if (!page) throw new Error('email step requires a Playwright page')
|
|
271
|
+
const emailStep = typeof step.email === 'object' ? step.email : { id: step.email }
|
|
272
|
+
const to = resolveContextValue(emailStep.to ?? '$email', context)
|
|
273
|
+
const templateId = emailStep.id ?? emailStep.templateId
|
|
274
|
+
const clickLabel = emailStep.click ?? emailStep.link
|
|
275
|
+
if (!to) throw new Error('email step requires "to" (recipient email)')
|
|
276
|
+
if (!templateId) throw new Error('email step requires "id" (email template id)')
|
|
277
|
+
if (!clickLabel) throw new Error('email step requires "click" (link text in the email)')
|
|
278
|
+
|
|
279
|
+
const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
|
|
280
|
+
const params = new URLSearchParams({ to: String(to), template: String(templateId) })
|
|
281
|
+
const inboxUrl = `${baseURL}${inboxPath}?${params}`
|
|
282
|
+
const pattern = linkNamePattern(clickLabel)
|
|
283
|
+
const deadline = Date.now() + (emailStep.timeout ?? 20000)
|
|
284
|
+
|
|
285
|
+
let clicked = false
|
|
286
|
+
while (Date.now() < deadline && !clicked) {
|
|
287
|
+
await page.goto(inboxUrl)
|
|
288
|
+
const link = page.getByRole('link', { name: pattern }).first()
|
|
289
|
+
try {
|
|
290
|
+
await link.waitFor({ state: 'visible', timeout: 2000 })
|
|
291
|
+
await Promise.all([
|
|
292
|
+
page.waitForLoadState('domcontentloaded'),
|
|
293
|
+
link.click(),
|
|
294
|
+
])
|
|
295
|
+
clicked = true
|
|
296
|
+
} catch {
|
|
297
|
+
await page.waitForTimeout(500)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!clicked) {
|
|
301
|
+
throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
|
|
302
|
+
}
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (step.result) {
|
|
307
|
+
if (!page) throw new Error('result step requires a Playwright page')
|
|
308
|
+
const { text, url, page: pageRef, action, selector, timeout = 8000 } = step.result
|
|
309
|
+
if (action != null) {
|
|
310
|
+
const actionId = resolveActionId(action)
|
|
311
|
+
const service = resolveActionService(action, step.result.service)
|
|
312
|
+
await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
|
|
313
|
+
}
|
|
314
|
+
if (selector != null) {
|
|
315
|
+
await expectFn(page.locator(selector).first()).toBeVisible({ timeout })
|
|
316
|
+
}
|
|
317
|
+
if (text != null) {
|
|
318
|
+
const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
|
|
319
|
+
await expectFn(page.getByText(pattern).first()).toBeVisible({ timeout })
|
|
320
|
+
}
|
|
321
|
+
if (pageRef != null) {
|
|
322
|
+
const pageId = normalizePageId(pageRef)
|
|
323
|
+
const pathname = router.getPathname({ id: pageId, language: locale })
|
|
324
|
+
if (!pathname) throw new Error(`Result page "${pageId}" not found in manifest`)
|
|
325
|
+
const pathnameBase = pathname.replace(/\/$/, '') || pathname
|
|
326
|
+
const escaped = pathnameBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
327
|
+
await expectFn(page).toHaveURL(new RegExp(`${escaped}\\/?(\\?|#|$)`), { timeout })
|
|
328
|
+
}
|
|
329
|
+
if (url != null) {
|
|
330
|
+
const pattern = url instanceof RegExp ? url : new RegExp(url, 'i')
|
|
331
|
+
await expectFn(page).toHaveURL(pattern, { timeout })
|
|
332
|
+
}
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (step.pickFirst != null) {
|
|
337
|
+
if (!page) throw new Error('pickFirst step requires a Playwright page')
|
|
338
|
+
const kind = typeof step.pickFirst === 'string' ? step.pickFirst : step.pickFirst.kind
|
|
339
|
+
const actionId = {
|
|
340
|
+
service: 'public-booking/pick-service',
|
|
341
|
+
date: 'public-booking/pick-date',
|
|
342
|
+
slot: 'public-booking/pick-slot',
|
|
343
|
+
}[kind]
|
|
344
|
+
if (!actionId) throw new Error(`Unknown pickFirst kind "${kind}"`)
|
|
345
|
+
const locator = page.locator(`${actionSelector(actionId)}:not([disabled])`).first()
|
|
346
|
+
await locator.waitFor({ state: 'visible', timeout: step.timeout ?? 15000 })
|
|
347
|
+
await locator.click()
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw new Error(`Unknown flow step: ${JSON.stringify(step)}`)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return context
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Register a single `*.flow.js` module as a Playwright test.
|
|
359
|
+
*
|
|
360
|
+
* Flows must be self-contained UI journeys — see docs/concepts/TESTING-SPEC.md.
|
|
361
|
+
*
|
|
362
|
+
* @param {{ metadata?: object, default?: object }} mod
|
|
363
|
+
* @param {{ manifestPath?: string, locale?: string }} options
|
|
364
|
+
*/
|
|
365
|
+
export function registerFlow (mod, options = {}) {
|
|
366
|
+
const meta = mod.metadata ?? mod.default?.metadata ?? {}
|
|
367
|
+
const flowBody = mod.default ?? mod
|
|
368
|
+
const id = meta.id ?? flowBody.id
|
|
369
|
+
if (typeof id !== 'string' || !id) throw new Error('flow module is missing metadata.id')
|
|
370
|
+
|
|
371
|
+
const feature = meta.feature ?? id
|
|
372
|
+
const steps = flowBody.steps ?? []
|
|
373
|
+
const title = flowBody.title ?? id
|
|
374
|
+
|
|
375
|
+
test.describe(feature, () => {
|
|
376
|
+
test(title, async ({ page, baseURL }) => {
|
|
377
|
+
const manifestPath = options.manifestPath
|
|
378
|
+
const manifest = manifestPath ? loadManifest(manifestPath) : undefined
|
|
379
|
+
await runFlow({ ...flowBody, steps, metadata: meta }, {
|
|
380
|
+
mode: 'test',
|
|
381
|
+
page,
|
|
382
|
+
baseURL,
|
|
383
|
+
manifest,
|
|
384
|
+
manifestPath,
|
|
385
|
+
locale: options.locale,
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Register all flows listed in `manifest.flows[]`.
|
|
393
|
+
* @param {string} manifestPath Absolute path to build/manifest.json
|
|
394
|
+
*/
|
|
395
|
+
export async function registerFlowsFromManifest (manifestPath) {
|
|
396
|
+
const manifest = loadManifest(manifestPath)
|
|
397
|
+
const buildDir = path.dirname(manifestPath)
|
|
398
|
+
const staticDir = path.join(buildDir, 'public', 'static')
|
|
399
|
+
|
|
400
|
+
for (const entry of manifest.flows ?? []) {
|
|
401
|
+
const chunkName = entry.entry.replace(/^\/static\//, '')
|
|
402
|
+
const chunkPath = path.join(staticDir, chunkName)
|
|
403
|
+
const mod = await import(pathToFileURL(chunkPath).href + `?ts=${Date.now()}`)
|
|
404
|
+
registerFlow(mod, { manifestPath, locale: manifest.config?.defaultLanguage })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function findNodeModules (startDir) {
|
|
409
|
+
let dir = startDir
|
|
410
|
+
for (let i = 0; i < 10; i++) {
|
|
411
|
+
const candidate = path.join(dir, 'node_modules')
|
|
412
|
+
if (fs.existsSync(candidate)) return candidate
|
|
413
|
+
const parent = path.dirname(dir)
|
|
414
|
+
if (parent === dir) break
|
|
415
|
+
dir = parent
|
|
416
|
+
}
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function discoverFlowFiles (nmDir) {
|
|
421
|
+
const results = []
|
|
422
|
+
const walk = (dir, cb) => {
|
|
423
|
+
if (!fs.existsSync(dir)) return
|
|
424
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
425
|
+
const full = path.join(dir, entry.name)
|
|
426
|
+
if (entry.isDirectory()) walk(full, cb)
|
|
427
|
+
else if (entry.isFile()) cb(full)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const tryPackageDir = (pkgDir) => {
|
|
432
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json')
|
|
433
|
+
if (!fs.existsSync(pkgJsonPath)) return
|
|
434
|
+
let pkg
|
|
435
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) } catch { return }
|
|
436
|
+
if (!pkg.ossy?.src) return
|
|
437
|
+
const srcDir = path.resolve(pkgDir, pkg.ossy.src)
|
|
438
|
+
walk(srcDir, filePath => {
|
|
439
|
+
if (FLOW_PATTERN.test(path.basename(filePath))) results.push(filePath)
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const entry of fs.readdirSync(nmDir, { withFileTypes: true })) {
|
|
444
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
445
|
+
if (entry.name.startsWith('@')) {
|
|
446
|
+
const scopeDir = path.join(nmDir, entry.name)
|
|
447
|
+
if (!fs.existsSync(scopeDir)) continue
|
|
448
|
+
for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
449
|
+
if (scoped.isDirectory() || scoped.isSymbolicLink()) {
|
|
450
|
+
tryPackageDir(path.join(scopeDir, scoped.name))
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
tryPackageDir(path.join(nmDir, entry.name))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return results
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Walk installed `@ossy/*` packages and register all `*.flow.js` files.
|
|
463
|
+
* @param {string} fromUrl `import.meta.url` of the calling spec file.
|
|
464
|
+
* @param {{ manifestPath?: string }} options
|
|
465
|
+
*/
|
|
466
|
+
export async function registerInstalledFlows (fromUrl, options = {}) {
|
|
467
|
+
const callerDir = path.dirname(fileURLToPath(fromUrl))
|
|
468
|
+
const nmDir = findNodeModules(callerDir)
|
|
469
|
+
if (!nmDir) return
|
|
470
|
+
|
|
471
|
+
const flowPaths = discoverFlowFiles(nmDir)
|
|
472
|
+
for (const filePath of flowPaths) {
|
|
473
|
+
const mod = await import(filePath)
|
|
474
|
+
registerFlow(mod, options)
|
|
475
|
+
}
|
|
476
|
+
}
|
package/src/test/test.util.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import casual from 'casual'
|
|
2
2
|
import { EventStore } from '@ossy/event-store'
|
|
3
3
|
|
|
4
|
+
const USER_SCHEMA = '@ossy/users/schema/user'
|
|
5
|
+
const TOKEN_SCHEMA = '@ossy/tokens/schema/token'
|
|
6
|
+
|
|
4
7
|
/** Requires Node 18+ (global `fetch`). */
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -130,11 +133,10 @@ export class TestUtil {
|
|
|
130
133
|
return EventStore.FindEvents(query)
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
/** JWT from the latest Verification token aggregate for this user (sign-up or sign-in request). */
|
|
134
136
|
static countVerificationTokenEvents() {
|
|
135
137
|
return EventStore.Collection.countDocuments({
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
type: TOKEN_SCHEMA,
|
|
139
|
+
event: 'Created',
|
|
138
140
|
'payload.type': 'Verification',
|
|
139
141
|
})
|
|
140
142
|
}
|
|
@@ -142,12 +144,12 @@ export class TestUtil {
|
|
|
142
144
|
static async getLatestApiTokenCreatedEventForSubject(subjectId) {
|
|
143
145
|
const ev = await EventStore.Collection.findOne(
|
|
144
146
|
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
type: TOKEN_SCHEMA,
|
|
148
|
+
event: 'Created',
|
|
147
149
|
'payload.type': 'Api',
|
|
148
150
|
'payload.subject': subjectId,
|
|
149
151
|
},
|
|
150
|
-
{ sort: { created: -1 } }
|
|
152
|
+
{ sort: { created: -1 } },
|
|
151
153
|
)
|
|
152
154
|
if (!ev) {
|
|
153
155
|
throw new Error('No Api token Created event for subject')
|
|
@@ -158,12 +160,12 @@ export class TestUtil {
|
|
|
158
160
|
static async getLatestVerificationJwtForSubject(subjectId) {
|
|
159
161
|
const ev = await EventStore.Collection.findOne(
|
|
160
162
|
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
type: TOKEN_SCHEMA,
|
|
164
|
+
event: 'Created',
|
|
163
165
|
'payload.type': 'Verification',
|
|
164
166
|
'payload.subject': subjectId,
|
|
165
167
|
},
|
|
166
|
-
{ sort: { created: -1 } }
|
|
168
|
+
{ sort: { created: -1 } },
|
|
167
169
|
)
|
|
168
170
|
if (!ev?.payload?.token) {
|
|
169
171
|
return Promise.reject(new Error('No verification token found for subject'))
|
|
@@ -175,51 +177,50 @@ export class TestUtil {
|
|
|
175
177
|
const email = `${casual.email}`
|
|
176
178
|
|
|
177
179
|
return TestUtil.InvokeAction({
|
|
178
|
-
actionId: 'authentication/sign-up',
|
|
180
|
+
actionId: '@ossy/authentication/actions/sign-up',
|
|
179
181
|
body: TestUtil.signUpBody({ email }),
|
|
180
182
|
})
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
.then(() => EventStore.FindEvent({
|
|
184
|
+
type: USER_SCHEMA,
|
|
185
|
+
event: 'Created',
|
|
186
|
+
'payload.email': email,
|
|
187
|
+
}))
|
|
188
|
+
.then(event => event.payload.verificationToken)
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
static async GetAuthenticatedTestUser(email = casual.email) {
|
|
190
192
|
|
|
191
193
|
await TestUtil.AssertActionResponse({
|
|
192
|
-
actionId: 'authentication/sign-up',
|
|
194
|
+
actionId: '@ossy/authentication/actions/sign-up',
|
|
193
195
|
body: TestUtil.signUpBody({ email }),
|
|
194
196
|
expectedResponseStatus: 200,
|
|
195
|
-
expectedResponseBody: { ok: true }
|
|
197
|
+
expectedResponseBody: { ok: true },
|
|
196
198
|
})
|
|
197
199
|
|
|
198
200
|
const signedUpEvent = await TestUtil.GetEvent({
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
'payload.email': email
|
|
201
|
+
type: USER_SCHEMA,
|
|
202
|
+
event: 'Created',
|
|
203
|
+
'payload.email': email,
|
|
202
204
|
})
|
|
203
205
|
|
|
204
|
-
const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(signedUpEvent.
|
|
206
|
+
const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(signedUpEvent.resourceId)
|
|
205
207
|
|
|
206
208
|
await fetch(
|
|
207
209
|
`${baseUrl()}/users/verify-sign-in?token=${verificationJwt}`,
|
|
208
|
-
{ method: 'GET' }
|
|
210
|
+
{ method: 'GET' },
|
|
209
211
|
)
|
|
210
212
|
|
|
211
213
|
const signInVerifiedEvent = await TestUtil.GetEvent({
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
type: USER_SCHEMA,
|
|
215
|
+
resourceId: signedUpEvent.resourceId,
|
|
216
|
+
event: 'SignInVerified',
|
|
215
217
|
})
|
|
216
218
|
|
|
217
219
|
return {
|
|
218
|
-
id: signedUpEvent.
|
|
220
|
+
id: signedUpEvent.resourceId,
|
|
219
221
|
token: signInVerifiedEvent.payload.token,
|
|
220
|
-
email
|
|
222
|
+
email,
|
|
221
223
|
}
|
|
222
|
-
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
static CloseDbConnection() {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const USER_SETTINGS_COOKIE = 'x-ossy-user-settings'
|
|
2
|
+
export const AUTH_COOKIE = 'auth'
|
|
3
|
+
|
|
4
|
+
const USER_SETTINGS_MAX_AGE_MS = 2147483647
|
|
5
|
+
const AUTH_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
export function readUserAppSettings (req) {
|
|
8
|
+
return JSON.parse(req.signedCookies?.[USER_SETTINGS_COOKIE] || '{}')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function mergeUserAppSettingsCookie (req, res, partial) {
|
|
12
|
+
const updated = { ...readUserAppSettings(req), ...partial }
|
|
13
|
+
res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(updated), {
|
|
14
|
+
httpOnly: true,
|
|
15
|
+
signed: true,
|
|
16
|
+
expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearWorkspaceFromUserAppSettings (req, res) {
|
|
21
|
+
const settings = readUserAppSettings(req)
|
|
22
|
+
delete settings.workspaceId
|
|
23
|
+
if (Object.keys(settings).length === 0) {
|
|
24
|
+
res.clearCookie(USER_SETTINGS_COOKIE)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(settings), {
|
|
28
|
+
httpOnly: true,
|
|
29
|
+
signed: true,
|
|
30
|
+
expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setAuthCookie (res, token) {
|
|
35
|
+
res.cookie(AUTH_COOKIE, token, {
|
|
36
|
+
httpOnly: true,
|
|
37
|
+
signed: true,
|
|
38
|
+
expires: new Date(Date.now() + AUTH_MAX_AGE_MS),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearAuthCookie (res) {
|
|
43
|
+
res.clearCookie(AUTH_COOKIE)
|
|
44
|
+
}
|