@ossy/platform 3.2.0 → 3.4.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,7 +60,7 @@ 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
- | `API_URL` + `OSSY_API_KEY` | SDK configuration. When set, tasks receive a pre-configured SDK instance. |
63
+ | `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
64
  | `PORT` | HTTP port. |
65
65
 
66
66
  ## Health check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.2.0",
3
+ "version": "3.4.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.2.0",
49
- "@ossy/locale": "^3.0.9",
50
- "@ossy/manifest": "^3.0.9",
48
+ "@ossy/event-store": "^3.4.0",
49
+ "@ossy/locale": "^3.4.0",
50
+ "@ossy/manifest": "^3.4.0",
51
51
  "@ossy/observability": "^3.0.9",
52
52
  "@ossy/policies": "^3.0.9",
53
- "@ossy/schema": "^3.0.9",
54
- "@ossy/sdk": "^3.2.0",
53
+ "@ossy/schema": "^3.4.0",
54
+ "@ossy/sdk": "^3.4.0",
55
55
  "@ossy/tokens": "^3.0.9",
56
- "@ossy/users": "^3.2.0",
57
- "@ossy/workspaces": "^3.2.0",
56
+ "@ossy/users": "^3.4.0",
57
+ "@ossy/workspaces": "^3.4.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",
@@ -74,5 +74,5 @@
74
74
  "src",
75
75
  "Dockerfile"
76
76
  ],
77
- "gitHead": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
77
+ "gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
78
78
  }
@@ -7,5 +7,9 @@ export default {
7
7
  { name: 'Key', type: 'text' },
8
8
  { name: 'ContentLength', type: 'number' },
9
9
  { name: 'ContentType', type: 'text' },
10
+ // Populated async by @ossy/media-tasks/tasks/resize-common-web
11
+ { name: 'blurhash', type: 'text' },
12
+ { name: 'width', type: 'number' },
13
+ { name: 'height', type: 'number' },
10
14
  ],
11
15
  }
package/src/health.js CHANGED
@@ -6,16 +6,35 @@
6
6
  */
7
7
  export const HEALTH_PATH = '/health'
8
8
 
9
+ /**
10
+ * Abort hung Node `fetch` probes under the usual 5s Docker/ECS check timeout.
11
+ * Mirrored by `@ossy/deployment-tools` `HEALTHCHECK_FETCH_TIMEOUT_MS`.
12
+ */
13
+ export const HEALTHCHECK_FETCH_TIMEOUT_MS = 4000
14
+
15
+ /**
16
+ * @param {string | undefined} explicit
17
+ * @returns {string}
18
+ */
19
+ export function resolveHealthServiceName (explicit) {
20
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
21
+ const fromEnv = typeof process.env.OSSY_SERVICE_NAME === 'string'
22
+ ? process.env.OSSY_SERVICE_NAME.trim()
23
+ : ''
24
+ return fromEnv || 'ossy'
25
+ }
26
+
9
27
  /**
10
28
  * @param {import('express').Express} app
11
29
  * @param {{ service?: string }} [options]
12
30
  */
13
- export function mountHealthEndpoint (app, { service = 'ossy' } = {}) {
31
+ export function mountHealthEndpoint (app, { service } = {}) {
32
+ const resolvedService = resolveHealthServiceName(service)
14
33
  const handler = (_req, res) => {
15
34
  res.status(200).json({
16
35
  ok: true,
17
36
  status: 'ok',
18
- service,
37
+ service: resolvedService,
19
38
  })
20
39
  }
21
40
 
@@ -1,6 +1,11 @@
1
- import { describe, expect, it, jest } from '@jest/globals'
1
+ import { afterEach, describe, expect, it, jest } from '@jest/globals'
2
2
  import express from 'express'
3
- import { HEALTH_PATH, mountHealthEndpoint } from './health.js'
3
+ import {
4
+ HEALTH_PATH,
5
+ HEALTHCHECK_FETCH_TIMEOUT_MS,
6
+ mountHealthEndpoint,
7
+ resolveHealthServiceName,
8
+ } from './health.js'
4
9
 
5
10
  function createMockRes () {
6
11
  const res = {
@@ -18,7 +23,38 @@ function createMockRes () {
18
23
  return res
19
24
  }
20
25
 
26
+ describe('resolveHealthServiceName', () => {
27
+ const original = process.env.OSSY_SERVICE_NAME
28
+
29
+ afterEach(() => {
30
+ if (original === undefined) delete process.env.OSSY_SERVICE_NAME
31
+ else process.env.OSSY_SERVICE_NAME = original
32
+ })
33
+
34
+ it('prefers an explicit non-empty service', () => {
35
+ process.env.OSSY_SERVICE_NAME = 'from-env'
36
+ expect(resolveHealthServiceName('platform-runtime')).toBe('platform-runtime')
37
+ })
38
+
39
+ it('uses OSSY_SERVICE_NAME when explicit is omitted', () => {
40
+ process.env.OSSY_SERVICE_NAME = 'website-ossy'
41
+ expect(resolveHealthServiceName()).toBe('website-ossy')
42
+ })
43
+
44
+ it('defaults to ossy', () => {
45
+ delete process.env.OSSY_SERVICE_NAME
46
+ expect(resolveHealthServiceName()).toBe('ossy')
47
+ })
48
+ })
49
+
21
50
  describe('mountHealthEndpoint', () => {
51
+ const original = process.env.OSSY_SERVICE_NAME
52
+
53
+ afterEach(() => {
54
+ if (original === undefined) delete process.env.OSSY_SERVICE_NAME
55
+ else process.env.OSSY_SERVICE_NAME = original
56
+ })
57
+
22
58
  it('registers GET /health that returns 200 without auth', () => {
23
59
  const app = express()
24
60
  const get = jest.spyOn(app, 'get')
@@ -42,6 +78,7 @@ describe('mountHealthEndpoint', () => {
42
78
  })
43
79
 
44
80
  it('defaults service name to ossy', () => {
81
+ delete process.env.OSSY_SERVICE_NAME
45
82
  const app = express()
46
83
  const get = jest.spyOn(app, 'get')
47
84
  mountHealthEndpoint(app)
@@ -50,4 +87,20 @@ describe('mountHealthEndpoint', () => {
50
87
  handler({}, res)
51
88
  expect(res.body.service).toBe('ossy')
52
89
  })
90
+
91
+ it('labels via OSSY_SERVICE_NAME when set', () => {
92
+ process.env.OSSY_SERVICE_NAME = 'website-ossy'
93
+ const app = express()
94
+ const get = jest.spyOn(app, 'get')
95
+ mountHealthEndpoint(app)
96
+ const handler = get.mock.calls[0][1]
97
+ const res = createMockRes()
98
+ handler({}, res)
99
+ expect(res.body.service).toBe('website-ossy')
100
+ })
101
+
102
+ it('exports HEALTHCHECK_FETCH_TIMEOUT_MS under the ECS check timeout', () => {
103
+ expect(HEALTHCHECK_FETCH_TIMEOUT_MS).toBe(4000)
104
+ expect(HEALTHCHECK_FETCH_TIMEOUT_MS).toBeLessThan(5000)
105
+ })
53
106
  })
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { TaskService } from './tasks/task-service.js'
2
+ export { createInProcessSdk } from './tasks/in-process-sdk.js'
2
3
  export { loadAndRegisterTasks } from './tasks/task-registry.js'
3
4
  export { ChangeStream } from './tasks/change-stream.js'
4
5
  export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
package/src/runtime.js CHANGED
@@ -78,7 +78,8 @@ export async function startRuntime ({ port } = {}) {
78
78
 
79
79
  const app = express()
80
80
  // Liveness for ALB/ECS — before site loading so probes never depend on CMS/domain.
81
- mountHealthEndpoint(app, { service: 'platform-runtime' })
81
+ // Prefer OSSY_SERVICE_NAME (ECS task env) when set.
82
+ mountHealthEndpoint(app)
82
83
  app.use(morgan('tiny'))
83
84
  app.use(express.json({ strict: false }))
84
85
  app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
package/src/server.js CHANGED
@@ -276,11 +276,12 @@ export async function startServer (options = {}) {
276
276
  const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
277
277
 
278
278
  // Register the SDK so all tasks receive it as `sdk`.
279
- // Priority: explicit options.sdk → SDK.of() from env vars null (direct-DB fallback in tasks).
279
+ // Priority: explicit options.sdk → HTTP bot SDK (API_URL + OSSY_API_KEY)in-process ActionService SDK.
280
280
  const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)
281
281
  ? SDK.of({ apiUrl: process.env.API_URL, authorization: process.env.OSSY_API_KEY })
282
282
  : null
283
- TaskService.setSdk(options.sdk ?? botSdk)
283
+ const { createInProcessSdk } = await import('./tasks/in-process-sdk.js')
284
+ TaskService.setSdk(options.sdk ?? botSdk ?? createInProcessSdk())
284
285
 
285
286
  TaskService.startScheduler()
286
287
 
@@ -322,7 +323,8 @@ export async function startServer (options = {}) {
322
323
 
323
324
  const app = express()
324
325
  // Liveness for ALB/ECS — before auth so probes never depend on cookies/Mongo.
325
- mountHealthEndpoint(app, { service: 'platform' })
326
+ // Prefer OSSY_SERVICE_NAME (ECS task env) when set.
327
+ mountHealthEndpoint(app)
326
328
  app.use(morgan('tiny'))
327
329
  app.use(express.json({ strict: false }))
328
330
  app.use(cookieParser(ConfigService.TokenSecret))
@@ -342,6 +344,10 @@ export async function startServer (options = {}) {
342
344
  next()
343
345
  })
344
346
  app.use(WorkspacesMiddleware.ExtractWorkspaceId())
347
+ // App-authored Express middleware from `src/middleware.js` (bundled to
348
+ // build/public/static/middleware.js). Runs after auth/workspace so handlers
349
+ // can rely on req.userId / req.workspaceId when present.
350
+ for (const mw of await loadAppMiddleware(buildDir)) app.use(mw)
345
351
  app.use(createSlowRequestLogger(log))
346
352
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
347
353
  app.use(ProxyInternal())
@@ -625,6 +631,32 @@ export async function startServer (options = {}) {
625
631
  return { app, server, port, close: closeServer, lifetime }
626
632
  }
627
633
 
634
+ /**
635
+ * Load optional app middleware bundled from `src/middleware.js`.
636
+ * Accepts a default export that is either a middleware function or an array.
637
+ *
638
+ * @param {string} buildDir
639
+ * @returns {Promise<import('express').RequestHandler[]>}
640
+ */
641
+ async function loadAppMiddleware (buildDir) {
642
+ const candidates = [
643
+ path.resolve(buildDir, 'public', 'static', 'middleware.js'),
644
+ path.resolve(buildDir, 'middleware.js'),
645
+ ]
646
+ for (const candidate of candidates) {
647
+ if (!fs.existsSync(candidate)) continue
648
+ try {
649
+ const mod = await import(pathToFileURL(candidate).href)
650
+ const value = mod.default
651
+ if (Array.isArray(value)) return value
652
+ if (typeof value === 'function') return [value]
653
+ } catch (err) {
654
+ log.warn(`Failed to load app middleware from ${candidate}`, undefined, err)
655
+ }
656
+ }
657
+ return []
658
+ }
659
+
628
660
  export default startServer
629
661
  export { loadLayoutsById, resolvePageLayoutRender }
630
662
  export { ConfigService } from './config.service.js'
@@ -0,0 +1,107 @@
1
+ import { resolveActionId } from '@ossy/sdk'
2
+ import { ActionService } from '../actions/action.service.js'
3
+ import { IntegrationService } from '../integration.service.js'
4
+ import { StorageClient } from '../storage/storage.client.js'
5
+ import { derivativeObjectKey } from '../storage/storage-keys.js'
6
+
7
+ /**
8
+ * @param {unknown} file
9
+ * @returns {Promise<Buffer>}
10
+ */
11
+ async function fileToBuffer (file) {
12
+ if (Buffer.isBuffer(file)) return file
13
+ if (file instanceof Uint8Array) return Buffer.from(file)
14
+ if (typeof file?.arrayBuffer === 'function') {
15
+ return Buffer.from(await file.arrayBuffer())
16
+ }
17
+ throw new Error('[InProcessSdk] uploadNamedVersion expects a File, Blob, Buffer, or Uint8Array')
18
+ }
19
+
20
+ /**
21
+ * Resolve actor/workspace from an eventstore document for nested action invokes.
22
+ *
23
+ * @param {object} [event]
24
+ * @returns {{ userId: string, workspaceId?: string }}
25
+ */
26
+ function reqFromEvent (event) {
27
+ return {
28
+ userId: event?.createdBy
29
+ ?? event?.payload?.createdBy
30
+ ?? event?.payload?.userId
31
+ ?? 'system',
32
+ workspaceId: event?.payload?.belongsTo
33
+ ?? event?.belongsTo
34
+ ?? event?.payload?.workspaceId,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Same-process SDK for changestream / scheduled tasks when no HTTP bot
40
+ * credentials (`API_URL` + `OSSY_API_KEY`) are configured.
41
+ *
42
+ * Implements the surface media and follow-up tasks use: `invoke` and
43
+ * `resources.uploadNamedVersion`, via ActionService + StorageClient.
44
+ *
45
+ * @param {{ req?: { userId?: string, workspaceId?: string } }} [options]
46
+ */
47
+ export function createInProcessSdk (options = {}) {
48
+ const req = options.req ?? { userId: 'system' }
49
+
50
+ const sdk = {
51
+ /**
52
+ * @param {import('@ossy/sdk').ActionRef} action
53
+ * @param {Record<string, unknown>} [payload]
54
+ */
55
+ invoke (action, payload = {}) {
56
+ const actionId = resolveActionId(action)
57
+ return ActionService.invoke(actionId, {
58
+ payload,
59
+ sdk,
60
+ integrations: IntegrationService,
61
+ req,
62
+ })
63
+ },
64
+
65
+ /**
66
+ * Bind actor/workspace from a triggering event for one dispatch.
67
+ * @param {object} event
68
+ */
69
+ withEventContext (event) {
70
+ return createInProcessSdk({ req: reqFromEvent(event) })
71
+ },
72
+
73
+ get resources () {
74
+ return {
75
+ /**
76
+ * Patch named derivative metadata, then write bytes to storage.
77
+ * @param {{ id: string, name: string, file: File | Blob | Buffer | Uint8Array }} args
78
+ */
79
+ async uploadNamedVersion ({ id, name, file }) {
80
+ if (!id) throw new Error('[InProcessSdk] uploadNamedVersion requires id')
81
+ if (!name) throw new Error('[InProcessSdk] uploadNamedVersion requires name')
82
+ if (!file) throw new Error('[InProcessSdk] uploadNamedVersion requires file')
83
+
84
+ const size = typeof file.size === 'number' ? file.size : undefined
85
+ const type = typeof file.type === 'string' ? file.type : undefined
86
+ const buffer = await fileToBuffer(file)
87
+
88
+ const resource = await sdk.invoke('@ossy/resources/actions/upload-named-version', {
89
+ id,
90
+ namedVersion: name,
91
+ // HTTP SDK historically sent `name`; accept either in the task.
92
+ name,
93
+ size: size ?? buffer.length,
94
+ type,
95
+ })
96
+
97
+ // Action response may rewrite `sizes` to read URLs via withResourceMedia;
98
+ // always persist with the canonical derivative key.
99
+ await StorageClient.save(derivativeObjectKey(id, name), buffer)
100
+ return resource
101
+ },
102
+ }
103
+ },
104
+ }
105
+
106
+ return sdk
107
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const invokeMock = jest.fn(async (_id, context) => ({
4
+ id: context.payload.id,
5
+ content: {
6
+ sizes: { [context.payload.namedVersion]: `${context.payload.id}:${context.payload.namedVersion}` },
7
+ uploadUrl: '/local-storage?key=unused',
8
+ },
9
+ }))
10
+
11
+ const saveMock = jest.fn(async () => {})
12
+
13
+ jest.unstable_mockModule('../actions/action.service.js', () => ({
14
+ ActionService: { invoke: invokeMock },
15
+ }))
16
+
17
+ jest.unstable_mockModule('../integration.service.js', () => ({
18
+ IntegrationService: { get: () => null },
19
+ }))
20
+
21
+ jest.unstable_mockModule('../storage/storage.client.js', () => ({
22
+ StorageClient: { save: saveMock },
23
+ }))
24
+
25
+ const { createInProcessSdk } = await import('./in-process-sdk.js')
26
+
27
+ describe('createInProcessSdk', () => {
28
+ it('invokes actions in-process with bound req', async () => {
29
+ invokeMock.mockClear()
30
+ const sdk = createInProcessSdk({
31
+ req: { userId: 'u1', workspaceId: 'ws1' },
32
+ })
33
+
34
+ await sdk.invoke({ id: '@ossy/resources/actions/get' }, { resourceId: 'r1' })
35
+
36
+ expect(invokeMock).toHaveBeenCalledWith(
37
+ '@ossy/resources/actions/get',
38
+ expect.objectContaining({
39
+ payload: { resourceId: 'r1' },
40
+ req: { userId: 'u1', workspaceId: 'ws1' },
41
+ }),
42
+ )
43
+ })
44
+
45
+ it('binds event actor/workspace via withEventContext', async () => {
46
+ invokeMock.mockClear()
47
+ const sdk = createInProcessSdk().withEventContext({
48
+ createdBy: 'author',
49
+ payload: { belongsTo: 'workspace-a' },
50
+ })
51
+
52
+ await sdk.invoke('@ossy/resources/actions/get', { resourceId: 'r2' })
53
+
54
+ expect(invokeMock).toHaveBeenCalledWith(
55
+ '@ossy/resources/actions/get',
56
+ expect.objectContaining({
57
+ req: { userId: 'author', workspaceId: 'workspace-a' },
58
+ }),
59
+ )
60
+ })
61
+
62
+ it('uploadNamedVersion patches then saves bytes', async () => {
63
+ invokeMock.mockClear()
64
+ saveMock.mockClear()
65
+ const sdk = createInProcessSdk()
66
+ const bytes = new Uint8Array([1, 2, 3, 4])
67
+
68
+ await sdk.resources.uploadNamedVersion({
69
+ id: 'file1',
70
+ name: 'thumbnailSmall',
71
+ file: new File([bytes], 'thumbnailSmall', { type: 'image/jpeg' }),
72
+ })
73
+
74
+ expect(invokeMock).toHaveBeenCalledWith(
75
+ '@ossy/resources/actions/upload-named-version',
76
+ expect.objectContaining({
77
+ payload: expect.objectContaining({
78
+ id: 'file1',
79
+ namedVersion: 'thumbnailSmall',
80
+ type: 'image/jpeg',
81
+ }),
82
+ }),
83
+ )
84
+ expect(saveMock).toHaveBeenCalledWith(
85
+ 'file1:thumbnailSmall',
86
+ expect.any(Buffer),
87
+ )
88
+ })
89
+ })
@@ -147,7 +147,10 @@ export class TaskService {
147
147
  static dispatch(event, { sdk } = {}) {
148
148
  if (!event) return
149
149
 
150
- const effectiveSdk = sdk ?? TaskService._sdk
150
+ const baseSdk = sdk ?? TaskService._sdk
151
+ const effectiveSdk = typeof baseSdk?.withEventContext === 'function'
152
+ ? baseSdk.withEventContext(event)
153
+ : baseSdk
151
154
 
152
155
  for (const { metadata, handler } of TaskService._tasks) {
153
156
  const triggers = metadata.triggers ?? []
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Normalize a declarative flow `click` step into a Playwright locator click.
3
+ * Prefer `{ action }` for `data-action` controls; use `click` for structural hooks
4
+ * (e.g. overlay backdrop) that are not action POJOs.
5
+ *
6
+ * @param {unknown} click
7
+ * @returns {{ selector: string, position?: { x: number, y: number } }}
8
+ */
9
+ export function resolveClickTarget (click) {
10
+ if (typeof click === 'string') {
11
+ const selector = click.trim()
12
+ if (!selector) {
13
+ throw new Error('click step requires a non-empty CSS selector')
14
+ }
15
+ return { selector }
16
+ }
17
+
18
+ if (click != null && typeof click === 'object' && !Array.isArray(click)) {
19
+ const selector = typeof click.selector === 'string' ? click.selector.trim() : ''
20
+ if (!selector) {
21
+ throw new Error('click step requires a non-empty CSS selector')
22
+ }
23
+
24
+ const out = { selector }
25
+ if (click.position != null) {
26
+ if (
27
+ typeof click.position !== 'object'
28
+ || Array.isArray(click.position)
29
+ || !Number.isFinite(Number(click.position.x))
30
+ || !Number.isFinite(Number(click.position.y))
31
+ ) {
32
+ throw new Error('click.position requires { x, y } with finite numbers')
33
+ }
34
+ out.position = {
35
+ x: Number(click.position.x),
36
+ y: Number(click.position.y),
37
+ }
38
+ }
39
+ return out
40
+ }
41
+
42
+ throw new Error('click step requires a non-empty CSS selector')
43
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveClickTarget } from './flow-click.js'
3
+
4
+ describe('resolveClickTarget', () => {
5
+ it('accepts a bare selector string', () => {
6
+ expect(resolveClickTarget('[data-overlay]')).toEqual({
7
+ selector: '[data-overlay]',
8
+ })
9
+ })
10
+
11
+ it('accepts { selector } and trims whitespace', () => {
12
+ expect(resolveClickTarget({ selector: ' [data-ossy-mobile-shell-nav-overlay] ' })).toEqual({
13
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
14
+ })
15
+ })
16
+
17
+ it('accepts optional { position: { x, y } }', () => {
18
+ expect(resolveClickTarget({
19
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
20
+ position: { x: 360, y: 200 },
21
+ })).toEqual({
22
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
23
+ position: { x: 360, y: 200 },
24
+ })
25
+ })
26
+
27
+ it('rejects empty or invalid values', () => {
28
+ expect(() => resolveClickTarget('')).toThrow(/non-empty CSS selector/)
29
+ expect(() => resolveClickTarget(' ')).toThrow(/non-empty CSS selector/)
30
+ expect(() => resolveClickTarget(null)).toThrow(/non-empty CSS selector/)
31
+ expect(() => resolveClickTarget({ selector: '' })).toThrow(/non-empty CSS selector/)
32
+ expect(() => resolveClickTarget([])).toThrow(/non-empty CSS selector/)
33
+ expect(() => resolveClickTarget({
34
+ selector: '[data-overlay]',
35
+ position: { x: 'left' },
36
+ })).toThrow(/position requires/)
37
+ expect(() => resolveClickTarget({
38
+ selector: '[data-overlay]',
39
+ position: 10,
40
+ })).toThrow(/position requires/)
41
+ })
42
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Normalize a declarative flow `press` step into a Playwright keyboard key.
3
+ *
4
+ * @param {unknown} press
5
+ * @returns {string}
6
+ */
7
+ export function resolvePressKey (press) {
8
+ if (typeof press === 'string') {
9
+ const key = press.trim()
10
+ if (!key) {
11
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
12
+ }
13
+ return key
14
+ }
15
+
16
+ if (press != null && typeof press === 'object' && !Array.isArray(press)) {
17
+ const key = typeof press.key === 'string' ? press.key.trim() : ''
18
+ if (!key) {
19
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
20
+ }
21
+ return key
22
+ }
23
+
24
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
25
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolvePressKey } from './flow-press.js'
3
+
4
+ describe('resolvePressKey', () => {
5
+ it('accepts a bare key string', () => {
6
+ expect(resolvePressKey('Escape')).toBe('Escape')
7
+ expect(resolvePressKey('Enter')).toBe('Enter')
8
+ expect(resolvePressKey('Tab')).toBe('Tab')
9
+ expect(resolvePressKey('Shift+Tab')).toBe('Shift+Tab')
10
+ })
11
+
12
+ it('accepts { key } and trims whitespace', () => {
13
+ expect(resolvePressKey({ key: ' Escape ' })).toBe('Escape')
14
+ expect(resolvePressKey({ key: ' Shift+Tab ' })).toBe('Shift+Tab')
15
+ })
16
+
17
+ it('rejects empty or invalid values', () => {
18
+ expect(() => resolvePressKey('')).toThrow(/non-empty key/)
19
+ expect(() => resolvePressKey(' ')).toThrow(/non-empty key/)
20
+ expect(() => resolvePressKey(null)).toThrow(/non-empty key/)
21
+ expect(() => resolvePressKey({ key: '' })).toThrow(/non-empty key/)
22
+ expect(() => resolvePressKey({ key: 27 })).toThrow(/non-empty key/)
23
+ expect(() => resolvePressKey([])).toThrow(/non-empty key/)
24
+ })
25
+ })
@@ -19,12 +19,18 @@ import {
19
19
  interpolateContextString,
20
20
  resolveContextValue,
21
21
  } from './flow-context.js'
22
+ import { resolveClickTarget } from './flow-click.js'
23
+ import { resolvePressKey } from './flow-press.js'
24
+ import { resolveViewportSize } from './flow-viewport.js'
22
25
 
23
26
  export {
24
27
  escapeCssAttrValue,
25
28
  interpolateContextString,
26
29
  resolveContextValue,
27
30
  } from './flow-context.js'
31
+ export { resolveClickTarget } from './flow-click.js'
32
+ export { resolvePressKey } from './flow-press.js'
33
+ export { resolveViewportSize } from './flow-viewport.js'
28
34
 
29
35
  const FLOW_PATTERN = /\.flow\.(mjs|cjs|js)$/
30
36
 
@@ -201,6 +207,31 @@ export async function runFlow (flow, options = {}) {
201
207
  continue
202
208
  }
203
209
 
210
+ if (step.viewport != null) {
211
+ if (!page) throw new Error('viewport step requires a Playwright page')
212
+ await page.setViewportSize(resolveViewportSize(step.viewport))
213
+ continue
214
+ }
215
+
216
+ if (step.press != null) {
217
+ if (!page) throw new Error('press step requires a Playwright page')
218
+ await page.keyboard.press(resolvePressKey(step.press))
219
+ continue
220
+ }
221
+
222
+ if (step.click != null) {
223
+ if (!page) throw new Error('click step requires a Playwright page')
224
+ const { selector, position } = resolveClickTarget(step.click)
225
+ const resolvedSelector = interpolateContextString(selector, context, {
226
+ escape: escapeCssAttrValue,
227
+ })
228
+ const locator = page.locator(resolvedSelector).first()
229
+ const clickTimeout = step.timeout ?? 15000
230
+ await locator.waitFor({ state: 'visible', timeout: clickTimeout })
231
+ await locator.click(position ? { position } : undefined)
232
+ continue
233
+ }
234
+
204
235
  if (step.form) {
205
236
  if (!page) throw new Error('form step requires a Playwright page')
206
237
  const formMeta = step.form
@@ -215,10 +246,16 @@ export async function runFlow (flow, options = {}) {
215
246
  }
216
247
  const engine = schemaEngineFromManifest(manifest)
217
248
  const content = mockFormContent(engine, template, context)
249
+ const formTimeout = step.timeout ?? 15000
218
250
  const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
251
+ await formRoot.waitFor({ state: 'visible', timeout: formTimeout })
219
252
  for (const field of template.fields) {
220
253
  const value = content[field.name]
221
- const locator = formRoot.locator(`[name="${field.name}"]`)
254
+ if (value === undefined || value === null) {
255
+ throw new Error(`mockFormContent produced no value for field "${field.name}"`)
256
+ }
257
+ const locator = formRoot.locator(`[name="${field.name}"]`).first()
258
+ await locator.waitFor({ state: 'visible', timeout: formTimeout })
222
259
  const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
223
260
  if (tag === 'select') {
224
261
  await locator.selectOption(String(value))
@@ -227,7 +264,18 @@ export async function runFlow (flow, options = {}) {
227
264
  if (inputType === 'checkbox') {
228
265
  if (value) await locator.check()
229
266
  } else {
230
- await locator.fill(String(value))
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
+ }
231
279
  }
232
280
  } else {
233
281
  await locator.fill(String(value))
@@ -240,10 +288,26 @@ export async function runFlow (flow, options = {}) {
240
288
  if (!page) throw new Error('action step requires a Playwright page')
241
289
  const actionId = resolveActionId(step.action)
242
290
  const service = resolveActionService(step.action, step.service)
243
- const locator = page.locator(actionSelector(actionId, service)).first()
291
+ const matches = page.locator(actionSelector(actionId, service))
244
292
  const actionTimeout = step.timeout ?? 15000
245
- await locator.waitFor({ state: 'visible', timeout: actionTimeout })
246
- await locator.click()
293
+ await matches.first().waitFor({ state: 'attached', timeout: actionTimeout })
294
+ // Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
295
+ const count = await matches.count()
296
+ let clicked = false
297
+ let lastError
298
+ for (let i = 0; i < count; i++) {
299
+ const candidate = matches.nth(i)
300
+ try {
301
+ await candidate.click({ timeout: Math.min(5000, actionTimeout) })
302
+ clicked = true
303
+ break
304
+ } catch (err) {
305
+ lastError = err
306
+ }
307
+ }
308
+ if (!clicked) {
309
+ throw lastError ?? new Error(`No actionable locator for ${actionSelector(actionId, service)}`)
310
+ }
247
311
  continue
248
312
  }
249
313
 
@@ -309,19 +373,37 @@ export async function runFlow (flow, options = {}) {
309
373
 
310
374
  if (step.result) {
311
375
  if (!page) throw new Error('result step requires a Playwright page')
312
- const { text, url, page: pageRef, action, selector, timeout = 8000 } = step.result
376
+ const {
377
+ text,
378
+ url,
379
+ page: pageRef,
380
+ action,
381
+ selector,
382
+ hidden = false,
383
+ timeout = 8000,
384
+ } = step.result
385
+ const assertLocator = async (locator) => {
386
+ if (hidden) {
387
+ await expectFn(locator).toBeHidden({ timeout })
388
+ return
389
+ }
390
+ await expectFn(locator).toBeVisible({ timeout })
391
+ }
313
392
  if (action != null) {
314
393
  const actionId = resolveActionId(action)
315
394
  const service = resolveActionService(action, step.result.service)
316
- await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
395
+ await assertLocator(page.locator(actionSelector(actionId, service)).first())
317
396
  }
318
397
  if (selector != null) {
319
398
  const resolvedSelector = interpolateContextString(selector, context, {
320
399
  escape: escapeCssAttrValue,
321
400
  })
322
- await expectFn(page.locator(resolvedSelector).first()).toBeVisible({ timeout })
401
+ await assertLocator(page.locator(resolvedSelector).first())
323
402
  }
324
403
  if (text != null) {
404
+ if (hidden) {
405
+ throw new Error('result.text cannot use hidden: true — assert via action or selector')
406
+ }
325
407
  const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
326
408
  await expectFn(page.getByText(pattern).first()).toBeVisible({ timeout })
327
409
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Normalize a declarative flow `viewport` step into Playwright setViewportSize input.
3
+ *
4
+ * @param {unknown} viewport
5
+ * @returns {{ width: number, height: number }}
6
+ */
7
+ export function resolveViewportSize (viewport) {
8
+ if (viewport == null || typeof viewport !== 'object' || Array.isArray(viewport)) {
9
+ throw new Error('viewport step requires { width, height } with positive numbers')
10
+ }
11
+
12
+ const width = Number(viewport.width)
13
+ const height = Number(viewport.height)
14
+
15
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
16
+ throw new Error('viewport step requires { width, height } with positive numbers')
17
+ }
18
+
19
+ return {
20
+ width: Math.round(width),
21
+ height: Math.round(height),
22
+ }
23
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveViewportSize } from './flow-viewport.js'
3
+
4
+ describe('resolveViewportSize', () => {
5
+ it('accepts positive width/height', () => {
6
+ expect(resolveViewportSize({ width: 390, height: 844 })).toEqual({
7
+ width: 390,
8
+ height: 844,
9
+ })
10
+ })
11
+
12
+ it('rounds fractional dimensions', () => {
13
+ expect(resolveViewportSize({ width: 389.6, height: 843.2 })).toEqual({
14
+ width: 390,
15
+ height: 843,
16
+ })
17
+ })
18
+
19
+ it('rejects missing or non-positive sizes', () => {
20
+ expect(() => resolveViewportSize(null)).toThrow(/width, height/)
21
+ expect(() => resolveViewportSize({ width: 390 })).toThrow(/width, height/)
22
+ expect(() => resolveViewportSize({ width: 0, height: 844 })).toThrow(/width, height/)
23
+ expect(() => resolveViewportSize({ width: -1, height: 844 })).toThrow(/width, height/)
24
+ expect(() => resolveViewportSize('mobile')).toThrow(/width, height/)
25
+ })
26
+ })
@@ -17,8 +17,16 @@
17
17
  /** @type {import('@playwright/test').PlaywrightTestConfig} */
18
18
  export default {
19
19
  retries: process.env.CI ? 1 : 0,
20
+ // Fail hung flow steps instead of sitting on the default 30s forever across many actions.
21
+ timeout: process.env.CI ? 60_000 : 30_000,
22
+ expect: {
23
+ timeout: process.env.CI ? 15_000 : 5_000,
24
+ },
20
25
  use: {
21
26
  baseURL: process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002',
27
+ trace: process.env.CI ? 'on-first-retry' : 'off',
28
+ actionTimeout: process.env.CI ? 15_000 : 0,
29
+ navigationTimeout: process.env.CI ? 30_000 : 0,
22
30
  },
23
31
  reporter: [['list']],
24
32
  env: {
@@ -24,7 +24,7 @@ export class TokenService {
24
24
 
25
25
  if (!token) {
26
26
  log.debug('[TokenService] No token to verify')
27
- return reject(new Error('No token'))
27
+ return reject(Object.assign(new Error('No token'), { status: 401 }))
28
28
  }
29
29
 
30
30
  jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] }, (error, payload) => {
@@ -32,7 +32,12 @@ export class TokenService {
32
32
 
33
33
  if (errorType) {
34
34
  log.error('[TokenService]: Token invalid', undefined, error)
35
- return reject()
35
+ // Callers (e.g. POST /actions) use `err.status ?? 500` — always attach 401
36
+ // so unverifiable auth tokens are not reported as internal errors (#494).
37
+ return reject(Object.assign(new Error('Invalid or expired token'), {
38
+ status: 401,
39
+ cause: error,
40
+ }))
36
41
  }
37
42
 
38
43
  log.info('[TokenService]: Token verified')
@@ -0,0 +1,49 @@
1
+ import { beforeAll, describe, expect, it } from '@jest/globals'
2
+ import jwt from 'jsonwebtoken'
3
+ import { TokenService } from './token.service.js'
4
+ import { ConfigService } from './config.service.js'
5
+
6
+ describe('TokenService.verify', () => {
7
+ beforeAll(() => {
8
+ process.env.TOKEN_SECRET = process.env.TOKEN_SECRET || 'test-token-secret-for-verify'
9
+ })
10
+
11
+ it('resolves a valid HS256 token payload', async () => {
12
+ const token = jwt.sign({ sub: 'user-1', type: 'WebAuth' }, ConfigService.TokenSecret, {
13
+ algorithm: 'HS256',
14
+ expiresIn: '1h',
15
+ })
16
+ await expect(TokenService.verify(token)).resolves.toMatchObject({
17
+ sub: 'user-1',
18
+ type: 'WebAuth',
19
+ })
20
+ })
21
+
22
+ it('rejects missing tokens with status 401', async () => {
23
+ await expect(TokenService.verify('')).rejects.toMatchObject({
24
+ message: 'No token',
25
+ status: 401,
26
+ })
27
+ await expect(TokenService.verify(null)).rejects.toMatchObject({
28
+ status: 401,
29
+ })
30
+ })
31
+
32
+ it('rejects garbage tokens with status 401 (not an empty rejection)', async () => {
33
+ await expect(TokenService.verify('not-a-jwt')).rejects.toMatchObject({
34
+ message: 'Invalid or expired token',
35
+ status: 401,
36
+ })
37
+ })
38
+
39
+ it('rejects expired tokens with status 401', async () => {
40
+ const token = jwt.sign({ sub: 'user-1' }, ConfigService.TokenSecret, {
41
+ algorithm: 'HS256',
42
+ expiresIn: -10,
43
+ })
44
+ await expect(TokenService.verify(token)).rejects.toMatchObject({
45
+ message: 'Invalid or expired token',
46
+ status: 401,
47
+ })
48
+ })
49
+ })