@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 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.9.0",
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.8.0",
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.5.0",
55
- "@ossy/tokens": "^3.5.0",
56
- "@ossy/users": "^3.8.0",
57
- "@ossy/workspaces": "^3.9.0",
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": "f404be69becb27a1fd853a6ff1903554e76e7d17"
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,
@@ -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
- req.userAppSettings = userSettings
90
- if (userSettings.workspaceId && !req.get('workspaceId')) {
91
- req.headers.workspaceid = userSettings.workspaceId
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
- mergeUserAppSettingsCookie(req, res, { workspaceId })
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
- req.userAppSettings = userSettings
334
- if (userSettings.workspaceId && !req.get('workspaceId')) {
335
- req.headers.workspaceid = userSettings.workspaceId
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, resolveMongoUrl } from '@ossy/event-store'
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
- * Opens the MongoDB changestream and wires up reconnect logic.
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 async _getMongoClient() {
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
- if (!ChangeStream._client) {
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 async _resetClient () {
62
+ static _resetSharedClient () {
90
63
  if (ChangeStream._stopped) return
91
- const MongoClient = await ChangeStream._getMongoClient()
92
- const prev = ChangeStream._client
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 = await ChangeStream._getClient()
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._resetClient().catch(() => {})
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
+ })
@@ -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
+ })