diff --git a/package.json b/package.json
index a07d3df26f..b0c6f16fef 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"
@@ -145,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/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/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/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..bf928175aa
--- /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 encoding from '@temporalio/common/lib/encoding.js';
+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]: encoding.encode(ENCODING),
+ [METADATA_ENCRYPTION_KEY_ID]: encoding.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 ||
+ encoding.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 = encoding.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(),
+ };
+}
diff --git a/tests/e2e/payload-decoder.desktop.spec.ts b/tests/e2e/payload-decoder.desktop.spec.ts
new file mode 100644
index 0000000000..6b9600d9df
--- /dev/null
+++ b/tests/e2e/payload-decoder.desktop.spec.ts
@@ -0,0 +1,166 @@
+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', () => {
+ 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 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();
+ 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 }) => {
+ 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();
+ 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');
+ });
+
+ test('event history ActivityTaskCompleted shows decoded result', async ({
+ page,
+ }) => {
+ test.slow();
+ 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 = eventHistory.expandedEditor('first', 1);
+ await expect(result).toContainText('processed');
+ });
+
+ test('event history ActivityTaskFailed shows expanded event details', async ({
+ page,
+ }) => {
+ test.slow();
+ 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');
+ });
+
+ test('event history StartChildWorkflowExecutionInitiated shows decoded input', async ({
+ page,
+ }) => {
+ test.slow();
+ 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');
+ });
+
+ test('event history ChildWorkflowExecutionCompleted shows decoded result', async ({
+ page,
+ }) => {
+ test.slow();
+ 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 = eventHistory.expandedEditor('first', 1);
+ await expect(result).toContainText(
+ 'child echoed: hello from PayloadCoverageWorkflow',
+ );
+ });
+
+ test('query get-status returns decoded result', async ({ page }) => {
+ 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 }) => {
+ 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();
+ 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 }) => {
+ const workflowDetail = new WorkflowDetailPage(page);
+ await workflowDetail.openCallStack();
+ await expect(workflowDetail.callStackEditor).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);
}
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..3399dc36f1 100644
--- a/utilities/temporal-server.ts
+++ b/utilities/temporal-server.ts
@@ -94,12 +94,16 @@ 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}`);
});
- const temporal = $`${cliPath} server start-dev ${flags}`.quiet();
+ 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);