@ossy/platform 3.9.0 → 3.11.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 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.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,7 @@
45
45
  "@aws-sdk/util-format-url": "^3.972.17",
46
46
  "@modelcontextprotocol/sdk": "^1.12.1",
47
47
  "@ossy/config": "^3.0.9",
48
- "@ossy/event-store": "^3.8.0",
48
+ "@ossy/event-store": "^3.11.0",
49
49
  "@ossy/locale": "^3.4.0",
50
50
  "@ossy/manifest": "^3.9.0",
51
51
  "@ossy/observability": "^3.0.9",
@@ -53,8 +53,8 @@
53
53
  "@ossy/schema": "^3.8.0",
54
54
  "@ossy/sdk": "^3.5.0",
55
55
  "@ossy/tokens": "^3.5.0",
56
- "@ossy/users": "^3.8.0",
57
- "@ossy/workspaces": "^3.9.0",
56
+ "@ossy/users": "^3.11.0",
57
+ "@ossy/workspaces": "^3.11.0",
58
58
  "cookie-parser": "^1.4.7",
59
59
  "dotenv": ">=16.0.0 <18.0.0",
60
60
  "express": ">=5.0.0 <6.0.0",
@@ -75,5 +75,5 @@
75
75
  "Dockerfile",
76
76
  "docker-healthcheck.js"
77
77
  ],
78
- "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
78
+ "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
79
79
  }
package/src/index.js CHANGED
@@ -15,8 +15,10 @@ export { matchesCron } from './tasks/cron.js'
15
15
  export { matchesGlob, globToRegex, policyToQueryClause } from './tasks/glob.js'
16
16
  export {
17
17
  USER_SETTINGS_COOKIE,
18
+ WORKSPACE_ID_COOKIE,
18
19
  AUTH_COOKIE,
19
20
  readUserAppSettings,
21
+ readWorkspaceIdFromCookies,
20
22
  mergeUserAppSettingsCookie,
21
23
  clearWorkspaceFromUserAppSettings,
22
24
  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,6 +43,7 @@ import {
43
43
  import {
44
44
  clearAuthCookie,
45
45
  clearWorkspaceFromUserAppSettings,
46
+ readWorkspaceIdFromCookies,
46
47
  } from './user-app-settings.js'
47
48
  const log = createLogger('@ossy/platform')
48
49
  const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
@@ -330,9 +331,10 @@ export async function startServer (options = {}) {
330
331
  app.use(cookieParser(ConfigService.TokenSecret))
331
332
  app.use((req, _res, next) => {
332
333
  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
334
+ const workspaceId = readWorkspaceIdFromCookies(req) || userSettings.workspaceId
335
+ req.userAppSettings = workspaceId ? { ...userSettings, workspaceId } : userSettings
336
+ if (workspaceId && !req.get('workspaceId')) {
337
+ req.headers.workspaceid = workspaceId
336
338
  }
337
339
  next()
338
340
  })
@@ -679,8 +681,10 @@ export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWor
679
681
  export { validateSchemasForImport } from './resources/schema.validation.js'
680
682
  export {
681
683
  USER_SETTINGS_COOKIE,
684
+ WORKSPACE_ID_COOKIE,
682
685
  AUTH_COOKIE,
683
686
  readUserAppSettings,
687
+ readWorkspaceIdFromCookies,
684
688
  mergeUserAppSettingsCookie,
685
689
  clearWorkspaceFromUserAppSettings,
686
690
  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
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Normalize a declarative flow `files` step into Playwright `setInputFiles` payloads.
3
+ * Use for native file inputs (`type=file`) — form fills intentionally skip file fields.
4
+ *
5
+ * @param {unknown} files
6
+ * @returns {{
7
+ * selector: string,
8
+ * payloads: Array<string | { name: string, mimeType: string, buffer: Buffer }>
9
+ * }}
10
+ */
11
+ export function resolveFilesTarget (files) {
12
+ if (files == null || typeof files !== 'object' || Array.isArray(files)) {
13
+ throw new Error('files step requires { selector, paths? } or { selector, items? }')
14
+ }
15
+
16
+ const selector = typeof files.selector === 'string' ? files.selector.trim() : ''
17
+ if (!selector) {
18
+ throw new Error('files step requires a non-empty CSS selector')
19
+ }
20
+
21
+ const paths = normalizePathList(files.paths ?? files.path)
22
+ const items = normalizeItemList(files.items ?? files.item)
23
+
24
+ if (paths.length === 0 && items.length === 0) {
25
+ throw new Error('files step requires at least one path or item')
26
+ }
27
+
28
+ /** @type {Array<string | { name: string, mimeType: string, buffer: Buffer }>} */
29
+ const payloads = [
30
+ ...paths,
31
+ ...items.map((item) => ({
32
+ name: item.name,
33
+ mimeType: item.mimeType,
34
+ buffer: Buffer.from(item.content, item.encoding ?? 'utf8'),
35
+ })),
36
+ ]
37
+
38
+ return { selector, payloads }
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} raw
43
+ * @returns {string[]}
44
+ */
45
+ function normalizePathList (raw) {
46
+ if (raw == null) return []
47
+ const list = Array.isArray(raw) ? raw : [raw]
48
+ return list.map((entry, index) => {
49
+ if (typeof entry !== 'string' || !entry.trim()) {
50
+ throw new Error(`files.paths[${index}] must be a non-empty string`)
51
+ }
52
+ return entry.trim()
53
+ })
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} raw
58
+ * @returns {Array<{ name: string, mimeType: string, content: string, encoding?: BufferEncoding }>}
59
+ */
60
+ function normalizeItemList (raw) {
61
+ if (raw == null) return []
62
+ const list = Array.isArray(raw) ? raw : [raw]
63
+ return list.map((entry, index) => {
64
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
65
+ throw new Error(`files.items[${index}] must be an object`)
66
+ }
67
+ const name = typeof entry.name === 'string' ? entry.name.trim() : ''
68
+ if (!name) {
69
+ throw new Error(`files.items[${index}].name must be a non-empty string`)
70
+ }
71
+ if (typeof entry.content !== 'string') {
72
+ throw new Error(`files.items[${index}].content must be a string`)
73
+ }
74
+ const mimeType = typeof entry.mimeType === 'string' && entry.mimeType.trim()
75
+ ? entry.mimeType.trim()
76
+ : 'application/octet-stream'
77
+ const encoding = entry.encoding == null ? undefined : entry.encoding
78
+ if (encoding != null && typeof encoding !== 'string') {
79
+ throw new Error(`files.items[${index}].encoding must be a string when set`)
80
+ }
81
+ return { name, mimeType, content: entry.content, encoding }
82
+ })
83
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveFilesTarget } from './flow-files.js'
3
+
4
+ describe('resolveFilesTarget', () => {
5
+ it('accepts a single filesystem path', () => {
6
+ expect(resolveFilesTarget({
7
+ selector: ' #upload-resources ',
8
+ path: ' /tmp/sample.txt ',
9
+ })).toEqual({
10
+ selector: '#upload-resources',
11
+ payloads: ['/tmp/sample.txt'],
12
+ })
13
+ })
14
+
15
+ it('accepts multiple filesystem paths', () => {
16
+ expect(resolveFilesTarget({
17
+ selector: '#upload-resources',
18
+ paths: ['a.txt', 'b.txt'],
19
+ })).toEqual({
20
+ selector: '#upload-resources',
21
+ payloads: ['a.txt', 'b.txt'],
22
+ })
23
+ })
24
+
25
+ it('accepts in-memory buffer items', () => {
26
+ const resolved = resolveFilesTarget({
27
+ selector: '#upload-resources',
28
+ items: [{
29
+ name: 'e2e-upload.txt',
30
+ mimeType: 'text/plain',
31
+ content: 'hello from e2e',
32
+ }],
33
+ })
34
+ expect(resolved.selector).toBe('#upload-resources')
35
+ expect(resolved.payloads).toHaveLength(1)
36
+ expect(resolved.payloads[0]).toMatchObject({
37
+ name: 'e2e-upload.txt',
38
+ mimeType: 'text/plain',
39
+ })
40
+ expect(Buffer.isBuffer(resolved.payloads[0].buffer)).toBe(true)
41
+ expect(resolved.payloads[0].buffer.toString('utf8')).toBe('hello from e2e')
42
+ })
43
+
44
+ it('defaults mimeType for buffer items', () => {
45
+ const resolved = resolveFilesTarget({
46
+ selector: '[data-ossy-upload-input]',
47
+ item: { name: 'blob.bin', content: 'x' },
48
+ })
49
+ expect(resolved.payloads[0].mimeType).toBe('application/octet-stream')
50
+ })
51
+
52
+ it('rejects empty or invalid values', () => {
53
+ expect(() => resolveFilesTarget(null)).toThrow(/requires \{ selector/)
54
+ expect(() => resolveFilesTarget({ selector: '' })).toThrow(/non-empty CSS selector/)
55
+ expect(() => resolveFilesTarget({ selector: '#x' })).toThrow(/at least one path or item/)
56
+ expect(() => resolveFilesTarget({
57
+ selector: '#x',
58
+ paths: [''],
59
+ })).toThrow(/files\.paths\[0\]/)
60
+ expect(() => resolveFilesTarget({
61
+ selector: '#x',
62
+ items: [{ name: '', content: 'x' }],
63
+ })).toThrow(/files\.items\[0\]\.name/)
64
+ expect(() => resolveFilesTarget({
65
+ selector: '#x',
66
+ items: [{ name: 'a.txt', content: 1 }],
67
+ })).toThrow(/files\.items\[0\]\.content/)
68
+ })
69
+ })
@@ -15,20 +15,36 @@ import { Router } from '@ossy/router'
15
15
  import { Schema } from '@ossy/schema'
16
16
  import { test, expect } from '@playwright/test'
17
17
  import {
18
+ applyFormFieldOverrides,
18
19
  escapeCssAttrValue,
19
20
  interpolateContextString,
20
21
  resolveContextValue,
21
22
  } from './flow-context.js'
23
+ import { actionSelector, resolveActionId, resolveActionMatchers, resolveActionService } from './flow-action.js'
22
24
  import { resolveClickTarget } from './flow-click.js'
25
+ import { assertDownloadFilenames, resolveDownloadTarget } from './flow-download.js'
26
+ import { resolveFilesTarget } from './flow-files.js'
23
27
  import { resolvePressKey } from './flow-press.js'
24
28
  import { resolveViewportSize } from './flow-viewport.js'
25
29
 
26
30
  export {
31
+ applyFormFieldOverrides,
27
32
  escapeCssAttrValue,
28
33
  interpolateContextString,
29
34
  resolveContextValue,
30
35
  } from './flow-context.js'
36
+ export {
37
+ actionSelector,
38
+ resolveActionId,
39
+ resolveActionMatchers,
40
+ resolveActionService,
41
+ } from './flow-action.js'
31
42
  export { resolveClickTarget } from './flow-click.js'
43
+ export {
44
+ assertDownloadFilenames,
45
+ resolveDownloadTarget,
46
+ } from './flow-download.js'
47
+ export { resolveFilesTarget } from './flow-files.js'
32
48
  export { resolvePressKey } from './flow-press.js'
33
49
  export { resolveViewportSize } from './flow-viewport.js'
34
50
 
@@ -92,15 +108,20 @@ function schemaEngineFromManifest (manifest) {
92
108
  }
93
109
 
94
110
  /**
95
- * Mock form field values via Schema.mock and update the run context.
111
+ * Mock form field values via Schema.mock, apply optional overrides, and update the run context.
96
112
  *
97
113
  * @param {import('@ossy/schema').Schema} engine
98
114
  * @param {{ id: string, fields?: { name: string, type?: string }[] }} template
99
115
  * @param {FlowRunContext} context
116
+ * @param {Record<string, unknown>} [fieldOverrides]
100
117
  */
101
- function mockFormContent (engine, template, context) {
102
- const content = engine.mock(template, { faker })
118
+ function mockFormContent (engine, template, context, fieldOverrides) {
119
+ const mocked = engine.mock(template, { faker })
120
+ const content = applyFormFieldOverrides(mocked, fieldOverrides, context)
103
121
  for (const field of template.fields ?? []) {
122
+ // Skip upload/reference mocks — they are not DOM-fillable and must not be
123
+ // restored onto file inputs during remount-safe action submits.
124
+ if (['file', 'image', 'reference'].includes(field.type)) continue
104
125
  const value = content[field.name]
105
126
  if (value !== undefined) context.fields[field.name] = value
106
127
  if (field.type === 'email' || field.name?.toLowerCase?.().includes('email')) {
@@ -110,23 +131,6 @@ function mockFormContent (engine, template, context) {
110
131
  return content
111
132
  }
112
133
 
113
- function resolveActionId (action) {
114
- if (typeof action === 'string') return action
115
- if (action && typeof action.id === 'string') return action.id
116
- throw new Error('Flow action step requires an action POJO or id string')
117
- }
118
-
119
- function actionSelector (actionId, service) {
120
- return service
121
- ? `[data-action="${actionId}"][data-service="${service}"]`
122
- : `[data-action="${actionId}"]`
123
- }
124
-
125
- function resolveActionService (action, fallback) {
126
- if (typeof action === 'object' && action.service) return action.service
127
- return fallback
128
- }
129
-
130
134
  function resolveContextObject (obj, context) {
131
135
  if (!obj || typeof obj !== 'object') return obj
132
136
  const out = {}
@@ -232,6 +236,67 @@ export async function runFlow (flow, options = {}) {
232
236
  continue
233
237
  }
234
238
 
239
+ if (step.files != null) {
240
+ if (!page) throw new Error('files step requires a Playwright page')
241
+ const { selector, payloads } = resolveFilesTarget(step.files)
242
+ const resolvedSelector = interpolateContextString(selector, context, {
243
+ escape: escapeCssAttrValue,
244
+ })
245
+ const locator = page.locator(resolvedSelector).first()
246
+ const filesTimeout = step.timeout ?? 15000
247
+ // File inputs are often visually hidden behind dropzones — attach is enough.
248
+ await locator.waitFor({ state: 'attached', timeout: filesTimeout })
249
+ // SSR markup is attached before React wires onChange. Settle like action clicks.
250
+ await page.evaluate(async () => {
251
+ await new Promise((resolve) => {
252
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
253
+ })
254
+ await new Promise((resolve) => setTimeout(resolve, 500))
255
+ })
256
+
257
+ // Path payloads use Playwright's setInputFiles. In-memory buffer payloads use
258
+ // DataTransfer — Playwright setInputFiles populates input.files but does not
259
+ // notify React 19's onChange for this input; assigning via DataTransfer + change does.
260
+ const pathPayloads = payloads.filter((p) => typeof p === 'string')
261
+ const bufferPayloads = payloads.filter((p) => typeof p === 'object' && p != null)
262
+ if (pathPayloads.length) {
263
+ await locator.setInputFiles(pathPayloads)
264
+ }
265
+ if (bufferPayloads.length) {
266
+ await locator.evaluate((el, files) => {
267
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') {
268
+ throw new Error('files step selector must resolve to an HTML file input')
269
+ }
270
+ const dt = new DataTransfer()
271
+ for (const file of files) {
272
+ const bytes = Uint8Array.from(atob(file.base64), (c) => c.charCodeAt(0))
273
+ dt.items.add(new File([bytes], file.name, { type: file.mimeType }))
274
+ }
275
+ el.files = dt.files
276
+ el.dispatchEvent(new Event('input', { bubbles: true }))
277
+ el.dispatchEvent(new Event('change', { bubbles: true }))
278
+ }, bufferPayloads.map((p) => ({
279
+ name: p.name,
280
+ mimeType: p.mimeType,
281
+ base64: Buffer.from(p.buffer).toString('base64'),
282
+ })))
283
+ } else if (pathPayloads.length) {
284
+ // Paths-only: nudge change in case the host needs an explicit event.
285
+ await locator.evaluate((el) => {
286
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') return
287
+ el.dispatchEvent(new Event('input', { bubbles: true }))
288
+ el.dispatchEvent(new Event('change', { bubbles: true }))
289
+ }).catch(() => {})
290
+ }
291
+
292
+ // Capture the first uploaded filename for later result assertions.
293
+ const firstName = typeof payloads[0] === 'string'
294
+ ? path.basename(payloads[0])
295
+ : payloads[0]?.name
296
+ if (firstName) context.uploadedFileName = firstName
297
+ continue
298
+ }
299
+
235
300
  if (step.form) {
236
301
  if (!page) throw new Error('form step requires a Playwright page')
237
302
  const formMeta = step.form
@@ -245,11 +310,13 @@ export async function runFlow (flow, options = {}) {
245
310
  throw new Error(`Resource schema "${schemaId}" not found or has no fields`)
246
311
  }
247
312
  const engine = schemaEngineFromManifest(manifest)
248
- const content = mockFormContent(engine, template, context)
313
+ // Optional field overrides: `{ form: SignInForm, fields: { email: '$email' } }`
314
+ const content = mockFormContent(engine, template, context, step.fields)
249
315
  const formTimeout = step.timeout ?? 15000
250
316
  const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
251
317
  await formRoot.waitFor({ state: 'visible', timeout: formTimeout })
252
- for (const field of template.fields) {
318
+
319
+ const fillField = async (field) => {
253
320
  const value = content[field.name]
254
321
  if (value === undefined || value === null) {
255
322
  throw new Error(`mockFormContent produced no value for field "${field.name}"`)
@@ -259,58 +326,218 @@ export async function runFlow (flow, options = {}) {
259
326
  const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
260
327
  if (tag === 'select') {
261
328
  await locator.selectOption(String(value))
262
- } else if (tag === 'input') {
329
+ return
330
+ }
331
+ if (tag === 'input') {
263
332
  const inputType = await locator.getAttribute('type')
264
333
  if (inputType === 'checkbox') {
265
334
  if (value) await locator.check()
266
- } else {
267
- // Retry fills: early hydration can accept DOM input then wipe on re-render.
268
- const asText = String(value)
269
- for (let attempt = 0; attempt < 3; attempt++) {
270
- await locator.fill(asText)
271
- try {
272
- await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
273
- break
274
- } catch (err) {
275
- if (attempt === 2) throw err
276
- await page.waitForTimeout(150)
277
- }
278
- }
335
+ else await locator.uncheck()
336
+ return
337
+ }
338
+ }
339
+ // Retry fills: early hydration / sibling controlled updates can wipe earlier fields.
340
+ const asText = String(value)
341
+ for (let attempt = 0; attempt < 3; attempt++) {
342
+ await locator.fill(asText)
343
+ // Blur so React controlled state commits before the next field fill.
344
+ await locator.blur().catch(() => {})
345
+ try {
346
+ await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
347
+ return
348
+ } catch (err) {
349
+ if (attempt === 2) throw err
350
+ await page.waitForTimeout(150)
279
351
  }
280
- } else {
281
- await locator.fill(String(value))
282
352
  }
283
353
  }
354
+
355
+ // File / reference / image fields need real uploads — skip in declarative fills.
356
+ const fillableFields = (template.fields ?? []).filter(
357
+ (field) => !['file', 'image', 'reference'].includes(field.type),
358
+ )
359
+
360
+ for (const field of fillableFields) {
361
+ await fillField(field)
362
+ }
363
+
364
+ // Sibling controlled re-renders can clear earlier inputs after later fills —
365
+ // re-assert text-like fields and repair any that drifted.
366
+ for (let settle = 0; settle < 3; settle++) {
367
+ let drifted = false
368
+ for (const field of fillableFields) {
369
+ const value = content[field.name]
370
+ if (value === undefined || value === null) continue
371
+ const locator = formRoot.locator(`[name="${field.name}"]`).first()
372
+ const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
373
+ if (tag === 'select') continue
374
+ if (tag === 'input') {
375
+ const inputType = await locator.getAttribute('type')
376
+ if (inputType === 'checkbox' || inputType === 'file') continue
377
+ }
378
+ const asText = String(value)
379
+ const current = await locator.inputValue().catch(() => '')
380
+ if (current !== asText) {
381
+ drifted = true
382
+ await fillField(field)
383
+ }
384
+ }
385
+ if (!drifted) break
386
+ if (settle === 2) {
387
+ throw new Error(`form step could not keep field values stable for "${formId}"`)
388
+ }
389
+ await page.waitForTimeout(150)
390
+ }
284
391
  continue
285
392
  }
286
393
 
287
394
  if (step.action != null) {
288
395
  if (!page) throw new Error('action step requires a Playwright page')
289
- const actionId = resolveActionId(step.action)
290
- const service = resolveActionService(step.action, step.service)
291
- const matches = page.locator(actionSelector(actionId, service))
396
+ const actionStep = typeof step.action === 'object' && step.action != null
397
+ ? resolveContextObject(step.action, context)
398
+ : step.action
399
+ const actionId = resolveActionId(actionStep)
400
+ const matchers = resolveActionMatchers(actionStep, {
401
+ service: resolveContextValue(step.service, context),
402
+ memberEmail: resolveContextValue(step.memberEmail, context),
403
+ language: resolveContextValue(step.language, context),
404
+ })
405
+ const selector = actionSelector(actionId, matchers)
292
406
  const actionTimeout = step.timeout ?? 15000
293
- await matches.first().waitFor({ state: 'attached', timeout: actionTimeout })
407
+ const actionDeadline = Date.now() + actionTimeout
408
+ // SSR markup is visible before React attaches onClick. Wait a paint+microtask
409
+ // so hydration can finish; then use DOM click() (Playwright pointer clicks can
410
+ // miss React handlers when overlays intercept hit-testing).
411
+ //
412
+ // Re-query the live node after the settle wait: concurrent auth/workspace
413
+ // fetches can remount the tree and detach Playwright locators mid-scroll
414
+ // (and wipe uncontrolled form state). Retry within the action timeout.
294
415
  // Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
295
- const count = await matches.count()
296
416
  let clicked = false
297
417
  let lastError
298
- for (let i = 0; i < count; i++) {
299
- const candidate = matches.nth(i)
418
+ while (!clicked && Date.now() < actionDeadline) {
419
+ const remaining = Math.max(250, actionDeadline - Date.now())
420
+ const matches = page.locator(selector)
300
421
  try {
301
- await candidate.click({ timeout: Math.min(5000, actionTimeout) })
302
- clicked = true
303
- break
422
+ await matches.first().waitFor({ state: 'attached', timeout: remaining })
304
423
  } catch (err) {
305
424
  lastError = err
425
+ break
426
+ }
427
+ const count = await matches.count()
428
+ for (let i = 0; i < count; i++) {
429
+ const candidate = matches.nth(i)
430
+ try {
431
+ await candidate.waitFor({
432
+ state: 'visible',
433
+ timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
434
+ })
435
+ // Scroll inside evaluate via a fresh querySelector — Playwright's
436
+ // scrollIntoViewIfNeeded holds a locator through a stability wait and
437
+ // throws "not attached" when GetWorkspace remounts PackageServiceToggle.
438
+ const fieldRestore = { ...(context.fields ?? {}) }
439
+ if (context.email != null && fieldRestore.email == null) {
440
+ fieldRestore.email = context.email
441
+ }
442
+ await page.evaluate(async ({ selector: sel, fields }) => {
443
+ await new Promise((resolve) => {
444
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
445
+ })
446
+ await new Promise((resolve) => setTimeout(resolve, 500))
447
+ const el = document.querySelector(sel)
448
+ if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
449
+ if (typeof el.scrollIntoView === 'function') {
450
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
451
+ }
452
+ const form = el.closest('form')
453
+ if (form && fields && typeof fields === 'object') {
454
+ for (const [name, value] of Object.entries(fields)) {
455
+ if (value == null || typeof value === 'object') continue
456
+ const input = form.querySelector(`[name="${name}"]`)
457
+ if (!input || input.disabled) continue
458
+ // File inputs throw InvalidStateError if value is set programmatically.
459
+ if (input instanceof HTMLInputElement && input.type === 'file') continue
460
+ const asText = String(value)
461
+ if (input.value === asText) continue
462
+ const proto = Object.getPrototypeOf(input)
463
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
464
+ desc?.set?.call(input, asText)
465
+ input.dispatchEvent(new Event('input', { bubbles: true }))
466
+ input.dispatchEvent(new Event('change', { bubbles: true }))
467
+ }
468
+ }
469
+ const isSubmit = form && (
470
+ (el instanceof HTMLButtonElement && el.type === 'submit')
471
+ || el.getAttribute('type') === 'submit'
472
+ )
473
+ if (isSubmit && typeof form.requestSubmit === 'function') {
474
+ form.requestSubmit(el)
475
+ } else if (typeof el.click === 'function') {
476
+ el.click()
477
+ } else {
478
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
479
+ }
480
+ }, { selector, fields: fieldRestore })
481
+ clicked = true
482
+ break
483
+ } catch (err) {
484
+ lastError = err
485
+ const message = String(err?.message ?? err)
486
+ if (/not attached|disappeared after settle/i.test(message)) {
487
+ await page.waitForTimeout(100)
488
+ break
489
+ }
490
+ }
491
+ }
492
+ if (!clicked && Date.now() < actionDeadline) {
493
+ await page.waitForTimeout(100)
306
494
  }
307
495
  }
308
496
  if (!clicked) {
309
- throw lastError ?? new Error(`No actionable locator for ${actionSelector(actionId, service)}`)
497
+ throw lastError ?? new Error(`No actionable locator for ${selector}`)
310
498
  }
311
499
  continue
312
500
  }
313
501
 
502
+ if (step.download != null) {
503
+ if (!page) throw new Error('download step requires a Playwright page')
504
+ const target = resolveDownloadTarget(step.download)
505
+ const timeout = step.timeout ?? target.timeout
506
+ const locator = target.actionId
507
+ ? page.locator(actionSelector(target.actionId, target.service))
508
+ : page.locator(target.selector)
509
+ await locator.first().waitFor({ state: 'visible', timeout })
510
+
511
+ // Collect downloads via one listener — parallel waitForEvent('download')
512
+ // waiters all resolve on the *first* event (Playwright EventEmitter fan-out).
513
+ const downloads = []
514
+ const onDownload = (download) => {
515
+ downloads.push(download)
516
+ }
517
+ page.on('download', onDownload)
518
+ try {
519
+ await locator.first().scrollIntoViewIfNeeded()
520
+ await locator.first().click({ timeout })
521
+ const deadline = Date.now() + timeout
522
+ while (downloads.length < target.count) {
523
+ if (Date.now() > deadline) {
524
+ throw new Error(
525
+ `Timed out waiting for ${target.count} download(s); got ${downloads.length}`,
526
+ )
527
+ }
528
+ await page.waitForTimeout(50)
529
+ }
530
+ } finally {
531
+ page.off('download', onDownload)
532
+ }
533
+
534
+ const suggested = downloads.slice(0, target.count).map((download) => download.suggestedFilename())
535
+ assertDownloadFilenames(suggested, target.filenames)
536
+ context.downloads = suggested
537
+ if (suggested[0]) context.downloadedFileName = suggested[0]
538
+ continue
539
+ }
540
+
314
541
  if (step.capture != null) {
315
542
  if (!page) throw new Error('capture step requires a Playwright page')
316
543
  for (const [key, spec] of Object.entries(step.capture)) {
@@ -353,8 +580,12 @@ export async function runFlow (flow, options = {}) {
353
580
  let clicked = false
354
581
  while (Date.now() < deadline && !clicked) {
355
582
  await page.goto(inboxUrl)
356
- const link = page.getByRole('link', { name: pattern }).first()
583
+ // Scope to email HTML body so chrome auth links (e.g. header "Sign in")
584
+ // cannot steal the click when the CTA label collides.
585
+ const body = page.locator('[data-email-body]').first()
586
+ const link = body.getByRole('link', { name: pattern }).first()
357
587
  try {
588
+ await body.waitFor({ state: 'visible', timeout: 2000 })
358
589
  await link.waitFor({ state: 'visible', timeout: 2000 })
359
590
  await Promise.all([
360
591
  page.waitForLoadState('domcontentloaded'),
@@ -390,9 +621,16 @@ export async function runFlow (flow, options = {}) {
390
621
  await expectFn(locator).toBeVisible({ timeout })
391
622
  }
392
623
  if (action != null) {
393
- const actionId = resolveActionId(action)
394
- const service = resolveActionService(action, step.result.service)
395
- await assertLocator(page.locator(actionSelector(actionId, service)).first())
624
+ const actionResult = typeof action === 'object' && action != null
625
+ ? resolveContextObject(action, context)
626
+ : action
627
+ const actionId = resolveActionId(actionResult)
628
+ const matchers = resolveActionMatchers(actionResult, {
629
+ service: resolveContextValue(step.result.service, context),
630
+ memberEmail: resolveContextValue(step.result.memberEmail, context),
631
+ language: resolveContextValue(step.result.language, context),
632
+ })
633
+ await assertLocator(page.locator(actionSelector(actionId, matchers)).first())
396
634
  }
397
635
  if (selector != null) {
398
636
  const resolvedSelector = interpolateContextString(selector, context, {
@@ -463,6 +701,9 @@ export function registerFlow (mod, options = {}) {
463
701
 
464
702
  test.describe(feature, () => {
465
703
  test(title, async ({ page, baseURL }) => {
704
+ if (typeof meta.timeout === 'number' && meta.timeout > 0) {
705
+ test.setTimeout(meta.timeout)
706
+ }
466
707
  const manifestPath = options.manifestPath
467
708
  const manifest = manifestPath ? loadManifest(manifestPath) : undefined
468
709
  await runFlow({ ...flowBody, steps, metadata: meta }, {
@@ -1,35 +1,96 @@
1
1
  export const USER_SETTINGS_COOKIE = 'x-ossy-user-settings'
2
+ export const WORKSPACE_ID_COOKIE = 'x-ossy-workspace-id'
2
3
  export const AUTH_COOKIE = 'auth'
3
4
 
4
5
  const USER_SETTINGS_MAX_AGE_MS = 2147483647
5
6
  const AUTH_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
6
7
 
8
+ const signedCookieOpts = {
9
+ httpOnly: true,
10
+ signed: true,
11
+ path: '/',
12
+ }
13
+
14
+ function settingsCookieExpires () {
15
+ return new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS)
16
+ }
17
+
7
18
  export function readUserAppSettings (req) {
8
19
  return JSON.parse(req.signedCookies?.[USER_SETTINGS_COOKIE] || '{}')
9
20
  }
10
21
 
22
+ /**
23
+ * Active workspace id: dedicated cookie (select-workspace / sign-in) wins over the
24
+ * legacy field inside x-ossy-user-settings. Shell Sync PATCH must not clobber the
25
+ * dedicated cookie — see mergeUserAppSettingsCookie.
26
+ */
27
+ export function readWorkspaceIdFromCookies (req) {
28
+ const dedicated = req.signedCookies?.[WORKSPACE_ID_COOKIE]
29
+ if (dedicated && typeof dedicated === 'string' && dedicated !== 'undefined') {
30
+ return dedicated
31
+ }
32
+ const fromSettings = readUserAppSettings(req).workspaceId
33
+ if (fromSettings && typeof fromSettings === 'string' && fromSettings !== 'undefined') {
34
+ return fromSettings
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ export function setWorkspaceIdCookie (res, workspaceId) {
40
+ res.cookie(WORKSPACE_ID_COOKIE, String(workspaceId), {
41
+ ...signedCookieOpts,
42
+ expires: settingsCookieExpires(),
43
+ })
44
+ }
45
+
46
+ export function clearWorkspaceIdCookie (res) {
47
+ const clearOpts = { httpOnly: true, path: '/' }
48
+ res.clearCookie(WORKSPACE_ID_COOKIE, clearOpts)
49
+ res.clearCookie(WORKSPACE_ID_COOKIE, { ...clearOpts, signed: true })
50
+ res.cookie(WORKSPACE_ID_COOKIE, '', {
51
+ ...clearOpts,
52
+ signed: true,
53
+ expires: new Date(0),
54
+ })
55
+ }
56
+
11
57
  export function mergeUserAppSettingsCookie (req, res, partial) {
12
- const updated = { ...readUserAppSettings(req), ...partial }
58
+ const current = readUserAppSettings(req)
59
+ const updated = { ...current, ...partial }
60
+ const explicitWorkspaceId = Object.prototype.hasOwnProperty.call(partial, 'workspaceId')
61
+
62
+ if (explicitWorkspaceId) {
63
+ if (partial.workspaceId) {
64
+ setWorkspaceIdCookie(res, partial.workspaceId)
65
+ updated.workspaceId = partial.workspaceId
66
+ } else {
67
+ clearWorkspaceIdCookie(res)
68
+ delete updated.workspaceId
69
+ }
70
+ } else {
71
+ // Shell Sync and similar patches omit workspaceId. Never rewrite it from the
72
+ // request's cookie snapshot — an in-flight PATCH started before select-workspace
73
+ // would otherwise clobber the newly selected workspace (Switch workspace e2e flake).
74
+ delete updated.workspaceId
75
+ }
76
+
13
77
  res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(updated), {
14
- httpOnly: true,
15
- signed: true,
16
- path: '/',
17
- expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
78
+ ...signedCookieOpts,
79
+ expires: settingsCookieExpires(),
18
80
  })
19
81
  }
20
82
 
21
83
  export function clearWorkspaceFromUserAppSettings (req, res) {
22
84
  const settings = readUserAppSettings(req)
23
85
  delete settings.workspaceId
86
+ clearWorkspaceIdCookie(res)
24
87
  if (Object.keys(settings).length === 0) {
25
88
  res.clearCookie(USER_SETTINGS_COOKIE, { httpOnly: true, signed: true, path: '/' })
26
89
  return
27
90
  }
28
91
  res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(settings), {
29
- httpOnly: true,
30
- signed: true,
31
- path: '/',
32
- expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
92
+ ...signedCookieOpts,
93
+ expires: settingsCookieExpires(),
33
94
  })
34
95
  }
35
96