@ossy/platform 3.9.0 → 3.11.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 +2 -0
- package/package.json +8 -7
- package/src/index.js +3 -0
- package/src/proxy-internal.js +7 -2
- package/src/runtime.js +5 -3
- package/src/select-workspace.api.js +5 -1
- package/src/server.js +14 -3
- package/src/tasks/change-stream.js +11 -45
- package/src/test/flow-action.js +62 -0
- package/src/test/flow-action.spec.js +64 -0
- package/src/test/flow-context.js +20 -0
- package/src/test/flow-context.spec.js +14 -0
- package/src/test/flow-download.js +102 -0
- package/src/test/flow-download.spec.js +60 -0
- package/src/test/flow-files.js +83 -0
- package/src/test/flow-files.spec.js +69 -0
- package/src/test/flow-runner.js +428 -68
- package/src/test/test.util.js +37 -0
- package/src/user-app-settings.js +70 -9
- package/src/verify-sign-in-session.js +24 -0
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ Optional environment variables used by the platform itself:
|
|
|
60
60
|
| Variable | Description |
|
|
61
61
|
|---|---|
|
|
62
62
|
| `DB_URL` | MongoDB connection string. Required for tasks and aggregates. |
|
|
63
|
+
| `MONGO_MAX_POOL_SIZE` | Per-process MongoDB driver pool size (default `50`). Each ECS task shares one client for the event store and change stream — raise only if you see wait-queue latency. |
|
|
64
|
+
| `MONGO_MAX_IDLE_TIME_MS` | Optional. When set to a positive ms value, closes idle pool sockets after that idle time. Unset by default (no idle churn). |
|
|
63
65
|
| `API_URL` + `OSSY_API_KEY` | Optional HTTP bot SDK for tasks. When unset, tasks get an in-process SDK that calls `ActionService` / storage in the same process (local app-test and same-server changestream). |
|
|
64
66
|
| `PORT` | HTTP port. |
|
|
65
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./test/flow-runner.js": "./src/test/flow-runner.js",
|
|
28
28
|
"./test/playwright.config.js": "./src/test/playwright.config.js",
|
|
29
29
|
"./locale": "./src/locale.js",
|
|
30
|
+
"./verify-sign-in-session": "./src/verify-sign-in-session.js",
|
|
30
31
|
"./storage-keys": "./src/storage/storage-keys.js",
|
|
31
32
|
"./mcp": "./src/mcp/mount-platform-mcp.js"
|
|
32
33
|
},
|
|
@@ -45,16 +46,16 @@
|
|
|
45
46
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
46
47
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
48
|
"@ossy/config": "^3.0.9",
|
|
48
|
-
"@ossy/event-store": "^3.
|
|
49
|
+
"@ossy/event-store": "^3.11.1",
|
|
49
50
|
"@ossy/locale": "^3.4.0",
|
|
50
51
|
"@ossy/manifest": "^3.9.0",
|
|
51
52
|
"@ossy/observability": "^3.0.9",
|
|
52
53
|
"@ossy/policies": "^3.0.9",
|
|
53
54
|
"@ossy/schema": "^3.8.0",
|
|
54
|
-
"@ossy/sdk": "^3.
|
|
55
|
-
"@ossy/tokens": "^3.
|
|
56
|
-
"@ossy/users": "^3.
|
|
57
|
-
"@ossy/workspaces": "^3.
|
|
55
|
+
"@ossy/sdk": "^3.11.1",
|
|
56
|
+
"@ossy/tokens": "^3.11.1",
|
|
57
|
+
"@ossy/users": "^3.11.1",
|
|
58
|
+
"@ossy/workspaces": "^3.11.1",
|
|
58
59
|
"cookie-parser": "^1.4.7",
|
|
59
60
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
60
61
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -75,5 +76,5 @@
|
|
|
75
76
|
"Dockerfile",
|
|
76
77
|
"docker-healthcheck.js"
|
|
77
78
|
],
|
|
78
|
-
"gitHead": "
|
|
79
|
+
"gitHead": "f279de3535b6bc76631cbc6b943dae8f92a1565f"
|
|
79
80
|
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, reso
|
|
|
8
8
|
export { IntegrationService } from './integration.service.js'
|
|
9
9
|
export { ConfigService } from './config.service.js'
|
|
10
10
|
export { ActionService } from './actions/action.service.js'
|
|
11
|
+
export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
11
12
|
export { TokenService } from './token.service.js'
|
|
12
13
|
export { UsersMiddleware } from './users.middleware.js'
|
|
13
14
|
export { WorkspacesMiddleware } from './workspaces.middleware.js'
|
|
@@ -15,8 +16,10 @@ export { matchesCron } from './tasks/cron.js'
|
|
|
15
16
|
export { matchesGlob, globToRegex, policyToQueryClause } from './tasks/glob.js'
|
|
16
17
|
export {
|
|
17
18
|
USER_SETTINGS_COOKIE,
|
|
19
|
+
WORKSPACE_ID_COOKIE,
|
|
18
20
|
AUTH_COOKIE,
|
|
19
21
|
readUserAppSettings,
|
|
22
|
+
readWorkspaceIdFromCookies,
|
|
20
23
|
mergeUserAppSettingsCookie,
|
|
21
24
|
clearWorkspaceFromUserAppSettings,
|
|
22
25
|
setAuthCookie,
|
package/src/proxy-internal.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createLogger } from '@ossy/observability'
|
|
|
2
2
|
import {
|
|
3
3
|
mergeUserAppSettingsCookie,
|
|
4
4
|
readUserAppSettings,
|
|
5
|
+
readWorkspaceIdFromCookies,
|
|
5
6
|
} from './user-app-settings.js'
|
|
6
7
|
|
|
7
8
|
const log = createLogger('platform')
|
|
@@ -26,7 +27,10 @@ export function ProxyInternal () {
|
|
|
26
27
|
return
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const requestedSettings = req.body
|
|
30
|
+
const requestedSettings = { ...req.body }
|
|
31
|
+
// Active workspace is selected via /users/select-workspace (and sign-in).
|
|
32
|
+
// Shell Sync must not send workspaceId — strip if a client does.
|
|
33
|
+
delete requestedSettings.workspaceId
|
|
30
34
|
mergeUserAppSettingsCookie(req, res, requestedSettings)
|
|
31
35
|
|
|
32
36
|
res.status(201)
|
|
@@ -37,8 +41,9 @@ export function ProxyInternal () {
|
|
|
37
41
|
if (req.originalUrl.startsWith('/@ossy/users/me/app-settings') && req.method === 'GET') {
|
|
38
42
|
log.info('[@ossy/platform][proxy] GET /@ossy/users/me/app-settings')
|
|
39
43
|
const userSettings = readUserAppSettings(req)
|
|
44
|
+
const workspaceId = readWorkspaceIdFromCookies(req)
|
|
40
45
|
res.status(200)
|
|
41
|
-
res.json(userSettings)
|
|
46
|
+
res.json(workspaceId ? { ...userSettings, workspaceId } : userSettings)
|
|
42
47
|
return
|
|
43
48
|
}
|
|
44
49
|
|
package/src/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ import { ProxyInternal } from './proxy-internal.js'
|
|
|
11
11
|
import { loadSite, invalidateSite } from './site-loader.js'
|
|
12
12
|
import { createLogger } from '@ossy/observability'
|
|
13
13
|
import { mountHealthEndpoint } from './health.js'
|
|
14
|
+
import { readWorkspaceIdFromCookies } from './user-app-settings.js'
|
|
14
15
|
|
|
15
16
|
const log = createLogger('platform')
|
|
16
17
|
|
|
@@ -86,9 +87,10 @@ export async function startRuntime ({ port } = {}) {
|
|
|
86
87
|
|
|
87
88
|
app.use((req, _res, next) => {
|
|
88
89
|
const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const workspaceId = readWorkspaceIdFromCookies(req) || userSettings.workspaceId
|
|
91
|
+
req.userAppSettings = workspaceId ? { ...userSettings, workspaceId } : userSettings
|
|
92
|
+
if (workspaceId && !req.get('workspaceId')) {
|
|
93
|
+
req.headers.workspaceid = workspaceId
|
|
92
94
|
}
|
|
93
95
|
// Prefer a verified signed auth cookie; fall back to presence only when unsigned.
|
|
94
96
|
req.isAuthenticated = !!(req.signedCookies?.auth)
|
|
@@ -35,7 +35,11 @@ export default async function handle (req, res) {
|
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
const selected = workspaces.find((w) => w.id === workspaceId)
|
|
39
|
+
mergeUserAppSettingsCookie(req, res, {
|
|
40
|
+
workspaceId,
|
|
41
|
+
...(selected?.name ? { workspaceName: selected.name } : {}),
|
|
42
|
+
})
|
|
39
43
|
|
|
40
44
|
const redirect = req.query.redirect
|
|
41
45
|
if (redirect && typeof redirect === 'string') {
|
package/src/server.js
CHANGED
|
@@ -43,7 +43,11 @@ import {
|
|
|
43
43
|
import {
|
|
44
44
|
clearAuthCookie,
|
|
45
45
|
clearWorkspaceFromUserAppSettings,
|
|
46
|
+
mergeUserAppSettingsCookie,
|
|
47
|
+
readWorkspaceIdFromCookies,
|
|
48
|
+
setAuthCookie,
|
|
46
49
|
} from './user-app-settings.js'
|
|
50
|
+
import { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
47
51
|
const log = createLogger('@ossy/platform')
|
|
48
52
|
const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
|
|
49
53
|
const SSR_RENDER_TIMEOUT_MS = resolveTimeoutMs('OSSY_SSR_RENDER_TIMEOUT_MS', 30_000)
|
|
@@ -330,9 +334,10 @@ export async function startServer (options = {}) {
|
|
|
330
334
|
app.use(cookieParser(ConfigService.TokenSecret))
|
|
331
335
|
app.use((req, _res, next) => {
|
|
332
336
|
const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
const workspaceId = readWorkspaceIdFromCookies(req) || userSettings.workspaceId
|
|
338
|
+
req.userAppSettings = workspaceId ? { ...userSettings, workspaceId } : userSettings
|
|
339
|
+
if (workspaceId && !req.get('workspaceId')) {
|
|
340
|
+
req.headers.workspaceid = workspaceId
|
|
336
341
|
}
|
|
337
342
|
next()
|
|
338
343
|
})
|
|
@@ -425,6 +430,9 @@ export async function startServer (options = {}) {
|
|
|
425
430
|
clearAuthCookie(res)
|
|
426
431
|
clearWorkspaceFromUserAppSettings(req, res)
|
|
427
432
|
}
|
|
433
|
+
if (actionId === '@ossy/authentication/actions/verify-sign-in' && result?.token) {
|
|
434
|
+
await applyVerifySignInSessionCookies(req, res, result)
|
|
435
|
+
}
|
|
428
436
|
res.json(result ?? { ok: true })
|
|
429
437
|
} catch (err) {
|
|
430
438
|
// Still clear auth on sign-out failures so a broken session can recover.
|
|
@@ -668,6 +676,7 @@ export default startServer
|
|
|
668
676
|
export { loadLayoutsById, resolvePageLayoutRender }
|
|
669
677
|
export { ConfigService } from './config.service.js'
|
|
670
678
|
export { ActionService } from './actions/action.service.js'
|
|
679
|
+
export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
671
680
|
export { IntegrationService } from './integration.service.js'
|
|
672
681
|
export { StorageClient } from './storage/storage.client.js'
|
|
673
682
|
export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
|
|
@@ -679,8 +688,10 @@ export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWor
|
|
|
679
688
|
export { validateSchemasForImport } from './resources/schema.validation.js'
|
|
680
689
|
export {
|
|
681
690
|
USER_SETTINGS_COOKIE,
|
|
691
|
+
WORKSPACE_ID_COOKIE,
|
|
682
692
|
AUTH_COOKIE,
|
|
683
693
|
readUserAppSettings,
|
|
694
|
+
readWorkspaceIdFromCookies,
|
|
684
695
|
mergeUserAppSettingsCookie,
|
|
685
696
|
clearWorkspaceFromUserAppSettings,
|
|
686
697
|
setAuthCookie,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TaskService } from './task-service.js'
|
|
2
2
|
import { createLogger } from '@ossy/observability'
|
|
3
|
-
import { ProjectionRebuild, PushInvalidation
|
|
3
|
+
import { Mongo, ProjectionRebuild, PushInvalidation } from '@ossy/event-store'
|
|
4
4
|
|
|
5
5
|
const log = createLogger('platform')
|
|
6
6
|
|
|
@@ -11,26 +11,20 @@ function isMongoTopologyClosedError(error) {
|
|
|
11
11
|
return false
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Watches `eventstore` inserts. Uses the shared {@link Mongo} client — one per process. */
|
|
14
15
|
export class ChangeStream {
|
|
15
16
|
|
|
16
17
|
static _stopped = false
|
|
17
18
|
static _reconnectAttempts = 0
|
|
18
|
-
static _dbUrl = null
|
|
19
|
-
static _client = null
|
|
20
19
|
/** @type {import('mongodb').ChangeStream | null} */
|
|
21
20
|
static _changeStream = null
|
|
22
21
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
23
22
|
static _reconnectTimer = null
|
|
24
23
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
* Logs errors; does not crash the process on Mongo outages.
|
|
28
|
-
* @param {string} dbUrl - MongoDB connection URL (process.env.DB_URL)
|
|
29
|
-
*/
|
|
30
|
-
static start(dbUrl) {
|
|
24
|
+
/** @param {string} [_dbUrl] - Unused; kept for call-site compatibility. */
|
|
25
|
+
static start(_dbUrl) {
|
|
31
26
|
ChangeStream._stopped = false
|
|
32
27
|
ChangeStream._reconnectAttempts = 0
|
|
33
|
-
ChangeStream._dbUrl = resolveMongoUrl(dbUrl)
|
|
34
28
|
ChangeStream._open().catch((error) => {
|
|
35
29
|
log.error('[ChangeStream] Change stream could not be opened', undefined, error)
|
|
36
30
|
ChangeStream._scheduleReconnect()
|
|
@@ -55,48 +49,20 @@ export class ChangeStream {
|
|
|
55
49
|
}
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
const client = ChangeStream._client
|
|
59
|
-
ChangeStream._client = null
|
|
60
|
-
if (client) {
|
|
61
|
-
try {
|
|
62
|
-
await client.close()
|
|
63
|
-
} catch {
|
|
64
|
-
// ignore
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
52
|
log.info('[ChangeStream] Stopped')
|
|
69
53
|
}
|
|
70
54
|
|
|
71
|
-
static
|
|
72
|
-
const { MongoClient } = await import('mongodb')
|
|
73
|
-
return MongoClient
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
static async _getClient () {
|
|
55
|
+
static _getClient () {
|
|
77
56
|
if (ChangeStream._stopped) {
|
|
78
57
|
throw new Error('ChangeStream stopped')
|
|
79
58
|
}
|
|
80
|
-
|
|
81
|
-
const MongoClient = await ChangeStream._getMongoClient()
|
|
82
|
-
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
83
|
-
serverSelectionTimeoutMS: 30_000,
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
return ChangeStream._client
|
|
59
|
+
return Mongo.Client
|
|
87
60
|
}
|
|
88
61
|
|
|
89
|
-
static
|
|
62
|
+
static _resetSharedClient () {
|
|
90
63
|
if (ChangeStream._stopped) return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
94
|
-
serverSelectionTimeoutMS: 30_000,
|
|
95
|
-
})
|
|
96
|
-
if (prev) {
|
|
97
|
-
prev.close().catch(() => {})
|
|
98
|
-
}
|
|
99
|
-
log.info('[ChangeStream] New MongoClient instance created')
|
|
64
|
+
Mongo.resetClient()
|
|
65
|
+
log.info('[ChangeStream] Shared Mongo client reset')
|
|
100
66
|
}
|
|
101
67
|
|
|
102
68
|
static _scheduleReconnect () {
|
|
@@ -127,7 +93,7 @@ export class ChangeStream {
|
|
|
127
93
|
log.info('[ChangeStream] Watching for changes')
|
|
128
94
|
|
|
129
95
|
const dbName = process.env.DB_NAME || 'test'
|
|
130
|
-
const client =
|
|
96
|
+
const client = ChangeStream._getClient()
|
|
131
97
|
if (ChangeStream._stopped) return
|
|
132
98
|
|
|
133
99
|
const collection = client.db(dbName).collection('eventstore')
|
|
@@ -166,7 +132,7 @@ export class ChangeStream {
|
|
|
166
132
|
if (ChangeStream._stopped) return
|
|
167
133
|
log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
|
|
168
134
|
if (isMongoTopologyClosedError(error)) {
|
|
169
|
-
ChangeStream.
|
|
135
|
+
ChangeStream._resetSharedClient()
|
|
170
136
|
}
|
|
171
137
|
scheduleOnce()
|
|
172
138
|
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for resolving action POJOs in declarative flow steps.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {unknown} action
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function resolveActionId (action) {
|
|
10
|
+
if (typeof action === 'string') return action
|
|
11
|
+
if (action && typeof action.id === 'string') return action.id
|
|
12
|
+
throw new Error('Flow action step requires an action POJO or id string')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional discriminators for when multiple controls share an action id
|
|
17
|
+
* (e.g. per-row OpenRemoveMember beside a system bot row, or language switch targets).
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} action
|
|
20
|
+
* @param {{ service?: string, memberEmail?: string, language?: string }} [fallback]
|
|
21
|
+
* @returns {{ service?: string, memberEmail?: string, language?: string }}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveActionMatchers (action, fallback = {}) {
|
|
24
|
+
const fromAction = typeof action === 'object' && action != null ? action : {}
|
|
25
|
+
const service = fromAction.service ?? fromAction['data-service'] ?? fallback.service
|
|
26
|
+
const memberEmail = fromAction.memberEmail
|
|
27
|
+
?? fromAction['data-member-email']
|
|
28
|
+
?? fallback.memberEmail
|
|
29
|
+
const language = fromAction.language
|
|
30
|
+
?? fromAction['data-language']
|
|
31
|
+
?? fallback.language
|
|
32
|
+
return {
|
|
33
|
+
...(service != null && service !== '' ? { service: String(service) } : {}),
|
|
34
|
+
...(memberEmail != null && memberEmail !== '' ? { memberEmail: String(memberEmail) } : {}),
|
|
35
|
+
...(language != null && language !== '' ? { language: String(language) } : {}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} actionId
|
|
41
|
+
* @param {string | { service?: string, memberEmail?: string, language?: string }} [serviceOrMatchers]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function actionSelector (actionId, serviceOrMatchers) {
|
|
45
|
+
const matchers = typeof serviceOrMatchers === 'string'
|
|
46
|
+
? { service: serviceOrMatchers }
|
|
47
|
+
: (serviceOrMatchers ?? {})
|
|
48
|
+
let selector = `[data-action="${actionId}"]`
|
|
49
|
+
if (matchers.service) selector += `[data-service="${matchers.service}"]`
|
|
50
|
+
if (matchers.memberEmail) selector += `[data-member-email="${matchers.memberEmail}"]`
|
|
51
|
+
if (matchers.language) selector += `[data-language="${matchers.language}"]`
|
|
52
|
+
return selector
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {unknown} action
|
|
57
|
+
* @param {string} [fallback]
|
|
58
|
+
* @returns {string | undefined}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveActionService (action, fallback) {
|
|
61
|
+
return resolveActionMatchers(action, { service: fallback }).service
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
actionSelector,
|
|
4
|
+
resolveActionId,
|
|
5
|
+
resolveActionMatchers,
|
|
6
|
+
resolveActionService,
|
|
7
|
+
} from './flow-action.js'
|
|
8
|
+
|
|
9
|
+
describe('resolveActionId', () => {
|
|
10
|
+
it('reads id from a POJO', () => {
|
|
11
|
+
expect(resolveActionId({ id: '@ossy/app/actions/switch-language' }))
|
|
12
|
+
.toBe('@ossy/app/actions/switch-language')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('accepts a bare id string', () => {
|
|
16
|
+
expect(resolveActionId('@ossy/app/actions/switch-language'))
|
|
17
|
+
.toBe('@ossy/app/actions/switch-language')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('resolveActionMatchers', () => {
|
|
22
|
+
it('reads service, memberEmail, and language from the action POJO', () => {
|
|
23
|
+
expect(resolveActionMatchers({
|
|
24
|
+
id: 'x',
|
|
25
|
+
service: 'booking',
|
|
26
|
+
memberEmail: 'a@b.c',
|
|
27
|
+
language: 'sv',
|
|
28
|
+
})).toEqual({
|
|
29
|
+
service: 'booking',
|
|
30
|
+
memberEmail: 'a@b.c',
|
|
31
|
+
language: 'sv',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('falls back to data-* keys and step fallbacks', () => {
|
|
36
|
+
expect(resolveActionMatchers(
|
|
37
|
+
{ 'data-service': 'timesheets', 'data-member-email': 'x@y.z', 'data-language': 'en' },
|
|
38
|
+
{ service: 'ignored', memberEmail: 'ignored', language: 'ignored' },
|
|
39
|
+
)).toEqual({
|
|
40
|
+
service: 'timesheets',
|
|
41
|
+
memberEmail: 'x@y.z',
|
|
42
|
+
language: 'en',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('actionSelector', () => {
|
|
48
|
+
it('includes language in the selector', () => {
|
|
49
|
+
expect(actionSelector('@ossy/app/actions/switch-language', { language: 'sv' }))
|
|
50
|
+
.toBe('[data-action="@ossy/app/actions/switch-language"][data-language="sv"]')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('still supports a bare service string', () => {
|
|
54
|
+
expect(actionSelector('@ossy/workspaces/actions/enable-service', 'booking'))
|
|
55
|
+
.toBe('[data-action="@ossy/workspaces/actions/enable-service"][data-service="booking"]')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('resolveActionService', () => {
|
|
60
|
+
it('delegates to matchers', () => {
|
|
61
|
+
expect(resolveActionService({ service: 'analytics' })).toBe('analytics')
|
|
62
|
+
expect(resolveActionService({}, 'booking')).toBe('booking')
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/test/flow-context.js
CHANGED
|
@@ -42,3 +42,23 @@ export function interpolateContextString (template, context, options = {}) {
|
|
|
42
42
|
return escape(value)
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merge optional form-step field overrides into faker-mocked content.
|
|
48
|
+
* Values may be `$contextKey` placeholders (e.g. `{ email: '$email' }`).
|
|
49
|
+
*
|
|
50
|
+
* @param {Record<string, unknown>} content
|
|
51
|
+
* @param {Record<string, unknown> | null | undefined} overrides
|
|
52
|
+
* @param {{ fields?: Record<string, unknown>, [key: string]: unknown }} context
|
|
53
|
+
* @returns {Record<string, unknown>}
|
|
54
|
+
*/
|
|
55
|
+
export function applyFormFieldOverrides (content, overrides, context) {
|
|
56
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
57
|
+
return content
|
|
58
|
+
}
|
|
59
|
+
const next = { ...content }
|
|
60
|
+
for (const [name, raw] of Object.entries(overrides)) {
|
|
61
|
+
next[name] = resolveContextValue(raw, context)
|
|
62
|
+
}
|
|
63
|
+
return next
|
|
64
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
2
|
import {
|
|
3
|
+
applyFormFieldOverrides,
|
|
3
4
|
escapeCssAttrValue,
|
|
4
5
|
interpolateContextString,
|
|
5
6
|
resolveContextValue,
|
|
@@ -41,4 +42,17 @@ describe('flow-context', () => {
|
|
|
41
42
|
interpolateContextString('[data-x="$missing"]', context),
|
|
42
43
|
).toBe('[data-x="$missing"]')
|
|
43
44
|
})
|
|
45
|
+
|
|
46
|
+
it('applies form field overrides with $context placeholders', () => {
|
|
47
|
+
const mocked = { email: 'faker@example.com', firstName: 'Faker' }
|
|
48
|
+
expect(
|
|
49
|
+
applyFormFieldOverrides(mocked, { email: '$email' }, context),
|
|
50
|
+
).toEqual({ email: 'new@example.com', firstName: 'Faker' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns content unchanged when overrides are missing', () => {
|
|
54
|
+
const mocked = { email: 'faker@example.com' }
|
|
55
|
+
expect(applyFormFieldOverrides(mocked, null, context)).toBe(mocked)
|
|
56
|
+
expect(applyFormFieldOverrides(mocked, undefined, context)).toBe(mocked)
|
|
57
|
+
})
|
|
44
58
|
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a declarative `{ download }` flow step.
|
|
3
|
+
*
|
|
4
|
+
* Clicks an action (or CSS selector) while waiting for one or more browser
|
|
5
|
+
* download events — used for single-file and batch download journeys.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveActionId, resolveActionService } from './flow-action.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} value
|
|
12
|
+
* @returns {{
|
|
13
|
+
* actionId: string | null,
|
|
14
|
+
* service: string | undefined,
|
|
15
|
+
* selector: string | null,
|
|
16
|
+
* count: number,
|
|
17
|
+
* filenames: string[],
|
|
18
|
+
* timeout: number,
|
|
19
|
+
* }}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveDownloadTarget (value) {
|
|
22
|
+
if (value == null || (typeof value !== 'object' && typeof value !== 'string')) {
|
|
23
|
+
throw new Error('download step requires { action } or { selector }')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const spec = typeof value === 'string'
|
|
27
|
+
? { action: value }
|
|
28
|
+
: value
|
|
29
|
+
|
|
30
|
+
const actionRaw = spec.action
|
|
31
|
+
const selectorRaw = typeof spec.selector === 'string' ? spec.selector.trim() : ''
|
|
32
|
+
let actionId = null
|
|
33
|
+
let service = typeof spec.service === 'string' ? spec.service : undefined
|
|
34
|
+
|
|
35
|
+
if (actionRaw != null) {
|
|
36
|
+
actionId = resolveActionId(actionRaw)
|
|
37
|
+
service = resolveActionService(actionRaw, service)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!actionId && !selectorRaw) {
|
|
41
|
+
throw new Error('download step requires { action } or a non-empty { selector }')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const filenames = []
|
|
45
|
+
if (spec.filename != null) {
|
|
46
|
+
if (typeof spec.filename !== 'string' || !spec.filename.trim()) {
|
|
47
|
+
throw new Error('download.filename must be a non-empty string')
|
|
48
|
+
}
|
|
49
|
+
filenames.push(spec.filename.trim())
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(spec.filenames)) {
|
|
52
|
+
for (let i = 0; i < spec.filenames.length; i += 1) {
|
|
53
|
+
const name = spec.filenames[i]
|
|
54
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
55
|
+
throw new Error(`download.filenames[${i}] must be a non-empty string`)
|
|
56
|
+
}
|
|
57
|
+
filenames.push(name.trim())
|
|
58
|
+
}
|
|
59
|
+
} else if (spec.filenames != null) {
|
|
60
|
+
throw new Error('download.filenames must be an array of strings')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const countRaw = spec.count
|
|
64
|
+
const inferredCount = spec.count == null && filenames.length > 1 ? filenames.length : null
|
|
65
|
+
const count = inferredCount ?? (countRaw == null ? 1 : Number(countRaw))
|
|
66
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
67
|
+
throw new Error('download.count must be a positive integer')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const timeoutRaw = spec.timeout
|
|
71
|
+
const timeout = timeoutRaw == null ? 30000 : Number(timeoutRaw)
|
|
72
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
73
|
+
throw new Error('download.timeout must be a positive number')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
actionId,
|
|
78
|
+
service,
|
|
79
|
+
selector: selectorRaw || null,
|
|
80
|
+
count,
|
|
81
|
+
filenames,
|
|
82
|
+
timeout,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string[]} suggested
|
|
88
|
+
* @param {string[]} expected
|
|
89
|
+
*/
|
|
90
|
+
export function assertDownloadFilenames (suggested, expected) {
|
|
91
|
+
if (!expected.length) return
|
|
92
|
+
const remaining = [...suggested]
|
|
93
|
+
for (const name of expected) {
|
|
94
|
+
const index = remaining.indexOf(name)
|
|
95
|
+
if (index === -1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Expected download filename "${name}" not found in [${suggested.join(', ')}]`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
remaining.splice(index, 1)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
assertDownloadFilenames,
|
|
4
|
+
resolveDownloadTarget,
|
|
5
|
+
} from './flow-download.js'
|
|
6
|
+
|
|
7
|
+
describe('resolveDownloadTarget', () => {
|
|
8
|
+
it('accepts an action POJO and optional filename', () => {
|
|
9
|
+
expect(resolveDownloadTarget({
|
|
10
|
+
action: { id: '@ossy/resources/actions/download-resource' },
|
|
11
|
+
filename: 'e2e-upload.txt',
|
|
12
|
+
})).toEqual({
|
|
13
|
+
actionId: '@ossy/resources/actions/download-resource',
|
|
14
|
+
service: undefined,
|
|
15
|
+
selector: null,
|
|
16
|
+
count: 1,
|
|
17
|
+
filenames: ['e2e-upload.txt'],
|
|
18
|
+
timeout: 30000,
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('infers count from filenames when count is omitted', () => {
|
|
23
|
+
expect(resolveDownloadTarget({
|
|
24
|
+
action: '@ossy/resources/actions/download-selected',
|
|
25
|
+
filenames: ['a.txt', 'b.txt'],
|
|
26
|
+
})).toMatchObject({
|
|
27
|
+
count: 2,
|
|
28
|
+
filenames: ['a.txt', 'b.txt'],
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('accepts a CSS selector fallback', () => {
|
|
33
|
+
expect(resolveDownloadTarget({
|
|
34
|
+
selector: ' [data-download] ',
|
|
35
|
+
count: 1,
|
|
36
|
+
})).toMatchObject({
|
|
37
|
+
actionId: null,
|
|
38
|
+
selector: '[data-download]',
|
|
39
|
+
count: 1,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects invalid values', () => {
|
|
44
|
+
expect(() => resolveDownloadTarget(null)).toThrow(/requires \{ action \}/)
|
|
45
|
+
expect(() => resolveDownloadTarget({})).toThrow(/requires \{ action \}/)
|
|
46
|
+
expect(() => resolveDownloadTarget({ action: 'x', count: 0 })).toThrow(/positive integer/)
|
|
47
|
+
expect(() => resolveDownloadTarget({ action: 'x', filename: '' })).toThrow(/filename/)
|
|
48
|
+
expect(() => resolveDownloadTarget({ action: 'x', timeout: -1 })).toThrow(/timeout/)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('assertDownloadFilenames', () => {
|
|
53
|
+
it('passes when expected names are present', () => {
|
|
54
|
+
expect(() => assertDownloadFilenames(['a.txt', 'b.txt'], ['b.txt', 'a.txt'])).not.toThrow()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('fails when a name is missing', () => {
|
|
58
|
+
expect(() => assertDownloadFilenames(['a.txt'], ['b.txt'])).toThrow(/b\.txt/)
|
|
59
|
+
})
|
|
60
|
+
})
|