@ossy/platform 3.2.0 → 3.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,7 +51,7 @@
51
51
  "@ossy/observability": "^3.0.9",
52
52
  "@ossy/policies": "^3.0.9",
53
53
  "@ossy/schema": "^3.0.9",
54
- "@ossy/sdk": "^3.2.0",
54
+ "@ossy/sdk": "^3.3.0",
55
55
  "@ossy/tokens": "^3.0.9",
56
56
  "@ossy/users": "^3.2.0",
57
57
  "@ossy/workspaces": "^3.2.0",
@@ -74,5 +74,5 @@
74
74
  "src",
75
75
  "Dockerfile"
76
76
  ],
77
- "gitHead": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
77
+ "gitHead": "19e8bae4c1e9df79270218f28c0c9391f4f9871c"
78
78
  }
@@ -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
@@ -309,19 +340,37 @@ export async function runFlow (flow, options = {}) {
309
340
 
310
341
  if (step.result) {
311
342
  if (!page) throw new Error('result step requires a Playwright page')
312
- const { text, url, page: pageRef, action, selector, timeout = 8000 } = step.result
343
+ const {
344
+ text,
345
+ url,
346
+ page: pageRef,
347
+ action,
348
+ selector,
349
+ hidden = false,
350
+ timeout = 8000,
351
+ } = step.result
352
+ const assertLocator = async (locator) => {
353
+ if (hidden) {
354
+ await expectFn(locator).toBeHidden({ timeout })
355
+ return
356
+ }
357
+ await expectFn(locator).toBeVisible({ timeout })
358
+ }
313
359
  if (action != null) {
314
360
  const actionId = resolveActionId(action)
315
361
  const service = resolveActionService(action, step.result.service)
316
- await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
362
+ await assertLocator(page.locator(actionSelector(actionId, service)).first())
317
363
  }
318
364
  if (selector != null) {
319
365
  const resolvedSelector = interpolateContextString(selector, context, {
320
366
  escape: escapeCssAttrValue,
321
367
  })
322
- await expectFn(page.locator(resolvedSelector).first()).toBeVisible({ timeout })
368
+ await assertLocator(page.locator(resolvedSelector).first())
323
369
  }
324
370
  if (text != null) {
371
+ if (hidden) {
372
+ throw new Error('result.text cannot use hidden: true — assert via action or selector')
373
+ }
325
374
  const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
326
375
  await expectFn(page.getByText(pattern).first()).toBeVisible({ timeout })
327
376
  }
@@ -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
+ })