@ossy/platform 3.8.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 +2 -0
- package/package.json +6 -6
- package/src/index.js +2 -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 +7 -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 +294 -53
- package/src/user-app-settings.js +70 -9
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.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,16 +45,16 @@
|
|
|
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.
|
|
48
|
+
"@ossy/event-store": "^3.11.0",
|
|
49
49
|
"@ossy/locale": "^3.4.0",
|
|
50
|
-
"@ossy/manifest": "^3.
|
|
50
|
+
"@ossy/manifest": "^3.9.0",
|
|
51
51
|
"@ossy/observability": "^3.0.9",
|
|
52
52
|
"@ossy/policies": "^3.0.9",
|
|
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.
|
|
57
|
-
"@ossy/workspaces": "^3.
|
|
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": "
|
|
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,
|
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,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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
+
})
|
|
@@ -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
|
+
})
|
package/src/test/flow-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
const
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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 }, {
|
package/src/user-app-settings.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
path: '/',
|
|
32
|
-
expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
|
|
92
|
+
...signedCookieOpts,
|
|
93
|
+
expires: settingsCookieExpires(),
|
|
33
94
|
})
|
|
34
95
|
}
|
|
35
96
|
|