From f94a1752e1023058a0555b6be201977254825da3 Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Fri, 17 Apr 2026 09:14:17 -0600 Subject: [PATCH 1/5] add payload coverage workflow and add encryption to codec server --- package.json | 3 +- scripts/{workflows.ts => e2e-workflows.ts} | 0 scripts/payload-coverage-workflow.ts | 13 ++ temporal/PAYLOAD_COVERAGE.md | 86 ++++++++++ temporal/activities/complex.ts | 32 ++++ temporal/activities/index.ts | 1 + temporal/client.ts | 44 ++++- temporal/codec-server.ts | 42 ++++- temporal/crypto.ts | 42 +++++ temporal/data-converter.ts | 10 +- temporal/encryption-codec.ts | 93 +++++++++++ temporal/payload-codec.ts | 34 ---- temporal/workers.ts | 2 +- temporal/workflows.ts | 184 +++++++++++++++++++++ 14 files changed, 542 insertions(+), 44 deletions(-) rename scripts/{workflows.ts => e2e-workflows.ts} (100%) create mode 100644 scripts/payload-coverage-workflow.ts create mode 100644 temporal/PAYLOAD_COVERAGE.md create mode 100644 temporal/activities/complex.ts create mode 100644 temporal/crypto.ts create mode 100644 temporal/encryption-codec.ts delete mode 100644 temporal/payload-codec.ts diff --git a/package.json b/package.json index a07d3df26f..6afc08cc89 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "stylelint": "stylelint \"src/**/*.{css,postcss,svelte}\"", "stylelint:fix": "stylelint --fix \"src/**/*.{css,postcss,svelte}\"", "generate:locales": "esno scripts/generate-locales.ts", - "workflows": "esno scripts/workflows.ts", + "e2e-workflows": "esno scripts/e2e-workflows.ts", + "payload-coverage-workflow": "esno scripts/payload-coverage-workflow.ts", "audit:tailwind": "esno scripts/audit-tailwind-colors", "audit:holocene-props": "esno scripts/generate-holocene-props.ts", "validate:versions": "./scripts/validate-versions.sh" diff --git a/scripts/workflows.ts b/scripts/e2e-workflows.ts similarity index 100% rename from scripts/workflows.ts rename to scripts/e2e-workflows.ts diff --git a/scripts/payload-coverage-workflow.ts b/scripts/payload-coverage-workflow.ts new file mode 100644 index 0000000000..acac55f459 --- /dev/null +++ b/scripts/payload-coverage-workflow.ts @@ -0,0 +1,13 @@ +import { connect, startPayloadCoverageWorkflow } from '../temporal/client'; +import { runWorkerUntil } from '../temporal/workers'; + +async function main() { + const client = await connect(); + const result = startPayloadCoverageWorkflow(client); + await runWorkerUntil(result); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/temporal/PAYLOAD_COVERAGE.md b/temporal/PAYLOAD_COVERAGE.md new file mode 100644 index 0000000000..2e3537b752 --- /dev/null +++ b/temporal/PAYLOAD_COVERAGE.md @@ -0,0 +1,86 @@ +# PayloadCoverageWorkflow — Design Notes + +## Context + +The `` component in `src/lib/components/payload/payload.svelte` decodes and renders Temporal Payloads from event history. To test all display/decode paths in the UI, we need a Temporal workflow that exercises every event type that produces a Payload field. The workflow is added to the existing `temporal/` directory which already has a worker, client, codec, and 4 existing workflows. + +## Files + +- `temporal/activities/complex.ts` — new activity with rich input/output types +- `temporal/activities/index.ts` — exports the new `complex` activity +- `temporal/workflows.ts` — adds `PayloadCoverageChildWorkflow` and `PayloadCoverageWorkflow` +- `temporal/client.ts` — adds `startPayloadCoverageWorkflow` helper for easy testing + +## Payload-Producing Event Types Covered + +| Event / Location | Payload Field | +| ------------------------------------------- | -------------------------------------------------------------------------- | +| `WorkflowExecutionStarted` | `input` — rich object with all JSON primitive types; `memo` — set at start | +| `ActivityTaskScheduled` | `input` — complex object with nested arrays/objects | +| `ActivityTaskCompleted` | `result` — complex return object | +| `ActivityTaskFailed` | `failure.details` — from deliberately failing activity variant | +| `ActivityTaskScheduled` (local) | inside `MarkerRecorded` details | +| `ActivityTaskCompleted` (local) | inside `MarkerRecorded` details | +| `WorkflowExecutionSignaled` | `input` — two signals with typed payloads | +| `WorkflowExecutionUpdateAccepted/Completed` | `input` / `result` | +| `UpsertWorkflowSearchAttributes` | search attribute value Payloads | +| `StartChildWorkflowExecutionInitiated` | `input` | +| `ChildWorkflowExecutionCompleted` | `result` | +| `WorkflowExecutionCompleted` | `result` — aggregate result object | +| Query `get-status` | runtime query response (visible in UI query tab) | +| Query `get-field` | parameterized query response | + +## Workflow Behavior + +- **Starts and runs activities/child workflow immediately** — all Payload events are created on start +- **Waits up to 5 minutes** for signals/queries/updates via `condition` — gives time for manual UI testing +- **Auto-completes** after the timeout, so you also see `WorkflowExecutionCompleted` with result payload +- **Failing activity** is wrapped in try/catch — workflow continues after `ActivityTaskFailed` is recorded + +## Running It + +```bash +# Register custom search attributes (one-time, local dev server) +temporal operator search-attribute create --name CustomKeywordField --type Keyword +temporal operator search-attribute create --name CustomIntField --type Int +``` + +Then call `startPayloadCoverageWorkflow(client)` from `client.ts`. The workflow starts at ID `payload-coverage-workflow`. + +## Interacting During the 5-Minute Window + +**Send a signal:** + +```bash +temporal workflow signal \ + --workflow-id payload-coverage-workflow \ + --name add-data \ + --input '{"key":"testKey","value":{"nested":true}}' + +temporal workflow signal \ + --workflow-id payload-coverage-workflow \ + --name trigger \ + --input '["tagA","tagB"]' +``` + +**Send an update:** + +```bash +temporal workflow update \ + --workflow-id payload-coverage-workflow \ + --name process-update \ + --input '{"operation":"transform","payload":{"x":1,"y":2}}' +``` + +**Run a query:** + +```bash +temporal workflow query \ + --workflow-id payload-coverage-workflow \ + --type get-status + +temporal workflow query \ + --workflow-id payload-coverage-workflow \ + --type get-field \ + --input '"stringField"' +``` diff --git a/temporal/activities/complex.ts b/temporal/activities/complex.ts new file mode 100644 index 0000000000..13187de623 --- /dev/null +++ b/temporal/activities/complex.ts @@ -0,0 +1,32 @@ +import { ApplicationFailure } from '@temporalio/activity'; + +export interface ComplexActivityInput { + strings: string[]; + numbers: number[]; + nested: { key: string; count: number; tags: string[] }; + flag: boolean; + nullable: null; + shouldFail?: boolean; +} + +export interface ComplexActivityResult { + processed: ComplexActivityInput; + summary: string; + timestamp: number; +} + +export async function complex( + input: ComplexActivityInput, +): Promise { + if (input.shouldFail) { + throw ApplicationFailure.create({ + message: 'Deliberate failure for Payload coverage', + details: [{ reason: 'test', input }], + }); + } + return { + processed: input, + summary: `Processed ${input.strings.length} strings and ${input.numbers.length} numbers`, + timestamp: Date.now(), + }; +} diff --git a/temporal/activities/index.ts b/temporal/activities/index.ts index eb9f6dccd3..3463d186ec 100644 --- a/temporal/activities/index.ts +++ b/temporal/activities/index.ts @@ -1,2 +1,3 @@ export { default as echo } from './echo'; export { default as double } from './double'; +export { complex } from './complex'; diff --git a/temporal/client.ts b/temporal/client.ts index 1f69d0b40e..91e6f5a9f2 100644 --- a/temporal/client.ts +++ b/temporal/client.ts @@ -4,6 +4,8 @@ import { getDataConverter } from './data-converter'; import { BlockingWorkflow, CompletedWorkflow, + type PayloadCoverageResult, + PayloadCoverageWorkflow, RunningWorkflow, Workflow, } from './workflows'; @@ -21,7 +23,7 @@ export const connect = async () => { const client = new Client({ connection, - dataConverter: getDataConverter(), + dataConverter: await getDataConverter(), }); return client; @@ -61,6 +63,46 @@ export const startWorkflows = async ( return Promise.all([wf1.result(), wf3.result()]); }; +export const startPayloadCoverageWorkflow = async ( + client: Client, +): Promise => { + const wf = await client.workflow.start(PayloadCoverageWorkflow, { + taskQueue: 'e2e-1', + workflowId: 'payload-coverage-workflow', + args: [ + { + stringField: 'hello world', + numberField: 42, + floatField: 3.14159, + booleanField: true, + nullField: null, + arrayOfStrings: ['alpha', 'beta', 'gamma'], + arrayOfNumbers: [1, 2, 3, 100, -7], + mixedArray: ['text', 99, false, null, 'more'], + nestedObject: { + level1: { + level2: 'deep value', + array: [10, 20, 30], + }, + flag: false, + }, + emptyObject: {}, + emptyArray: [], + }, + ], + memo: { + description: 'Payload coverage test workflow', + tags: ['e2e', 'payload', 'coverage'], + version: 1, + testData: { nested: { value: 42 }, active: true }, + }, + }); + + workflows.push(wf); + console.log(`✨ started payload-coverage-workflow (${wf.workflowId})`); + return wf.result(); +}; + export const stopWorkflows = (): Promise => { return Promise.all( workflows.map(async (workflow) => { diff --git a/temporal/codec-server.ts b/temporal/codec-server.ts index 7f5ba06bcd..9800f6fd4d 100644 --- a/temporal/codec-server.ts +++ b/temporal/codec-server.ts @@ -1,9 +1,12 @@ import type { Server } from 'http'; +import type { Payload } from '@temporalio/common'; import cors from 'cors'; import type { Application } from 'express'; import express from 'express'; +import { EncryptionCodec } from './encryption-codec'; + export type CodecServer = { start: () => Promise; stop: () => Promise; @@ -27,11 +30,40 @@ const PORT = 8888; let codecServer: CodecServer; export const getCodecServer = (): CodecServer => codecServer; +function fromJSON({ metadata, data }: JSONPayload): Payload { + return { + metadata: + metadata && + Object.fromEntries( + Object.entries(metadata).map(([k, v]): [string, Uint8Array] => [ + k, + Buffer.from(v, 'base64'), + ]), + ), + data: data ? Buffer.from(data, 'base64') : undefined, + }; +} + +function toJSON({ metadata, data }: Payload): JSONPayload { + return { + metadata: + metadata && + Object.fromEntries( + Object.entries(metadata).map(([k, v]): [string, string] => [ + k, + Buffer.from(v).toString('base64'), + ]), + ), + data: data ? Buffer.from(data).toString('base64') : undefined, + }; +} + export async function createCodecServer( { port }: CodecServerOptions = { port: PORT }, ): Promise { let server: Server; const app: Application = express(); + const codec = await EncryptionCodec.create('test-key-id'); app.use(cors({ allowedHeaders: ['x-namespace', 'content-type'] })); app.use(express.json()); @@ -39,7 +71,10 @@ export async function createCodecServer( app.post('/encode', async (req, res) => { try { const { payloads: raw } = req.body as Body; - res.json({ payloads: raw }).end(); + const decoded = raw.map(fromJSON); + const encoded = await codec.encode(decoded); + const payloads = encoded.map(toJSON); + res.json({ payloads }).end(); } catch (err) { console.error('Error in /encode', err); res.status(500).end('Internal server error'); @@ -49,7 +84,10 @@ export async function createCodecServer( app.post('/decode', async (req, res) => { try { const { payloads: raw } = req.body as Body; - res.json({ payloads: raw }).end(); + const encoded = raw.map(fromJSON); + const decoded = await codec.decode(encoded); + const payloads = decoded.map(toJSON); + res.json({ payloads }).end(); } catch (err) { console.error('Error in /decode', err); res.status(500).end('Internal server error'); diff --git a/temporal/crypto.ts b/temporal/crypto.ts new file mode 100644 index 0000000000..ef84752202 --- /dev/null +++ b/temporal/crypto.ts @@ -0,0 +1,42 @@ +import { webcrypto as crypto } from 'node:crypto'; + +const CIPHER = 'AES-GCM'; +const IV_LENGTH_BYTES = 12; +const TAG_LENGTH_BYTES = 16; + +export async function encrypt( + data: Uint8Array, + key: crypto.CryptoKey, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const encrypted = await crypto.subtle.encrypt( + { + name: CIPHER, + iv, + tagLength: TAG_LENGTH_BYTES * 8, + }, + key, + data, + ); + + return Buffer.concat([iv, new Uint8Array(encrypted)]); +} + +export async function decrypt( + encryptedData: Uint8Array, + key: crypto.CryptoKey, +): Promise { + const iv = encryptedData.subarray(0, IV_LENGTH_BYTES); + const ciphertext = encryptedData.subarray(IV_LENGTH_BYTES); + const decrypted = await crypto.subtle.decrypt( + { + name: CIPHER, + iv, + tagLength: TAG_LENGTH_BYTES * 8, + }, + key, + ciphertext, + ); + + return new Uint8Array(decrypted); +} diff --git a/temporal/data-converter.ts b/temporal/data-converter.ts index 55d9f0f8f5..9251915ac8 100644 --- a/temporal/data-converter.ts +++ b/temporal/data-converter.ts @@ -1,18 +1,18 @@ import type { DataConverter } from '@temporalio/common'; -import { PayloadCodec } from './payload-codec'; +import { EncryptionCodec } from './encryption-codec'; let dataConverter: DataConverter; -export function getDataConverter(): DataConverter { +export async function getDataConverter(): Promise { if (!dataConverter) { - dataConverter = createDataConverter(); + dataConverter = await createDataConverter(); } return dataConverter; } -function createDataConverter(): DataConverter { +async function createDataConverter(): Promise { return { - payloadCodecs: [new PayloadCodec()], + payloadCodecs: [await EncryptionCodec.create('test-key-id')], }; } diff --git a/temporal/encryption-codec.ts b/temporal/encryption-codec.ts new file mode 100644 index 0000000000..2a29b6cbc6 --- /dev/null +++ b/temporal/encryption-codec.ts @@ -0,0 +1,93 @@ +import { webcrypto as crypto } from 'node:crypto'; + +import { + METADATA_ENCODING_KEY, + type Payload, + type PayloadCodec, + ValueError, +} from '@temporalio/common'; +import { decode, encode } from '@temporalio/common/lib/encoding'; +import temporal from '@temporalio/proto'; + +import { decrypt, encrypt } from './crypto'; + +const ENCODING = 'binary/encrypted'; +const METADATA_ENCRYPTION_KEY_ID = 'encryption-key-id'; + +export class EncryptionCodec implements PayloadCodec { + constructor( + protected readonly keys: Map, + protected readonly defaultKeyId: string, + ) {} + + static async create(keyId: string): Promise { + const keys = new Map(); + keys.set(keyId, await fetchKey(keyId)); + return new this(keys, keyId); + } + + async encode(payloads: Payload[]): Promise { + return Promise.all( + payloads.map(async (payload) => ({ + metadata: { + [METADATA_ENCODING_KEY]: encode(ENCODING), + [METADATA_ENCRYPTION_KEY_ID]: encode(this.defaultKeyId), + }, + data: await encrypt( + temporal.temporal.api.common.v1.Payload.encode(payload).finish(), + this.keys.get(this.defaultKeyId)!, + ), + })), + ); + } + + async decode(payloads: Payload[]): Promise { + return Promise.all( + payloads.map(async (payload) => { + if ( + !payload.metadata || + decode(payload.metadata[METADATA_ENCODING_KEY]) !== ENCODING + ) { + return payload; + } + if (!payload.data) { + throw new ValueError('Payload data is missing'); + } + + const keyIdBytes = payload.metadata[METADATA_ENCRYPTION_KEY_ID]; + if (!keyIdBytes) { + throw new ValueError( + 'Unable to decrypt Payload without encryption key id', + ); + } + + const keyId = decode(keyIdBytes); + let key = this.keys.get(keyId); + if (!key) { + key = await fetchKey(keyId); + this.keys.set(keyId, key); + } + const decryptedPayloadBytes = await decrypt(payload.data, key); + console.log('Decrypting payload.data:', payload.data); + return temporal.temporal.api.common.v1.Payload.decode( + decryptedPayloadBytes, + ); + }), + ); + } +} + +async function fetchKey(_keyId: string): Promise { + const key = Buffer.from('test-key-test-key-test-key-test!'); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { + name: 'AES-GCM', + }, + true, + ['encrypt', 'decrypt'], + ); + + return cryptoKey; +} diff --git a/temporal/payload-codec.ts b/temporal/payload-codec.ts deleted file mode 100644 index 4c4fab173f..0000000000 --- a/temporal/payload-codec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { - Payload, - PayloadCodec as TPayloadCodec, -} from '@temporalio/common'; - -export class PayloadCodec implements TPayloadCodec { - async encode(payloads: Payload[]): Promise { - return payloads; - // return Promise.all( - // payloads.map(async (payload) => { - // return { - // metadata: { - // [METADATA_ENCODING_KEY]: encode(ENCODING), - // }, - // data: payload.data, - // }; - // }), - // ); - } - - async decode(payloads: Payload[]): Promise { - return payloads; - // return Promise.all( - // payloads.map(async (payload) => { - // return { - // metadata: { - // [METADATA_ENCODING_KEY]: encode(ENCODING), - // }, - // data: payload.data, - // }; - // }), - // ); - } -} diff --git a/temporal/workers.ts b/temporal/workers.ts index 93583f821f..60ffa6ee48 100644 --- a/temporal/workers.ts +++ b/temporal/workers.ts @@ -15,7 +15,7 @@ let worker: Worker; const createWorker = async (): Promise => { return Worker.create({ - dataConverter: getDataConverter(), + dataConverter: await getDataConverter(), workflowsPath: require.resolve('./workflows'), activities, taskQueue: 'e2e-1', diff --git a/temporal/workflows.ts b/temporal/workflows.ts index e27d5b757f..0994ad76c9 100644 --- a/temporal/workflows.ts +++ b/temporal/workflows.ts @@ -1,6 +1,7 @@ import * as workflow from '@temporalio/workflow'; import type * as activities from './activities'; +import type { ComplexActivityResult } from './activities/complex'; const { echo: Activity } = workflow.proxyActivities({ startToCloseTimeout: '10 seconds', @@ -63,3 +64,186 @@ export async function CompletedWorkflow( export async function RunningWorkflow(): Promise { return await workflow.sleep('10 days'); } + +// --- PayloadCoverageWorkflow --- + +interface PayloadCoverageInput { + stringField: string; + numberField: number; + floatField: number; + booleanField: boolean; + nullField: null; + arrayOfStrings: string[]; + arrayOfNumbers: number[]; + mixedArray: (string | number | boolean | null)[]; + nestedObject: { + level1: { + level2: string; + array: number[]; + }; + flag: boolean; + }; + emptyObject: Record; + emptyArray: never[]; +} + +interface ChildWorkflowInput { + message: string; + parentInput: PayloadCoverageInput; +} + +interface ChildWorkflowResult { + echoed: string; + activityResult: string; + receivedInput: ChildWorkflowInput; +} + +export interface PayloadCoverageResult { + received: PayloadCoverageInput; + localActivityResult: string; + activityResult: ComplexActivityResult; + childWorkflowResult: ChildWorkflowResult; + signalCount: number; + accumulatedData: Record; + timedOut: boolean; + completedAt: string; +} + +const { complex: complexActivity } = workflow.proxyActivities< + typeof activities +>({ + startToCloseTimeout: '30 seconds', +}); + +const { complex: failingActivity } = workflow.proxyActivities< + typeof activities +>({ + startToCloseTimeout: '30 seconds', + retry: { maximumAttempts: 1 }, +}); + +const addDataSignal = + workflow.defineSignal<[{ key: string; value: unknown }]>('add-data'); + +const triggerSignal = workflow.defineSignal<[string[]]>('trigger'); + +const getStatusQuery = workflow.defineQuery<{ + status: string; + data: Record; + count: number; +}>('get-status'); + +const getFieldQuery = workflow.defineQuery('get-field'); + +const processUpdate = workflow.defineUpdate< + { processed: boolean; echo: unknown }, + [{ operation: string; payload: unknown }] +>('process-update'); + +export async function PayloadCoverageChildWorkflow( + input: ChildWorkflowInput, +): Promise { + const activityResult = await Activity(input.message); + return { + echoed: `child echoed: ${input.message}`, + activityResult, + receivedInput: input, + }; +} + +export async function PayloadCoverageWorkflow( + input: PayloadCoverageInput, +): Promise { + let signalCount = 0; + let triggered = false; + const accumulatedData: Record = {}; + + workflow.setHandler(addDataSignal, ({ key, value }) => { + accumulatedData[key] = value; + signalCount++; + }); + + workflow.setHandler(triggerSignal, (tags) => { + accumulatedData['triggerTags'] = tags; + signalCount++; + triggered = true; + }); + + workflow.setHandler(getStatusQuery, () => ({ + status: triggered ? 'triggered' : 'waiting', + data: accumulatedData, + count: signalCount, + })); + + workflow.setHandler(getFieldQuery, (field) => { + if (field in input) + return (input as unknown as Record)[field]; + return accumulatedData[field] ?? null; + }); + + workflow.setHandler(processUpdate, ({ operation, payload }) => ({ + processed: true, + echo: { + operation, + payload, + handledAt: workflow.workflowInfo().historyLength, + }, + })); + + workflow.upsertSearchAttributes({ + CustomKeywordField: ['payload-coverage'], + CustomIntField: [1], + }); + + const localActivityResult = await LocalActivity( + JSON.stringify({ type: 'local', input }), + ); + + const activityResult = await complexActivity({ + strings: input.arrayOfStrings, + numbers: input.arrayOfNumbers, + nested: { + key: input.nestedObject.level1.level2, + count: input.nestedObject.level1.array.length, + tags: input.arrayOfStrings, + }, + flag: input.booleanField, + nullable: null, + }); + + try { + await failingActivity({ + strings: ['fail'], + numbers: [0], + nested: { key: 'error-path', count: 0, tags: [] }, + flag: false, + nullable: null, + shouldFail: true, + }); + } catch { + accumulatedData['activityFailureRecorded'] = true; + } + + const childWorkflowResult = await workflow.executeChild( + PayloadCoverageChildWorkflow, + { + args: [ + { message: 'hello from PayloadCoverageWorkflow', parentInput: input }, + ], + workflowId: workflow.workflowInfo().workflowId + '-child', + }, + ); + + const timedOut = !(await workflow.condition(() => triggered, '5 minutes')); + + return { + received: input, + localActivityResult, + activityResult, + childWorkflowResult, + signalCount, + accumulatedData, + timedOut, + completedAt: new Date().toISOString(), + }; +} From 55ca4400286c6a7c1ff4ec099fb0ed709ee80a7a Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Fri, 17 Apr 2026 13:16:41 -0600 Subject: [PATCH 2/5] test(e2e): add payload decoder tests covering all payload touchpoints Start payload-coverage-workflow fire-and-forget in global setup so the workflow remains running when tests execute, enabling signal and query interaction. Add payload-decoder.desktop.spec.ts covering: - Workflow input decoded via cluster codec - Memo payloads decoded - Search attributes decoded - Event history: WorkflowExecutionStarted, ActivityTaskScheduled, ActivityTaskCompleted, ActivityTaskFailed, StartChildWorkflowExecutionInitiated, ChildWorkflowExecutionCompleted - Query results (get-status, get-field) - Send signal and verify decoded payload in history - Call stack stack trace Use getByRole('textbox') within event-summary-row-expanded to access CodeMirror contentDOM directly, matching the pattern used by existing query and call-stack tests. --- tests/e2e/payload-decoder.desktop.spec.ts | 193 ++++++++++++++++++++++ tests/global-setup.ts | 6 +- 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/payload-decoder.desktop.spec.ts diff --git a/tests/e2e/payload-decoder.desktop.spec.ts b/tests/e2e/payload-decoder.desktop.spec.ts new file mode 100644 index 0000000000..779f4ac568 --- /dev/null +++ b/tests/e2e/payload-decoder.desktop.spec.ts @@ -0,0 +1,193 @@ +import { expect, test } from '@playwright/test'; + +const WORKFLOW_ID = 'payload-coverage-workflow'; + +test.describe('Payload Decoder', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(baseURL); + await page.getByRole('link', { name: WORKFLOW_ID, exact: true }).click(); + }); + + test('workflow input is decoded', async ({ page }) => { + test.slow(); + const inputAndResult = page.getByTestId('input-and-result'); + await expect(inputAndResult).toContainText('hello world'); + await expect(inputAndResult).toContainText('numberField'); + await expect(inputAndResult).toContainText('3.14159'); + await expect(inputAndResult).toContainText('arrayOfStrings'); + }); + + test('memo payloads are decoded', async ({ page }) => { + test.slow(); + await page.getByTestId('memo-tab').click(); + const main = page.locator('main'); + await expect(main).toContainText('Payload coverage test workflow'); + await expect(main).toContainText('e2e'); + await expect(main).toContainText('coverage'); + await expect(main).toContainText('testData'); + }); + + test('search attributes are decoded', async ({ page }) => { + await page.getByTestId('search-attributes-tab').click(); + const main = page.locator('main'); + await expect(main).toContainText('payload-coverage'); + await expect(main).toContainText('CustomKeywordField'); + }); + + test('event history WorkflowExecutionStarted shows decoded input', async ({ + page, + }) => { + test.slow(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const startedRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Workflow Execution Started' }) + .first(); + await startedRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + const editor = expandedRow.getByRole('textbox').first(); + await expect(editor).toContainText('hello world'); + await expect(editor).toContainText('stringField'); + await expect(editor).toContainText('arrayOfStrings'); + }); + + test('event history ActivityTaskCompleted shows decoded result', async ({ + page, + }) => { + test.slow(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const completedRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Activity Task Completed' }) + .first(); + await completedRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + + const input = expandedRow.getByRole('textbox').first(); + await expect(input).toContainText('alpha'); + await expect(input).toContainText('beta'); + await expect(input).toContainText('gamma'); + + const result = expandedRow.getByRole('textbox').nth(1); + await expect(result).toContainText('processed'); + }); + + test('event history ActivityTaskFailed shows expanded event details', async ({ + page, + }) => { + test.slow(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const failedRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Activity Task Failed' }) + .first(); + await failedRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + await expect(expandedRow).toBeVisible(); + await expect(expandedRow).toContainText('Activity Task Failed'); + }); + + test('event history StartChildWorkflowExecutionInitiated shows decoded input', async ({ + page, + }) => { + test.slow(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const childInitiatedRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Start Child Workflow Execution Initiated' }) + .first(); + await childInitiatedRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + const editor = expandedRow.getByRole('textbox').first(); + await expect(editor).toContainText('hello from PayloadCoverageWorkflow'); + }); + + test('event history ChildWorkflowExecutionCompleted shows decoded result', async ({ + page, + }) => { + test.slow(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const childCompletedRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Child Workflow Execution Completed' }) + .first(); + await childCompletedRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + const input = expandedRow.getByRole('textbox').first(); + await expect(input).toContainText('hello from PayloadCoverageWorkflow'); + const result = expandedRow.getByRole('textbox').nth(1); + await expect(result).toContainText( + 'child echoed: hello from PayloadCoverageWorkflow', + ); + }); + + test('query get-status returns decoded result', async ({ page }) => { + await page.getByTestId('queries-tab').click(); + await page.getByTestId('query-select-button').click(); + await page.getByRole('option', { name: 'get-status' }).click(); + await page.getByRole('button', { name: /query/i }).click(); + const result = page.getByTestId('query-result').getByRole('textbox'); + await expect(result).toContainText('status'); + await expect(result).toContainText('count'); + await expect(result).toContainText('data'); + }); + + test('query get-field returns decoded field value', async ({ page }) => { + await page.getByTestId('queries-tab').click(); + await page.getByTestId('query-select-button').click(); + await page.getByRole('option', { name: 'get-field' }).click(); + + const payloadInput = page.getByTestId('query-input').getByRole('textbox'); + + await payloadInput.focus(); + await payloadInput.fill('"stringField"'); + await page.getByRole('button', { name: /query/i }).click(); + const result = page.getByTestId('query-result').getByRole('textbox'); + await expect(result).toContainText('hello world'); + }); + + test('send signal and verify decoded signal payload in history', async ({ + page, + }) => { + test.slow(); + await page.getByRole('button', { name: 'More Actions' }).click(); + await page.getByRole('menuitem', { name: 'Send a Signal' }).click(); + + const modal = page.getByTestId('signal-confirmation-modal').nth(1); + const modalSubmitBtn = modal.locator( + '[data-testid="confirm-modal-button"]', + ); + + await expect(modal).toBeVisible(); + await modal.getByTestId('signal-select-button').click(); + await modal.getByRole('option', { name: 'add-data' }).click(); + await modal + .getByRole('textbox') + .nth(1) + .fill('{"key":"testKey","value":{"nested":true}}'); + await modalSubmitBtn.focus(); + await modalSubmitBtn.click(); + await page.getByTestId('history-tab').click(); + await page.getByTestId('feed').click(); + const signaledRow = page + .getByTestId('event-summary-row') + .filter({ hasText: 'Workflow Execution Signaled' }) + .last(); + await signaledRow.click(); + const expandedRow = page.getByTestId('event-summary-row-expanded').last(); + const editor = expandedRow.getByRole('textbox').first(); + await expect(editor).toContainText('testKey'); + await expect(editor).toContainText('nested'); + }); + + test('call stack shows stack trace', async ({ page }) => { + await page.getByTestId('call-stack-tab').click(); + const codeBlock = page.getByRole('textbox'); + await expect(codeBlock).toContainText('at workflow'); + }); +}); diff --git a/tests/global-setup.ts b/tests/global-setup.ts index a178914540..c239b26b16 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,6 +1,9 @@ import { chromium, FullConfig } from '@playwright/test'; -import { startWorkflows } from '../temporal/client'; +import { + startPayloadCoverageWorkflow, + startWorkflows, +} from '../temporal/client'; import { connect } from '../temporal/client'; import { createCodecServer } from '../temporal/codec-server'; import { runWorker } from '../temporal/workers'; @@ -24,6 +27,7 @@ const setupDependencies = async () => { const client = await connect(); await runWorker(); await startWorkflows(client); + startPayloadCoverageWorkflow(client).catch(() => {}); } catch (e) { console.log('Error setting up server: ', e); } From 822ba22dd55f36d75579d9a4d48fcf1c4f79932b Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Fri, 17 Apr 2026 13:27:32 -0600 Subject: [PATCH 3/5] test(e2e): refactor payload-decoder spec to use page object model Add four page classes to encapsulate e2e test interactions: - WorkflowDetailPage: tab navigation and input/result/main locators - EventHistoryPage: feed toggle, event row expansion, and editor access - WorkflowQueryPage: query type selection, arg input, and result - SignalModalPage: signal modal open, signal selection, payload fill and submit Refactor payload-decoder.desktop.spec.ts to use these classes, removing all raw page.getByTestId/getByRole calls from the test body. Also upgrades @temporalio/* packages from 1.15.0 to 1.16.0. --- package.json | 14 +- pnpm-lock.yaml | 148 ++++++++-------- src/lib/pages/workflow-query.svelte | 2 +- temporal/encryption-codec.ts | 10 +- temporal/workflows.ts | 2 +- tests/e2e/payload-decoder.desktop.spec.ts | 195 ++++++++++------------ tests/pages/event-history.ts | 35 ++++ tests/pages/signal-modal.ts | 44 +++++ tests/pages/workflow-detail.ts | 52 ++++++ tests/pages/workflow-query.ts | 31 ++++ utilities/temporal-server.ts | 5 + 11 files changed, 339 insertions(+), 199 deletions(-) create mode 100644 tests/pages/event-history.ts create mode 100644 tests/pages/signal-modal.ts create mode 100644 tests/pages/workflow-detail.ts create mode 100644 tests/pages/workflow-query.ts diff --git a/package.json b/package.json index 6afc08cc89..b0c6f16fef 100644 --- a/package.json +++ b/package.json @@ -146,13 +146,13 @@ "@sveltejs/adapter-vercel": "^6.3.2", "@sveltejs/kit": "2.57.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@temporalio/activity": "1.15.0", - "@temporalio/client": "1.15.0", - "@temporalio/common": "1.15.0", - "@temporalio/proto": "1.15.0", - "@temporalio/testing": "1.15.0", - "@temporalio/worker": "1.15.0", - "@temporalio/workflow": "1.15.0", + "@temporalio/activity": "1.16.0", + "@temporalio/client": "1.16.0", + "@temporalio/common": "1.16.0", + "@temporalio/proto": "1.16.0", + "@temporalio/testing": "1.16.0", + "@temporalio/worker": "1.16.0", + "@temporalio/workflow": "1.16.0", "@types/base-64": "^1.0.0", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb9446ce1..413019f1f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,26 +226,26 @@ importers: specifier: ^6.2.4 version: 6.2.4(svelte@5.55.1)(vite@6.4.2(@types/node@18.19.130)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3)) '@temporalio/activity': - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 '@temporalio/client': - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 '@temporalio/common': - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 '@temporalio/proto': - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 '@temporalio/testing': - specifier: 1.15.0 - version: 1.15.0(esbuild@0.25.12) + specifier: 1.16.0 + version: 1.16.0(esbuild@0.25.12) '@temporalio/worker': - specifier: 1.15.0 - version: 1.15.0(esbuild@0.25.12) + specifier: 1.16.0 + version: 1.16.0(esbuild@0.25.12) '@temporalio/workflow': - specifier: 1.15.0 - version: 1.15.0 + specifier: 1.16.0 + version: 1.16.0 '@types/base-64': specifier: ^1.0.0 version: 1.0.2 @@ -2022,40 +2022,40 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@temporalio/activity@1.15.0': - resolution: {integrity: sha512-kKEIrHMTsANiEpDVZd+v3OT6kU20UtcvAK7iibJNzQnoZUGC7XnqdKQlBuGYBxgpJzD9ppGgskXRczW+XU5Kng==} + '@temporalio/activity@1.16.0': + resolution: {integrity: sha512-hmWt7uAvgV+U0sr2x4Z8jIpk5PnwlA0qkcfSlFTrzhp0NYD6GXHa+IPwHCu0wkkmjqfYwYEi/+fabApvsjmHRA==} engines: {node: '>= 20.0.0'} - '@temporalio/client@1.15.0': - resolution: {integrity: sha512-SxTGqRIa2+Vy4P9+06ZpUf4u7ZZmOXfx/kr9XvNqAApLxTMKjTQIg5OH5Wt4JLUtIR7dFkuHIyhewdRyG+hSsQ==} + '@temporalio/client@1.16.0': + resolution: {integrity: sha512-ojMgx6ozO8WEnF+AwK+pGxToV/bGLnKKwMwdG+pbV6w494Z98IXpqc7teDU2eTjuaEboPWRzZubTytjgpBbCHQ==} engines: {node: '>= 20.0.0'} - '@temporalio/common@1.15.0': - resolution: {integrity: sha512-tBfC3fdOExsNoS5krkMUXnaMtCRKj05Jts4+TH+cgHpbys68nslFvUQLqwPIw2x6155Divb9MF219a/75itbTg==} + '@temporalio/common@1.16.0': + resolution: {integrity: sha512-xim7gAIAjquHOXgZWOtzmovXJJYlczzcVdrCMfJB1/21ZTxS028X72na98mtFyl7tA+Z1vKRvdtyew/XPK8pMg==} engines: {node: '>= 20.0.0'} - '@temporalio/core-bridge@1.15.0': - resolution: {integrity: sha512-Qdrs5zju5MiOwmERCWzQ6uHZwn1JaQk/ppS2UHbxqZndjbAEFPDU9KQqFLkxWAicHMy7+LGPnA4DpVOANGlTZA==} + '@temporalio/core-bridge@1.16.0': + resolution: {integrity: sha512-ozuytog/OoS5KEqAOcPFIUzNyd7Ees2+faL2LohzxrkOdlUbtpyREJTyMCr8KC7M40lXrWktRD9tvQzZCjWmfg==} engines: {node: '>= 20.0.0'} - '@temporalio/nexus@1.15.0': - resolution: {integrity: sha512-E6CdIjskkbK2aObxcb76Z4V3o1D3QDxEtsxmuHX5D7HEABuYGdV+oeOzDyxMlfeY9GyIM9Nvky4XCiSz2h2XRA==} + '@temporalio/nexus@1.16.0': + resolution: {integrity: sha512-xvLgJyxxOvOidl/qAavc90URqvqca3djxlWR0rLQ/PDZUmj+F1RkYHU9APMF8ez6Ifw0G4/YW/APgy2XWCXg1g==} engines: {node: '>= 20.0.0'} - '@temporalio/proto@1.15.0': - resolution: {integrity: sha512-Awy4Fjzyba7Pg/CVZjjQ3x2CWkDL1qELyTZWcLlyjXq8bX694JVfBsmiMmF6tHn5/ySOIxDTcc0MSScZ0oKX5A==} + '@temporalio/proto@1.16.0': + resolution: {integrity: sha512-2zT4k0FP9pOi445ZviVceKy4+QdNXiydWRmmYnsg7gDbS3jCwe1Z+SBGdFBEIu/gQHyaR8a2a9Ru2hhDr7nFPw==} engines: {node: '>= 20.0.0'} - '@temporalio/testing@1.15.0': - resolution: {integrity: sha512-OPbl9Fk969aoGAIamviL+3QT3xWY95MMzcKO+7UUBkmOJTIIbptsWY6ub5d21nmHVS4ewtlj04//dSYBD3EJYQ==} + '@temporalio/testing@1.16.0': + resolution: {integrity: sha512-0W7Dtq/247ipb5iG8EqTYpqoQG2GLTmv2L3UgYsSv2XAkF4GC+AuUQBw3L3Z7MuudHUCalkgSctnomLl1cD1fg==} engines: {node: '>= 20.0.0'} - '@temporalio/worker@1.15.0': - resolution: {integrity: sha512-9e0AWP2OxYFgeztMdkoWYbDVqmNubreRkG7/frVKFT3xHdIOrFQ2W6Yomv61q3oKMXTIrpvjClHtiTjAUr70uA==} + '@temporalio/worker@1.16.0': + resolution: {integrity: sha512-iFsZ7rMjbk7v+YMIEuSwwQdKyjYLs9ONm3UjcMiwvDNAYCNUlcW8RZ2vC/l9hHqSBeZejWbntCHRzlUcjEOpJw==} engines: {node: '>= 20.0.0'} - '@temporalio/workflow@1.15.0': - resolution: {integrity: sha512-VaMhVtlA0hLLM/pna26vFSdn5W1Arq2+ccgItdbdRdZUa0X8eW2B7sl/PcUYQOWc4aMUGnvPATKUGUoFKyCxSg==} + '@temporalio/workflow@1.16.0': + resolution: {integrity: sha512-jP3qIkP75bvKRYptKPaCmhHzgIo0L0D6hjpYozNjj/7rLiX8x/Awco7eXUV3MpHQxZkoxTg6Kzyue/NCZmV5aQ==} engines: {node: '>= 20.0.0'} '@testing-library/dom@10.4.0': @@ -5678,9 +5678,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - nexus-rpc@0.0.1: - resolution: {integrity: sha512-hAWn8Hh2eewpB5McXR5EW81R3pR/ziuGhKCF3wFyUVCklanPqrIgMNr7jKCbzXeNVad0nUDfWpFRqh2u+zxQtw==} - engines: {node: '>= 18.0.0'} + nexus-rpc@0.0.2: + resolution: {integrity: sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==} + engines: {node: '>= 20.0.0'} nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -9813,56 +9813,56 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@temporalio/activity@1.15.0': + '@temporalio/activity@1.16.0': dependencies: - '@temporalio/client': 1.15.0 - '@temporalio/common': 1.15.0 + '@temporalio/client': 1.16.0 + '@temporalio/common': 1.16.0 abort-controller: 3.0.0 - '@temporalio/client@1.15.0': + '@temporalio/client@1.16.0': dependencies: '@grpc/grpc-js': 1.14.3 - '@temporalio/common': 1.15.0 - '@temporalio/proto': 1.15.0 + '@temporalio/common': 1.16.0 + '@temporalio/proto': 1.16.0 abort-controller: 3.0.0 long: 5.3.2 uuid: 11.1.0 - '@temporalio/common@1.15.0': + '@temporalio/common@1.16.0': dependencies: - '@temporalio/proto': 1.15.0 + '@temporalio/proto': 1.16.0 long: 5.3.2 ms: 3.0.0-canary.1 - nexus-rpc: 0.0.1 + nexus-rpc: 0.0.2 proto3-json-serializer: 2.0.2 - '@temporalio/core-bridge@1.15.0': + '@temporalio/core-bridge@1.16.0': dependencies: '@grpc/grpc-js': 1.14.3 - '@temporalio/common': 1.15.0 + '@temporalio/common': 1.16.0 - '@temporalio/nexus@1.15.0': + '@temporalio/nexus@1.16.0': dependencies: - '@temporalio/client': 1.15.0 - '@temporalio/common': 1.15.0 - '@temporalio/proto': 1.15.0 + '@temporalio/client': 1.16.0 + '@temporalio/common': 1.16.0 + '@temporalio/proto': 1.16.0 long: 5.3.2 - nexus-rpc: 0.0.1 + nexus-rpc: 0.0.2 - '@temporalio/proto@1.15.0': + '@temporalio/proto@1.16.0': dependencies: long: 5.3.2 protobufjs: 7.5.4 - '@temporalio/testing@1.15.0(esbuild@0.25.12)': + '@temporalio/testing@1.16.0(esbuild@0.25.12)': dependencies: - '@temporalio/activity': 1.15.0 - '@temporalio/client': 1.15.0 - '@temporalio/common': 1.15.0 - '@temporalio/core-bridge': 1.15.0 - '@temporalio/proto': 1.15.0 - '@temporalio/worker': 1.15.0(esbuild@0.25.12) - '@temporalio/workflow': 1.15.0 + '@temporalio/activity': 1.16.0 + '@temporalio/client': 1.16.0 + '@temporalio/common': 1.16.0 + '@temporalio/core-bridge': 1.16.0 + '@temporalio/proto': 1.16.0 + '@temporalio/worker': 1.16.0(esbuild@0.25.12) + '@temporalio/workflow': 1.16.0 abort-controller: 3.0.0 transitivePeerDependencies: - '@swc/helpers' @@ -9870,21 +9870,21 @@ snapshots: - uglify-js - webpack-cli - '@temporalio/worker@1.15.0(esbuild@0.25.12)': + '@temporalio/worker@1.16.0(esbuild@0.25.12)': dependencies: '@grpc/grpc-js': 1.14.3 '@swc/core': 1.15.5 - '@temporalio/activity': 1.15.0 - '@temporalio/client': 1.15.0 - '@temporalio/common': 1.15.0 - '@temporalio/core-bridge': 1.15.0 - '@temporalio/nexus': 1.15.0 - '@temporalio/proto': 1.15.0 - '@temporalio/workflow': 1.15.0 + '@temporalio/activity': 1.16.0 + '@temporalio/client': 1.16.0 + '@temporalio/common': 1.16.0 + '@temporalio/core-bridge': 1.16.0 + '@temporalio/nexus': 1.16.0 + '@temporalio/proto': 1.16.0 + '@temporalio/workflow': 1.16.0 abort-controller: 3.0.0 heap-js: 2.7.1 memfs: 4.51.1 - nexus-rpc: 0.0.1 + nexus-rpc: 0.0.2 proto3-json-serializer: 2.0.2 protobufjs: 7.5.4 rxjs: 7.8.2 @@ -9900,11 +9900,11 @@ snapshots: - uglify-js - webpack-cli - '@temporalio/workflow@1.15.0': + '@temporalio/workflow@1.16.0': dependencies: - '@temporalio/common': 1.15.0 - '@temporalio/proto': 1.15.0 - nexus-rpc: 0.0.1 + '@temporalio/common': 1.16.0 + '@temporalio/proto': 1.16.0 + nexus-rpc: 0.0.2 '@testing-library/dom@10.4.0': dependencies: @@ -14376,7 +14376,7 @@ snapshots: neo-async@2.6.2: {} - nexus-rpc@0.0.1: {} + nexus-rpc@0.0.2: {} nice-try@1.0.5: {} diff --git a/src/lib/pages/workflow-query.svelte b/src/lib/pages/workflow-query.svelte index f40d127198..c3baf6a527 100644 --- a/src/lib/pages/workflow-query.svelte +++ b/src/lib/pages/workflow-query.svelte @@ -145,7 +145,7 @@ {/each} -
+
diff --git a/temporal/encryption-codec.ts b/temporal/encryption-codec.ts index 2a29b6cbc6..bf928175aa 100644 --- a/temporal/encryption-codec.ts +++ b/temporal/encryption-codec.ts @@ -6,7 +6,7 @@ import { type PayloadCodec, ValueError, } from '@temporalio/common'; -import { decode, encode } from '@temporalio/common/lib/encoding'; +import encoding from '@temporalio/common/lib/encoding.js'; import temporal from '@temporalio/proto'; import { decrypt, encrypt } from './crypto'; @@ -30,8 +30,8 @@ export class EncryptionCodec implements PayloadCodec { return Promise.all( payloads.map(async (payload) => ({ metadata: { - [METADATA_ENCODING_KEY]: encode(ENCODING), - [METADATA_ENCRYPTION_KEY_ID]: encode(this.defaultKeyId), + [METADATA_ENCODING_KEY]: encoding.encode(ENCODING), + [METADATA_ENCRYPTION_KEY_ID]: encoding.encode(this.defaultKeyId), }, data: await encrypt( temporal.temporal.api.common.v1.Payload.encode(payload).finish(), @@ -46,7 +46,7 @@ export class EncryptionCodec implements PayloadCodec { payloads.map(async (payload) => { if ( !payload.metadata || - decode(payload.metadata[METADATA_ENCODING_KEY]) !== ENCODING + encoding.decode(payload.metadata[METADATA_ENCODING_KEY]) !== ENCODING ) { return payload; } @@ -61,7 +61,7 @@ export class EncryptionCodec implements PayloadCodec { ); } - const keyId = decode(keyIdBytes); + const keyId = encoding.decode(keyIdBytes); let key = this.keys.get(keyId); if (!key) { key = await fetchKey(keyId); diff --git a/temporal/workflows.ts b/temporal/workflows.ts index 0994ad76c9..5b65a21148 100644 --- a/temporal/workflows.ts +++ b/temporal/workflows.ts @@ -234,7 +234,7 @@ export async function PayloadCoverageWorkflow( }, ); - const timedOut = !(await workflow.condition(() => triggered, '5 minutes')); + const timedOut = !(await workflow.condition(() => triggered, '10 days')); return { received: input, diff --git a/tests/e2e/payload-decoder.desktop.spec.ts b/tests/e2e/payload-decoder.desktop.spec.ts index 779f4ac568..6b9600d9df 100644 --- a/tests/e2e/payload-decoder.desktop.spec.ts +++ b/tests/e2e/payload-decoder.desktop.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; +import { EventHistoryPage } from '../pages/event-history'; +import { SignalModalPage } from '../pages/signal-modal'; +import { WorkflowDetailPage } from '../pages/workflow-detail'; +import { WorkflowQueryPage } from '../pages/workflow-query'; + const WORKFLOW_ID = 'payload-coverage-workflow'; test.describe('Payload Decoder', () => { @@ -10,43 +15,42 @@ test.describe('Payload Decoder', () => { test('workflow input is decoded', async ({ page }) => { test.slow(); - const inputAndResult = page.getByTestId('input-and-result'); - await expect(inputAndResult).toContainText('hello world'); - await expect(inputAndResult).toContainText('numberField'); - await expect(inputAndResult).toContainText('3.14159'); - await expect(inputAndResult).toContainText('arrayOfStrings'); + const workflowDetail = new WorkflowDetailPage(page); + await expect(workflowDetail.inputAndResult).toContainText('hello world'); + await expect(workflowDetail.inputAndResult).toContainText('numberField'); + await expect(workflowDetail.inputAndResult).toContainText('3.14159'); + await expect(workflowDetail.inputAndResult).toContainText('arrayOfStrings'); }); test('memo payloads are decoded', async ({ page }) => { test.slow(); - await page.getByTestId('memo-tab').click(); - const main = page.locator('main'); - await expect(main).toContainText('Payload coverage test workflow'); - await expect(main).toContainText('e2e'); - await expect(main).toContainText('coverage'); - await expect(main).toContainText('testData'); + const workflowDetail = new WorkflowDetailPage(page); + await workflowDetail.openMemo(); + await expect(workflowDetail.main).toContainText( + 'Payload coverage test workflow', + ); + await expect(workflowDetail.main).toContainText('e2e'); + await expect(workflowDetail.main).toContainText('coverage'); + await expect(workflowDetail.main).toContainText('testData'); }); test('search attributes are decoded', async ({ page }) => { - await page.getByTestId('search-attributes-tab').click(); - const main = page.locator('main'); - await expect(main).toContainText('payload-coverage'); - await expect(main).toContainText('CustomKeywordField'); + const workflowDetail = new WorkflowDetailPage(page); + await workflowDetail.openSearchAttributes(); + await expect(workflowDetail.main).toContainText('payload-coverage'); + await expect(workflowDetail.main).toContainText('CustomKeywordField'); }); test('event history WorkflowExecutionStarted shows decoded input', async ({ page, }) => { test.slow(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const startedRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Workflow Execution Started' }) - .first(); - await startedRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').first(); - const editor = expandedRow.getByRole('textbox').first(); + const workflowDetail = new WorkflowDetailPage(page); + const eventHistory = new EventHistoryPage(page); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Workflow Execution Started'); + const editor = eventHistory.expandedEditor('first', 0); await expect(editor).toContainText('hello world'); await expect(editor).toContainText('stringField'); await expect(editor).toContainText('arrayOfStrings'); @@ -56,21 +60,16 @@ test.describe('Payload Decoder', () => { page, }) => { test.slow(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const completedRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Activity Task Completed' }) - .first(); - await completedRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').first(); - - const input = expandedRow.getByRole('textbox').first(); + const workflowDetail = new WorkflowDetailPage(page); + const eventHistory = new EventHistoryPage(page); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Activity Task Completed'); + const input = eventHistory.expandedEditor('first', 0); await expect(input).toContainText('alpha'); await expect(input).toContainText('beta'); await expect(input).toContainText('gamma'); - - const result = expandedRow.getByRole('textbox').nth(1); + const result = eventHistory.expandedEditor('first', 1); await expect(result).toContainText('processed'); }); @@ -78,14 +77,12 @@ test.describe('Payload Decoder', () => { page, }) => { test.slow(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const failedRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Activity Task Failed' }) - .first(); - await failedRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').first(); + const workflowDetail = new WorkflowDetailPage(page); + const eventHistory = new EventHistoryPage(page); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Activity Task Failed'); + const expandedRow = eventHistory.expandedRow('first'); await expect(expandedRow).toBeVisible(); await expect(expandedRow).toContainText('Activity Task Failed'); }); @@ -94,15 +91,12 @@ test.describe('Payload Decoder', () => { page, }) => { test.slow(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const childInitiatedRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Start Child Workflow Execution Initiated' }) - .first(); - await childInitiatedRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').first(); - const editor = expandedRow.getByRole('textbox').first(); + const workflowDetail = new WorkflowDetailPage(page); + const eventHistory = new EventHistoryPage(page); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Start Child Workflow Execution Initiated'); + const editor = eventHistory.expandedEditor('first', 0); await expect(editor).toContainText('hello from PayloadCoverageWorkflow'); }); @@ -110,84 +104,63 @@ test.describe('Payload Decoder', () => { page, }) => { test.slow(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const childCompletedRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Child Workflow Execution Completed' }) - .first(); - await childCompletedRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').first(); - const input = expandedRow.getByRole('textbox').first(); + const workflowDetail = new WorkflowDetailPage(page); + const eventHistory = new EventHistoryPage(page); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Child Workflow Execution Completed'); + const input = eventHistory.expandedEditor('first', 0); await expect(input).toContainText('hello from PayloadCoverageWorkflow'); - const result = expandedRow.getByRole('textbox').nth(1); + const result = eventHistory.expandedEditor('first', 1); await expect(result).toContainText( 'child echoed: hello from PayloadCoverageWorkflow', ); }); test('query get-status returns decoded result', async ({ page }) => { - await page.getByTestId('queries-tab').click(); - await page.getByTestId('query-select-button').click(); - await page.getByRole('option', { name: 'get-status' }).click(); - await page.getByRole('button', { name: /query/i }).click(); - const result = page.getByTestId('query-result').getByRole('textbox'); - await expect(result).toContainText('status'); - await expect(result).toContainText('count'); - await expect(result).toContainText('data'); + const workflowDetail = new WorkflowDetailPage(page); + const workflowQuery = new WorkflowQueryPage(page); + await workflowDetail.openQueries(); + await workflowQuery.selectQueryType('get-status'); + await workflowQuery.run(); + await expect(workflowQuery.queryResult).toContainText('status'); + await expect(workflowQuery.queryResult).toContainText('count'); + await expect(workflowQuery.queryResult).toContainText('data'); }); test('query get-field returns decoded field value', async ({ page }) => { - await page.getByTestId('queries-tab').click(); - await page.getByTestId('query-select-button').click(); - await page.getByRole('option', { name: 'get-field' }).click(); - - const payloadInput = page.getByTestId('query-input').getByRole('textbox'); - - await payloadInput.focus(); - await payloadInput.fill('"stringField"'); - await page.getByRole('button', { name: /query/i }).click(); - const result = page.getByTestId('query-result').getByRole('textbox'); - await expect(result).toContainText('hello world'); + const workflowDetail = new WorkflowDetailPage(page); + const workflowQuery = new WorkflowQueryPage(page); + await workflowDetail.openQueries(); + await workflowQuery.selectQueryType('get-field'); + await workflowQuery.fillQueryArg('"stringField"'); + await workflowQuery.run(); + await expect(workflowQuery.queryResult).toContainText('hello world'); }); test('send signal and verify decoded signal payload in history', async ({ page, }) => { test.slow(); - await page.getByRole('button', { name: 'More Actions' }).click(); - await page.getByRole('menuitem', { name: 'Send a Signal' }).click(); - - const modal = page.getByTestId('signal-confirmation-modal').nth(1); - const modalSubmitBtn = modal.locator( - '[data-testid="confirm-modal-button"]', - ); - - await expect(modal).toBeVisible(); - await modal.getByTestId('signal-select-button').click(); - await modal.getByRole('option', { name: 'add-data' }).click(); - await modal - .getByRole('textbox') - .nth(1) - .fill('{"key":"testKey","value":{"nested":true}}'); - await modalSubmitBtn.focus(); - await modalSubmitBtn.click(); - await page.getByTestId('history-tab').click(); - await page.getByTestId('feed').click(); - const signaledRow = page - .getByTestId('event-summary-row') - .filter({ hasText: 'Workflow Execution Signaled' }) - .last(); - await signaledRow.click(); - const expandedRow = page.getByTestId('event-summary-row-expanded').last(); - const editor = expandedRow.getByRole('textbox').first(); + const workflowDetail = new WorkflowDetailPage(page); + const signalModal = new SignalModalPage(page); + const eventHistory = new EventHistoryPage(page); + await signalModal.open(); + await expect(signalModal.modal).toBeVisible(); + await signalModal.selectSignal('add-data'); + await signalModal.fillPayload('{"key":"testKey","value":{"nested":true}}'); + await signalModal.submit(); + await workflowDetail.openHistory(); + await eventHistory.switchToFeed(); + await eventHistory.expandEvent('Workflow Execution Signaled', 'last'); + const editor = eventHistory.expandedEditor('last', 0); await expect(editor).toContainText('testKey'); await expect(editor).toContainText('nested'); }); test('call stack shows stack trace', async ({ page }) => { - await page.getByTestId('call-stack-tab').click(); - const codeBlock = page.getByRole('textbox'); - await expect(codeBlock).toContainText('at workflow'); + const workflowDetail = new WorkflowDetailPage(page); + await workflowDetail.openCallStack(); + await expect(workflowDetail.callStackEditor).toContainText('at workflow'); }); }); diff --git a/tests/pages/event-history.ts b/tests/pages/event-history.ts new file mode 100644 index 0000000000..bb34f4500a --- /dev/null +++ b/tests/pages/event-history.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; + +export class EventHistoryPage { + readonly page: Page; + readonly feedButton: Locator; + + constructor(page: Page) { + this.page = page; + this.feedButton = page.getByTestId('feed'); + } + + async switchToFeed() { + await this.feedButton.click(); + } + + eventRow(eventTypeText: string, nth: 'first' | 'last' = 'first'): Locator { + const rows = this.page + .getByTestId('event-summary-row') + .filter({ hasText: eventTypeText }); + return nth === 'last' ? rows.last() : rows.first(); + } + + expandedRow(nth: 'first' | 'last' = 'first'): Locator { + const rows = this.page.getByTestId('event-summary-row-expanded'); + return nth === 'last' ? rows.last() : rows.first(); + } + + expandedEditor(rowNth: 'first' | 'last' = 'first', editorNth = 0): Locator { + return this.expandedRow(rowNth).getByRole('textbox').nth(editorNth); + } + + async expandEvent(eventTypeText: string, nth: 'first' | 'last' = 'first') { + await this.eventRow(eventTypeText, nth).click(); + } +} diff --git a/tests/pages/signal-modal.ts b/tests/pages/signal-modal.ts new file mode 100644 index 0000000000..9ba3dbd2b3 --- /dev/null +++ b/tests/pages/signal-modal.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +export class SignalModalPage { + readonly page: Page; + readonly moreActionsButton: Locator; + readonly sendSignalMenuItem: Locator; + readonly modal: Locator; + readonly signalSelectButton: Locator; + readonly payloadInput: Locator; + readonly submitButton: Locator; + + constructor(page: Page) { + this.page = page; + this.moreActionsButton = page.getByRole('button', { name: 'More Actions' }); + this.sendSignalMenuItem = page.getByRole('menuitem', { + name: 'Send a Signal', + }); + this.modal = page.getByTestId('signal-confirmation-modal').nth(1); + this.signalSelectButton = this.modal.getByTestId('signal-select-button'); + this.payloadInput = this.modal.getByRole('textbox').nth(1); + this.submitButton = this.modal.locator( + '[data-testid="confirm-modal-button"]', + ); + } + + async open() { + await this.moreActionsButton.click(); + await this.sendSignalMenuItem.click(); + } + + async selectSignal(name: string) { + await this.signalSelectButton.click(); + await this.modal.getByRole('option', { name }).click(); + } + + async fillPayload(json: string) { + await this.payloadInput.fill(json); + } + + async submit() { + await this.submitButton.focus(); + await this.submitButton.click(); + } +} diff --git a/tests/pages/workflow-detail.ts b/tests/pages/workflow-detail.ts new file mode 100644 index 0000000000..1990d40281 --- /dev/null +++ b/tests/pages/workflow-detail.ts @@ -0,0 +1,52 @@ +import type { Locator, Page } from '@playwright/test'; + +export class WorkflowDetailPage { + readonly page: Page; + readonly inputAndResult: Locator; + readonly main: Locator; + readonly memoTab: Locator; + readonly searchAttributesTab: Locator; + readonly historyTab: Locator; + readonly callStackTab: Locator; + readonly queriesTab: Locator; + readonly callStackEditor: Locator; + + constructor(page: Page) { + this.page = page; + this.inputAndResult = page.getByTestId('input-and-result'); + this.main = page.locator('main'); + this.memoTab = page.getByTestId('memo-tab'); + this.searchAttributesTab = page.getByTestId('search-attributes-tab'); + this.historyTab = page.getByTestId('history-tab'); + this.callStackTab = page.getByTestId('call-stack-tab'); + this.queriesTab = page.getByTestId('queries-tab'); + this.callStackEditor = page.getByRole('textbox'); + } + + async navigate(baseURL: string, workflowId: string) { + await this.page.goto(baseURL); + await this.page + .getByRole('link', { name: workflowId, exact: true }) + .click(); + } + + async openMemo() { + await this.memoTab.click(); + } + + async openSearchAttributes() { + await this.searchAttributesTab.click(); + } + + async openHistory() { + await this.historyTab.click(); + } + + async openCallStack() { + await this.callStackTab.click(); + } + + async openQueries() { + await this.queriesTab.click(); + } +} diff --git a/tests/pages/workflow-query.ts b/tests/pages/workflow-query.ts new file mode 100644 index 0000000000..c10db6b67e --- /dev/null +++ b/tests/pages/workflow-query.ts @@ -0,0 +1,31 @@ +import type { Locator, Page } from '@playwright/test'; + +export class WorkflowQueryPage { + readonly page: Page; + readonly querySelectButton: Locator; + readonly queryInput: Locator; + readonly queryResult: Locator; + readonly runQueryButton: Locator; + + constructor(page: Page) { + this.page = page; + this.querySelectButton = page.getByTestId('query-select-button'); + this.queryInput = page.getByTestId('query-input').getByRole('textbox'); + this.queryResult = page.getByTestId('query-result').getByRole('textbox'); + this.runQueryButton = page.getByRole('button', { name: /query/i }); + } + + async selectQueryType(name: string) { + await this.querySelectButton.click(); + await this.page.getByRole('option', { name }).click(); + } + + async fillQueryArg(value: string) { + await this.queryInput.focus(); + await this.queryInput.fill(value); + } + + async run() { + await this.runQueryButton.click(); + } +} diff --git a/utilities/temporal-server.ts b/utilities/temporal-server.ts index c58921df5c..5d3f67a8e6 100644 --- a/utilities/temporal-server.ts +++ b/utilities/temporal-server.ts @@ -94,6 +94,9 @@ export const createTemporalServer = async ({ { flag: 'frontend.workerVersioningDataAPIs', value: 'true' }, { flag: 'frontend.workerVersioningWorkflowAPIs', value: 'true' }, { flag: 'worker.buildIdScavengerEnabled', value: 'true' }, + { flag: 'history.enableChasm', value: 'true' }, + { flag: 'history.externalPayloadsEnabled', value: 'true' }, + { flag: 'activity.enableStandalone', value: 'true' }, ].forEach(({ flag, value }) => { flags.push('--dynamic-config-value'); flags.push(`${flag}=${value}`); @@ -101,6 +104,8 @@ export const createTemporalServer = async ({ const temporal = $`${cliPath} server start-dev ${flags}`.quiet(); + await $`${cliPath} operator search-attribute create --name CustomKeywordField --type Keyword --name CustomIntField --type Int`; + temporal.catch(async ({ stdout, stderr, exitCode }) => { console.log('EXIT CODE', exitCode); if (exitCode) { From 31a13483c0e05b11af3917bb3fa011e632600a74 Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Fri, 17 Apr 2026 13:37:27 -0600 Subject: [PATCH 4/5] change wf timeout back to 5 minutes --- temporal/workflows.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal/workflows.ts b/temporal/workflows.ts index 5b65a21148..0994ad76c9 100644 --- a/temporal/workflows.ts +++ b/temporal/workflows.ts @@ -234,7 +234,7 @@ export async function PayloadCoverageWorkflow( }, ); - const timedOut = !(await workflow.condition(() => triggered, '10 days')); + const timedOut = !(await workflow.condition(() => triggered, '5 minutes')); return { received: input, From 2efa7acad60af0235c980255ebc0de815d0c9a7d Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Fri, 17 Apr 2026 14:14:11 -0600 Subject: [PATCH 5/5] fix search attributes --- utilities/temporal-server.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/utilities/temporal-server.ts b/utilities/temporal-server.ts index 5d3f67a8e6..3399dc36f1 100644 --- a/utilities/temporal-server.ts +++ b/utilities/temporal-server.ts @@ -102,9 +102,8 @@ export const createTemporalServer = async ({ flags.push(`${flag}=${value}`); }); - const temporal = $`${cliPath} server start-dev ${flags}`.quiet(); - - await $`${cliPath} operator search-attribute create --name CustomKeywordField --type Keyword --name CustomIntField --type Int`; + const temporal = + $`${cliPath} server start-dev --search-attribute CustomKeywordField=Keyword --search-attribute CustomIntField=Int ${flags}`.quiet(); temporal.catch(async ({ stdout, stderr, exitCode }) => { console.log('EXIT CODE', exitCode);