From f810c813e13fc8182b710f9d50700a33e7830b4c Mon Sep 17 00:00:00 2001
From: "Guan-Ming (Wesley) Chiu"
<105915352+guan404ming@users.noreply.github.com>
Date: Fri, 3 Jul 2026 21:39:33 +0800
Subject: [PATCH 1/5] Add integration tests for coordinator-mode TypeScript
tasks
---
.../workflows/additional-prod-image-tests.yml | 16 ++
.github/workflows/ci-amd.yml | 2 +
.github/workflows/ci-arm.yml | 2 +
airflow-e2e-tests/docker/ts.yml | 45 +++++
.../tests/airflow_e2e_tests/conftest.py | 117 ++++++++++++
.../tests/airflow_e2e_tests/constants.py | 12 ++
.../ts_sdk_tests/__init__.py | 16 ++
.../ts_sdk_tests/test_ts_sdk_dag.py | 169 ++++++++++++++++++
dev/breeze/doc/ci/04_selective_checks.md | 1 +
.../airflow_breeze/utils/selective_checks.py | 13 ++
dev/breeze/tests/test_selective_checks.py | 15 ++
ts-sdk/example/.gitignore | 1 +
12 files changed, 409 insertions(+)
create mode 100644 airflow-e2e-tests/docker/ts.yml
create mode 100644 airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/__init__.py
create mode 100644 airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
diff --git a/.github/workflows/additional-prod-image-tests.yml b/.github/workflows/additional-prod-image-tests.yml
index 99a3a4c872858..a4ec751e5f4ef 100644
--- a/.github/workflows/additional-prod-image-tests.yml
+++ b/.github/workflows/additional-prod-image-tests.yml
@@ -72,6 +72,10 @@ on: # yamllint disable-line rule:truthy
description: "Whether to run the OpenLineage older-Airflow compat matrix (true/false)"
required: true
type: string
+ run-ts-sdk-e2e-tests:
+ description: "Whether to run TypeScript SDK e2e tests (true/false)"
+ required: true
+ type: string
providers-compatibility-tests-matrix:
description: "JSON matrix of released Airflow versions for the OpenLineage e2e compat tests."
required: true
@@ -338,6 +342,18 @@ jobs:
e2e_test_mode: "go_sdk"
if: inputs.canary-run == 'true' || inputs.run-go-sdk-e2e-tests == 'true'
+ test-e2e-integration-tests-ts-sdk:
+ name: "TypeScript SDK e2e tests with PROD image"
+ uses: ./.github/workflows/airflow-e2e-tests.yml
+ with:
+ workflow-name: "TypeScript SDK e2e test"
+ runners: ${{ inputs.runners }}
+ platform: ${{ inputs.platform }}
+ default-python-version: "${{ inputs.default-python-version }}"
+ use-uv: ${{ inputs.use-uv }}
+ e2e_test_mode: "ts_sdk"
+ if: inputs.canary-run == 'true' || inputs.run-ts-sdk-e2e-tests == 'true'
+
test-e2e-integration-tests-openlineage:
name: "OpenLineage e2e tests with PROD image"
uses: ./.github/workflows/airflow-e2e-tests.yml
diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index 9edb394a6cc7c..c2a477b694590 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -149,6 +149,7 @@ jobs:
run-go-sdk-e2e-tests: ${{ steps.selective-checks.outputs.run-go-sdk-e2e-tests }}
run-openlineage-e2e-tests: ${{ steps.selective-checks.outputs.run-openlineage-e2e-tests }}
run-openlineage-e2e-compat-tests: ${{ steps.selective-checks.outputs.run-openlineage-e2e-compat-tests }}
+ run-ts-sdk-e2e-tests: ${{ steps.selective-checks.outputs.run-ts-sdk-e2e-tests }}
run-system-tests: ${{ steps.selective-checks.outputs.run-system-tests }}
run-task-sdk-tests: ${{ steps.selective-checks.outputs.run-task-sdk-tests }}
run-task-sdk-integration-tests: ${{ steps.selective-checks.outputs.run-task-sdk-integration-tests }}
@@ -930,6 +931,7 @@ jobs:
# yamllint disable rule:line-length
providers-compatibility-tests-matrix: >
${{ needs.build-info.outputs.providers-compatibility-tests-matrix }}
+ run-ts-sdk-e2e-tests: ${{ needs.build-info.outputs.run-ts-sdk-e2e-tests }}
use-uv: ${{ needs.build-info.outputs.use-uv }}
run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }}
run-airflow-ctl-integration-tests: ${{ needs.build-info.outputs.run-airflow-ctl-integration-tests }}
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index 8f5ce3ac07987..42ae7e5215b8c 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -138,6 +138,7 @@ jobs:
run-go-sdk-e2e-tests: ${{ steps.selective-checks.outputs.run-go-sdk-e2e-tests }}
run-openlineage-e2e-tests: ${{ steps.selective-checks.outputs.run-openlineage-e2e-tests }}
run-openlineage-e2e-compat-tests: ${{ steps.selective-checks.outputs.run-openlineage-e2e-compat-tests }}
+ run-ts-sdk-e2e-tests: ${{ steps.selective-checks.outputs.run-ts-sdk-e2e-tests }}
run-system-tests: ${{ steps.selective-checks.outputs.run-system-tests }}
run-task-sdk-tests: ${{ steps.selective-checks.outputs.run-task-sdk-tests }}
run-task-sdk-integration-tests: ${{ steps.selective-checks.outputs.run-task-sdk-integration-tests }}
@@ -919,6 +920,7 @@ jobs:
# yamllint disable rule:line-length
providers-compatibility-tests-matrix: >
${{ needs.build-info.outputs.providers-compatibility-tests-matrix }}
+ run-ts-sdk-e2e-tests: ${{ needs.build-info.outputs.run-ts-sdk-e2e-tests }}
use-uv: ${{ needs.build-info.outputs.use-uv }}
run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }}
run-airflow-ctl-integration-tests: ${{ needs.build-info.outputs.run-airflow-ctl-integration-tests }}
diff --git a/airflow-e2e-tests/docker/ts.yml b/airflow-e2e-tests/docker/ts.yml
new file mode 100644
index 0000000000000..6ea502425056d
--- /dev/null
+++ b/airflow-e2e-tests/docker/ts.yml
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Docker Compose override for ts_sdk E2E test mode.
+#
+# NodeCoordinator launches the TypeScript bundle with a Node.js runtime, which
+# the stock Airflow worker image does not ship. The node-provider service
+# copies the node binary from the official Node image (the same image
+# conftest._setup_ts_sdk_integration builds the bundle with) into a shared
+# volume, the bundle built by conftest is bind-mounted into the directory
+# NodeCoordinator scans, and the worker consumes the "typescript" Celery queue
+# where @task.stub tasks are routed.
+---
+services:
+ node-provider:
+ image: ${NODE_IMAGE:-node:22-slim}
+ command: ["cp", "/usr/local/bin/node", "/opt/nodejs/node"]
+ volumes:
+ - nodejs-bin:/opt/nodejs
+
+ airflow-worker:
+ volumes:
+ - ./ts-bundles:/opt/airflow/ts-bundles:ro
+ - nodejs-bin:/opt/nodejs:ro
+ command: celery worker -q typescript,default
+ depends_on:
+ node-provider:
+ condition: service_completed_successfully
+
+volumes:
+ nodejs-bin:
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 72687795ae1f9..1e0a6e0c465e5 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -18,6 +18,7 @@
import json
import os
+import re
import subprocess
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
@@ -55,6 +56,7 @@
LANG_SDK_NATIVE_TOOLCHAIN,
LOCALSTACK_PATH,
LOGS_FOLDER,
+ NODE_IMAGE,
OPENLINEAGE_COMPOSE_PATH,
OPENSEARCH_PATH,
PROVIDERS_MOUNT_CONTAINER_PATH,
@@ -62,6 +64,10 @@
SCALA_SPARK_EXAMPLE_DAGS_PATH,
SCALA_SPARK_EXAMPLE_LIBS_PATH,
TEST_REPORT_FILE,
+ TS_COMPOSE_PATH,
+ TS_SDK_BUILD_HOME_PATH,
+ TS_SDK_EXAMPLE_PATH,
+ TS_SDK_ROOT_PATH,
XCOM_BUCKET,
)
@@ -640,6 +646,114 @@ def _setup_openlineage_integration(dot_env_file, tmp_dir):
copyfile(OPENLINEAGE_COMPOSE_PATH, tmp_dir / "openlineage.yml")
+def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
+ """Set up the ts_sdk E2E test mode.
+
+ Builds the ts-sdk example bundle with pnpm inside an ephemeral Node
+ container (the host needs no Node toolchain), drops it with its
+ airflow-metadata.yaml sidecar into the directory ``NodeCoordinator``
+ scans, copies the Python stub Dag, and writes the coordinator
+ configuration.
+
+ The worker has no Node runtime of its own; the node-provider service in
+ ``ts.yml`` copies the node binary out of the same image used here for the
+ build, so the bundle executes on the runtime it was built for.
+ """
+ # --user keeps build outputs owned by the current user (not root); HOME
+ # points at a writable, gitignored dir so the pnpm store and corepack
+ # cache persist between runs. corepack resolves the pnpm version pinned
+ # by ts-sdk/package.json's packageManager field.
+ TS_SDK_BUILD_HOME_PATH.mkdir(parents=True, exist_ok=True)
+ # Shims go into a writable dir on PATH (the container user cannot write to
+ # /usr/local/bin) so nested `pnpm run ...` invocations resolve pnpm too.
+ build_script = (
+ 'export PATH="$HOME/bin:$PATH"'
+ ' && mkdir -p "$HOME/bin"'
+ ' && corepack enable --install-directory "$HOME/bin"'
+ " && cd /repo/ts-sdk"
+ " && pnpm install --frozen-lockfile"
+ " && pnpm run build"
+ " && cd example"
+ " && pnpm install"
+ " && pnpm run build"
+ )
+ console.print(f"[yellow]Building TypeScript SDK example bundle ({NODE_IMAGE})...")
+ subprocess.run(
+ [
+ "docker",
+ "run",
+ "--rm",
+ "--user",
+ f"{os.getuid()}:{os.getgid()}",
+ "-e",
+ "HOME=/repo/files/pnpm-home",
+ "-e",
+ "COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
+ "-e",
+ # Non-interactive pnpm: never prompt (e.g. before replacing a
+ # node_modules left behind by a host-side install).
+ "CI=true",
+ "-v",
+ f"{AIRFLOW_ROOT_PATH}:/repo",
+ NODE_IMAGE,
+ "bash",
+ "-c",
+ build_script,
+ ],
+ check=True,
+ )
+
+ # Copy the compose override into the temp directory.
+ copyfile(TS_COMPOSE_PATH, tmp_dir / "ts.yml")
+
+ # Place the bundle and its metadata sidecar where the compose bind-mount
+ # (./ts-bundles) exposes them to the worker at /opt/airflow/ts-bundles.
+ ts_bundles_dir = tmp_dir / "ts-bundles"
+ ts_bundles_dir.mkdir()
+ copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.mjs", ts_bundles_dir / "bundle.mjs")
+ supervisor_ts = (TS_SDK_ROOT_PATH / "src" / "generated" / "supervisor.ts").read_text()
+ version_match = re.search(r'SUPERVISOR_API_VERSION = "(\d{4}-\d{2}-\d{2})"', supervisor_ts)
+ if not version_match:
+ raise RuntimeError("Cannot find SUPERVISOR_API_VERSION in ts-sdk/src/generated/supervisor.ts")
+ (ts_bundles_dir / "airflow-metadata.yaml").write_text(
+ f'sdk:\n supervisor_schema_version: "{version_match.group(1)}"\n'
+ )
+
+ # Copy the TS SDK example stub Dag so Airflow can discover and serialize it.
+ copyfile(
+ TS_SDK_EXAMPLE_PATH / "dags" / "typescript_example.py", tmp_dir / "dags" / "typescript_example.py"
+ )
+
+ # Coordinator registry: maps the logical name "ts" to NodeCoordinator, which
+ # scans bundles_root for bundle.mjs and launches it with the node binary
+ # provided by the node-provider service.
+ # Queue mapping: routes tasks on the "typescript" queue to "ts".
+ coordinator_config = json.dumps(
+ {
+ "ts": {
+ "classpath": "airflow.sdk.coordinators.node.NodeCoordinator",
+ "kwargs": {
+ "bundles_root": ["/opt/airflow/ts-bundles"],
+ "node_executable": "/opt/nodejs/node",
+ },
+ }
+ }
+ )
+ queue_to_coordinator = json.dumps({"typescript": "ts"})
+
+ dot_env_file.write_text(
+ f"AIRFLOW_UID={os.getuid()}\n"
+ f"NODE_IMAGE={NODE_IMAGE}\n"
+ # Single-quote the JSON values so Docker Compose reads them literally.
+ f"AIRFLOW__SDK__COORDINATORS='{coordinator_config}'\n"
+ f"AIRFLOW__SDK__QUEUE_TO_COORDINATOR='{queue_to_coordinator}'\n"
+ # Connection and variable read by the TS example bundle tasks.
+ "AIRFLOW_CONN_TYPESCRIPT_EXAMPLE_HTTP=http://user:pass@example.com/\n"
+ "AIRFLOW_VAR_TYPESCRIPT_EXAMPLE_GREETING=greetings from e2e\n"
+ )
+ os.environ["ENV_FILE_PATH"] = str(dot_env_file)
+
+
def spin_up_airflow_environment(tmp_path_factory: pytest.TempPathFactory):
tmp_dir = tmp_path_factory.mktemp("breeze-airflow-e2e-tests")
@@ -694,6 +808,9 @@ def spin_up_airflow_environment(tmp_path_factory: pytest.TempPathFactory):
elif E2E_TEST_MODE == "openlineage":
compose_file_names.append("openlineage.yml")
_setup_openlineage_integration(dot_env_file, tmp_dir)
+ elif E2E_TEST_MODE == "ts_sdk":
+ compose_file_names.append("ts.yml")
+ _setup_ts_sdk_integration(dot_env_file, tmp_dir)
#
# Please Do not use this Fernet key in any deployments! Please generate your own key.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index 6b8d7c42d0c83..2b245a0021cc1 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -90,6 +90,18 @@
# and module fetches go through the HTTPS proxy (no git/gcc needed).
GO_BUILDER_IMAGE = os.environ.get("GO_BUILDER_IMAGE", "golang:1.25-alpine")
+# TypeScript SDK E2E test paths
+TS_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "ts-sdk"
+TS_SDK_EXAMPLE_PATH = TS_SDK_ROOT_PATH / "example"
+TS_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "ts.yml"
+# Node image used both to build the bundle and to provide the node binary to the
+# worker (ts.yml); keeping them identical guarantees the bundle runs on the same
+# runtime it was built for.
+NODE_IMAGE = os.environ.get("NODE_IMAGE", "node:22-slim")
+# Writable HOME for the containerized pnpm build; keeps the pnpm store and
+# corepack cache between runs (files/ is gitignored).
+TS_SDK_BUILD_HOME_PATH = AIRFLOW_ROOT_PATH / "files" / "pnpm-home"
+
# Local provider sources are mounted into the airflow containers under this directory so
# ``_PIP_ADDITIONAL_REQUIREMENTS`` can install the in-tree (latest, possibly unreleased)
# provider rather than the published one from PyPI.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/__init__.py b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/__init__.py
new file mode 100644
index 0000000000000..13a83393a9124
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
new file mode 100644
index 0000000000000..542bbf387cd4d
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
@@ -0,0 +1,169 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""End-to-end tests for coordinator-mode TypeScript tasks.
+
+Run with::
+
+ E2E_TEST_MODE=ts_sdk uv run --project airflow-e2e-tests pytest \\
+ tests/airflow_e2e_tests/ts_sdk_tests/ -xvs
+
+What is verified
+----------------
+``conftest._setup_ts_sdk_integration`` builds the ``ts-sdk/example`` bundle with
+pnpm inside an ephemeral Node container and drops ``bundle.mjs`` plus its
+``airflow-metadata.yaml`` sidecar into the directory ``NodeCoordinator`` scans.
+The ``typescript_example`` Dag (``ts-sdk/example/dags/typescript_example.py``)
+mixes a native Python task with TypeScript stub tasks::
+
+ python_start >> build_message; read_connection
+
+* ``build_message`` / ``read_connection`` are ``@task.stub(queue="typescript")``
+ tasks whose handlers live in the bundle (``ts-sdk/example/src/main.ts``). The
+ ``typescript`` queue is routed to ``NodeCoordinator``, which launches the
+ bundle with the node binary provided by the ``node-provider`` compose service
+ and drives it through the msgpack-over-IPC coordinator protocol.
+* ``python_start`` (Python) pushes an XCom; ``build_message`` (TS) pulls it,
+ reads the ``typescript_example_greeting`` Variable, pushes the
+ ``typescript_message`` XCom, and returns a message object; ``read_connection``
+ (TS) fetches the ``typescript_example_http`` Connection.
+
+The Dag is triggered exactly once by the module-scoped ``completed_run``
+fixture; each test asserts a different facet of that single run. Together they
+confirm, end-to-end:
+
+1. ``NodeCoordinator`` locates the bundle, launches it with the volume-provided
+ Node runtime, and every TypeScript task reports ``SucceedTask``.
+2. Variable / Connection reads and XCom writes work through the Task Execution
+ API, and XCom crosses the Python <-> TypeScript boundary in both directions.
+3. Structured task logs emitted by the TypeScript runtime over the coordinator
+ logs channel reach Airflow's task-log store.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+import pytest
+
+from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+
+# Coordinator startup only needs to launch node with the prebuilt bundle;
+# allow room for scheduling and the Python upstream task.
+_TS_TASK_TIMEOUT = 600
+# Task logs are written when the task finishes; allow a little slack for them
+# to become retrievable through the API after the run reaches a terminal state.
+_LOG_FETCH_TIMEOUT = 120
+
+_DAG_ID = "typescript_example"
+
+
+@dataclass
+class _CompletedRun:
+ """The single ``typescript_example`` run shared across the assertions in this module."""
+
+ client: AirflowClient
+ run_id: str
+ state: str
+ ti_states: dict[str, str]
+
+ def xcom(self, task_id: str, key: str = "return_value"):
+ return self.client.get_xcom_value(dag_id=_DAG_ID, task_id=task_id, run_id=self.run_id, key=key).get(
+ "value"
+ )
+
+ def logs(self, task_id: str, try_number: int = 1) -> str:
+ """Return the concatenated task-log records for *task_id*, retrying until present."""
+ deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
+ while True:
+ resp = self.client.get_task_logs(
+ dag_id=_DAG_ID, run_id=self.run_id, task_id=task_id, try_number=try_number
+ )
+ text = "\n".join(str(entry) for entry in resp.get("content", []))
+ if text.strip() or time.monotonic() > deadline:
+ return text
+ time.sleep(3)
+
+
+@pytest.fixture(scope="module")
+def completed_run() -> _CompletedRun:
+ """Trigger ``typescript_example`` once and wait for it to finish.
+
+ Module-scoped so the run happens a single time; every test in this module
+ inspects the resulting states, XComs, and logs.
+ """
+ client = AirflowClient()
+ resp = client.trigger_dag(_DAG_ID, json={"logical_date": datetime.now(timezone.utc).isoformat()})
+ run_id = resp["dag_run_id"]
+ state = client.wait_for_dag_run(dag_id=_DAG_ID, run_id=run_id, timeout=_TS_TASK_TIMEOUT)
+ ti_resp = client.get_task_instances(dag_id=_DAG_ID, run_id=run_id)
+ ti_states = {ti["task_id"]: ti.get("state") for ti in ti_resp.get("task_instances", [])}
+ return _CompletedRun(client=client, run_id=run_id, state=state, ti_states=ti_states)
+
+
+def test_dag_run_succeeded(completed_run: _CompletedRun):
+ assert completed_run.state == "success", (
+ f"expected the run to succeed; got {completed_run.state!r}. task states: {completed_run.ti_states}"
+ )
+
+
+def test_task_states(completed_run: _CompletedRun):
+ """The Python task and both coordinator-mode TypeScript tasks end ``success``."""
+ expected = {
+ "python_start": "success",
+ "build_message": "success",
+ "read_connection": "success",
+ }
+ for task_id, want in expected.items():
+ assert completed_run.ti_states.get(task_id) == want, (
+ f"{task_id!r} expected {want!r}. all task states: {completed_run.ti_states}"
+ )
+
+
+def test_build_message_xcom_round_trip(completed_run: _CompletedRun):
+ """XCom crosses Python -> TypeScript -> XCom, including the Variable read.
+
+ ``build_message`` pulls ``python_start``'s XCom, prefixes it with the
+ ``typescript_example_greeting`` Variable, pushes the combined text under the
+ ``typescript_message`` key, and returns it as ``return_value``.
+ """
+ assert completed_run.xcom("python_start") == "hello from Python"
+
+ message = "greetings from e2e; upstream=hello from Python"
+ value = completed_run.xcom("build_message")
+ assert value == {"message": message, "upstream": "hello from Python"}, (
+ f"unexpected 'build_message' return_value: {value!r}"
+ )
+ assert completed_run.xcom("build_message", key="typescript_message") == message
+
+
+def test_read_connection_xcom(completed_run: _CompletedRun):
+ """The TypeScript task reads the ``typescript_example_http`` Connection."""
+ value = completed_run.xcom("read_connection")
+ assert value == {
+ "id": "typescript_example_http",
+ "type": "http",
+ "host": "example.com",
+ "login": "user",
+ "hasPassword": True,
+ }, f"unexpected 'read_connection' return_value: {value!r}"
+
+
+def test_coordinator_logs_reach_task_log_store(completed_run: _CompletedRun):
+ """Runtime logs emitted over the coordinator logs channel land in the task logs."""
+ assert "[ts-sdk.runtime] Coordinator runtime started" in completed_run.logs("build_message")
diff --git a/dev/breeze/doc/ci/04_selective_checks.md b/dev/breeze/doc/ci/04_selective_checks.md
index 59d55d1117840..7475d15490414 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -584,6 +584,7 @@ GitHub Actions to pass the list of parameters to a command to execute
| run-mypy | Whether mypy check is supposed to run in this build | true | |
| run-system-tests | Whether system tests should be run ("true"/"false") | true | |
| run-task-sdk-tests | Whether Task SDK tests should be run ("true"/"false") | true | |
+| run-ts-sdk-e2e-tests | Whether TypeScript SDK e2e tests should be run — on `ts-sdk/`, TS e2e test, or Node coordinator changes ("true"/"false") | true | |
| run-ui-tests | Whether UI tests should be run ("true"/"false") | true | |
| run-unit-tests | Whether unit tests should be run ("true"/"false") | true | |
| run-www-tests | Whether Legacy WWW tests should be run ("true"/"false") | true | |
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 970c90e606fce..ae0ca7586035d 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -135,6 +135,7 @@ class FileGroupForCi(Enum):
GO_SDK_E2E_FILES = auto()
OPENLINEAGE_E2E_FILES = auto()
OPENLINEAGE_E2E_COMPAT_FILES = auto()
+ TS_SDK_E2E_FILES = auto()
ALL_PYPROJECT_TOML_FILES = auto()
ALL_PYTHON_FILES = auto()
ALL_SOURCE_FILES = auto()
@@ -271,6 +272,13 @@ def __hash__(self):
r"^airflow-e2e-tests/tests/airflow_e2e_tests/constants\.py$",
r"^airflow-e2e-tests/docker/openlineage-compat\.Dockerfile$",
],
+ FileGroupForCi.TS_SDK_E2E_FILES: [
+ r"^ts-sdk/.*",
+ r"^airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/.*",
+ r"^airflow-e2e-tests/docker/ts\.yml$",
+ r"^task-sdk/src/airflow/sdk/coordinators/_subprocess\.py$",
+ r"^task-sdk/src/airflow/sdk/coordinators/node/.*",
+ ],
FileGroupForCi.PYTHON_PRODUCTION_FILES: [
# Production Python source the runtime ships — excludes tests, docs,
# dev tooling, and generated files within those trees. Used by
@@ -1078,6 +1086,10 @@ def run_openlineage_e2e_compat_tests(self) -> bool:
return True
return self._should_be_run(FileGroupForCi.OPENLINEAGE_E2E_COMPAT_FILES)
+ @cached_property
+ def run_ts_sdk_e2e_tests(self) -> bool:
+ return self._should_be_run(FileGroupForCi.TS_SDK_E2E_FILES)
+
@cached_property
def run_amazon_tests(self) -> bool:
if self.providers_test_types_list_as_strings_in_json == "[]":
@@ -1222,6 +1234,7 @@ def prod_image_build(self) -> bool:
or self.run_go_sdk_e2e_tests
or self.run_openlineage_e2e_tests
or self.run_openlineage_e2e_compat_tests
+ or self.run_ts_sdk_e2e_tests
or self.run_ui_e2e_tests
)
diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py
index 3698d41d51b33..c901496baec3d 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -811,6 +811,21 @@ def assert_outputs_are_printed(expected_outputs: dict[str, str], stderr: str):
"Task SDK integration tests and prod image build should run but no other tests",
)
),
+ (
+ pytest.param(
+ ("ts-sdk/src/coordinator/runtime.ts",),
+ {
+ "prod-image-build": "true",
+ "run-unit-tests": "false",
+ "run-task-sdk-tests": "false",
+ "run-task-sdk-integration-tests": "false",
+ "run-ts-sdk-e2e-tests": "true",
+ "run-go-sdk-e2e-tests": "false",
+ "full-tests-needed": "false",
+ },
+ id="TypeScript SDK files changed - TS SDK e2e tests and prod image build should run",
+ )
+ ),
(
pytest.param(
("airflow-ctl/src/airflowctl/random.py",),
diff --git a/ts-sdk/example/.gitignore b/ts-sdk/example/.gitignore
index 8ee10058ff835..1652dc9c671bd 100644
--- a/ts-sdk/example/.gitignore
+++ b/ts-sdk/example/.gitignore
@@ -1,3 +1,4 @@
.pnpm-store/
dist/
node_modules/
+pnpm-lock.yaml
From c898038cf6937ea950619d089f24ab5e6344406e Mon Sep 17 00:00:00 2001
From: "Guan-Ming (Wesley) Chiu"
<105915352+guan404ming@users.noreply.github.com>
Date: Fri, 3 Jul 2026 23:02:07 +0800
Subject: [PATCH 2/5] Register ts_sdk e2e test mode in breeze CLI
---
.github/workflows/airflow-e2e-tests.yml | 4 ++--
dev/breeze/src/airflow_breeze/commands/testing_commands.py | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/airflow-e2e-tests.yml b/.github/workflows/airflow-e2e-tests.yml
index 8cb55568e3e5b..26f94e9740f39 100644
--- a/.github/workflows/airflow-e2e-tests.yml
+++ b/.github/workflows/airflow-e2e-tests.yml
@@ -49,7 +49,7 @@ on: # yamllint disable-line rule:truthy
type: string
required: true
e2e_test_mode:
- description: "Test mode - basic, remote_log, remote_log_elasticsearch, remote_log_opensearch, xcom_object_storage, event_driven, java_sdk, or go_sdk" # yamllint disable-line rule:line-length
+ description: "Test mode - basic, remote_log, remote_log_elasticsearch, remote_log_opensearch, xcom_object_storage, event_driven, java_sdk, go_sdk, or ts_sdk" # yamllint disable-line rule:line-length
type: string
default: "basic"
java-sdk-version:
@@ -84,7 +84,7 @@ on: # yamllint disable-line rule:truthy
type: string
default: ""
e2e_test_mode:
- description: "Test mode - basic, remote_log, remote_log_elasticsearch, remote_log_opensearch, xcom_object_storage, event_driven, java_sdk, or go_sdk" # yamllint disable-line rule:line-length
+ description: "Test mode - basic, remote_log, remote_log_elasticsearch, remote_log_opensearch, xcom_object_storage, event_driven, java_sdk, go_sdk, or ts_sdk" # yamllint disable-line rule:line-length
type: string
default: "basic"
java-sdk-version:
diff --git a/dev/breeze/src/airflow_breeze/commands/testing_commands.py b/dev/breeze/src/airflow_breeze/commands/testing_commands.py
index fd3f0444bd895..7731c25a3548b 100644
--- a/dev/breeze/src/airflow_breeze/commands/testing_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/testing_commands.py
@@ -1513,6 +1513,7 @@ def _build_openlineage_e2e_compat_image(airflow_version: str, python: str) -> st
"java_sdk",
"go_sdk",
"openlineage",
+ "ts_sdk",
],
case_sensitive=False,
),
From a94077a1a4fcc0f60064453b6fabe0a1e5601ecb Mon Sep 17 00:00:00 2001
From: "Guan-Ming (Wesley) Chiu"
<105915352+guan404ming@users.noreply.github.com>
Date: Sat, 18 Jul 2026 21:53:26 +0800
Subject: [PATCH 3/5] Exercise real airflow-ts-pack packing in TypeScript SDK
e2e tests
The reviewer asked the e2e tests to wait for airflow-ts-pack and use its
real packing path. The example build now runs airflow-ts-pack, which embeds
the airflow metadata in bundle.mjs, so the hand-written
airflow-metadata.yaml sidecar would bypass the path users actually take.
---
.../tests/airflow_e2e_tests/conftest.py | 24 +++++++------------
.../ts_sdk_tests/test_ts_sdk_dag.py | 5 ++--
.../output_testing_airflow-e2e-tests.svg | 2 +-
.../output_testing_airflow-e2e-tests.txt | 2 +-
4 files changed, 14 insertions(+), 19 deletions(-)
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 1e0a6e0c465e5..e4ab0027220a1 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -18,7 +18,6 @@
import json
import os
-import re
import subprocess
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
@@ -67,7 +66,6 @@
TS_COMPOSE_PATH,
TS_SDK_BUILD_HOME_PATH,
TS_SDK_EXAMPLE_PATH,
- TS_SDK_ROOT_PATH,
XCOM_BUCKET,
)
@@ -650,10 +648,11 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
"""Set up the ts_sdk E2E test mode.
Builds the ts-sdk example bundle with pnpm inside an ephemeral Node
- container (the host needs no Node toolchain), drops it with its
- airflow-metadata.yaml sidecar into the directory ``NodeCoordinator``
- scans, copies the Python stub Dag, and writes the coordinator
- configuration.
+ container (the host needs no Node toolchain); the example build runs
+ ``airflow-ts-pack``, which embeds the airflow metadata in the bundle
+ itself. The bundle is dropped into the directory ``NodeCoordinator``
+ scans, the Python stub Dag is copied, and the coordinator
+ configuration is written.
The worker has no Node runtime of its own; the node-provider service in
``ts.yml`` copies the node binary out of the same image used here for the
@@ -706,18 +705,13 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
# Copy the compose override into the temp directory.
copyfile(TS_COMPOSE_PATH, tmp_dir / "ts.yml")
- # Place the bundle and its metadata sidecar where the compose bind-mount
- # (./ts-bundles) exposes them to the worker at /opt/airflow/ts-bundles.
+ # Place the bundle where the compose bind-mount (./ts-bundles) exposes it
+ # to the worker at /opt/airflow/ts-bundles. airflow-ts-pack embeds the
+ # airflow metadata in bundle.mjs itself, so no sidecar is written — the
+ # coordinator must resolve the schema version from the embedded manifest.
ts_bundles_dir = tmp_dir / "ts-bundles"
ts_bundles_dir.mkdir()
copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.mjs", ts_bundles_dir / "bundle.mjs")
- supervisor_ts = (TS_SDK_ROOT_PATH / "src" / "generated" / "supervisor.ts").read_text()
- version_match = re.search(r'SUPERVISOR_API_VERSION = "(\d{4}-\d{2}-\d{2})"', supervisor_ts)
- if not version_match:
- raise RuntimeError("Cannot find SUPERVISOR_API_VERSION in ts-sdk/src/generated/supervisor.ts")
- (ts_bundles_dir / "airflow-metadata.yaml").write_text(
- f'sdk:\n supervisor_schema_version: "{version_match.group(1)}"\n'
- )
# Copy the TS SDK example stub Dag so Airflow can discover and serialize it.
copyfile(
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
index 542bbf387cd4d..8fd7d4835ca4c 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
@@ -24,8 +24,9 @@
What is verified
----------------
``conftest._setup_ts_sdk_integration`` builds the ``ts-sdk/example`` bundle with
-pnpm inside an ephemeral Node container and drops ``bundle.mjs`` plus its
-``airflow-metadata.yaml`` sidecar into the directory ``NodeCoordinator`` scans.
+pnpm inside an ephemeral Node container — the example build runs
+``airflow-ts-pack``, which embeds the airflow metadata in ``bundle.mjs`` — and
+drops the bundle into the directory ``NodeCoordinator`` scans.
The ``typescript_example`` Dag (``ts-sdk/example/dags/typescript_example.py``)
mixes a native Python task with TypeScript stub tasks::
diff --git a/dev/breeze/doc/images/output_testing_airflow-e2e-tests.svg b/dev/breeze/doc/images/output_testing_airflow-e2e-tests.svg
index 7f2f3b4a26aa7..942306b371c88 100644
--- a/dev/breeze/doc/images/output_testing_airflow-e2e-tests.svg
+++ b/dev/breeze/doc/images/output_testing_airflow-e2e-tests.svg
@@ -148,7 +148,7 @@
│(TEXT)│
│--e2e-test-mode Specify the mode to use for E2E tests. [default: basic]│
│(basic|remote_log|remote_log_elasticsearch|remote_log_opensearch|xcom_object_sto│
-│rage|event_driven|java_sdk|go_sdk|openlineage)│
+│rage|event_driven|java_sdk|go_sdk|openlineage|ts_sdk)│
│--airflow-version Run the openlineage mode against a released Airflow version (e.g. 3.1.8) with │
│current providers from main, instead of the default PROD image. Builds a │
│lightweight image from apache/airflow:<version>. Only used by the openlineage │
diff --git a/dev/breeze/doc/images/output_testing_airflow-e2e-tests.txt b/dev/breeze/doc/images/output_testing_airflow-e2e-tests.txt
index 123969fa33710..31336273b3b1a 100644
--- a/dev/breeze/doc/images/output_testing_airflow-e2e-tests.txt
+++ b/dev/breeze/doc/images/output_testing_airflow-e2e-tests.txt
@@ -1 +1 @@
-45750ff1a736395d4f563fdca433886f
+3005f4343ce3d9c33960042d8d7e2d20
From a498f5da934e23937d800ac0f656eef5ebb106c4 Mon Sep 17 00:00:00 2001
From: "Guan-Ming (Wesley) Chiu"
<105915352+guan404ming@users.noreply.github.com>
Date: Sat, 18 Jul 2026 22:44:26 +0800
Subject: [PATCH 4/5] Trim ts_sdk e2e docstrings and comments to essentials
---
airflow-e2e-tests/docker/ts.yml | 11 ++--
.../tests/airflow_e2e_tests/conftest.py | 42 ++++---------
.../tests/airflow_e2e_tests/constants.py | 8 +--
.../ts_sdk_tests/test_ts_sdk_dag.py | 60 ++++---------------
4 files changed, 31 insertions(+), 90 deletions(-)
diff --git a/airflow-e2e-tests/docker/ts.yml b/airflow-e2e-tests/docker/ts.yml
index 6ea502425056d..056106ff9d54a 100644
--- a/airflow-e2e-tests/docker/ts.yml
+++ b/airflow-e2e-tests/docker/ts.yml
@@ -17,13 +17,10 @@
# Docker Compose override for ts_sdk E2E test mode.
#
-# NodeCoordinator launches the TypeScript bundle with a Node.js runtime, which
-# the stock Airflow worker image does not ship. The node-provider service
-# copies the node binary from the official Node image (the same image
-# conftest._setup_ts_sdk_integration builds the bundle with) into a shared
-# volume, the bundle built by conftest is bind-mounted into the directory
-# NodeCoordinator scans, and the worker consumes the "typescript" Celery queue
-# where @task.stub tasks are routed.
+# The stock worker image ships no Node.js runtime, so node-provider copies the
+# node binary from the same image conftest builds the bundle with into a
+# shared volume. The bundle is bind-mounted where NodeCoordinator scans, and
+# the worker consumes the "typescript" queue where @task.stub tasks are routed.
---
services:
node-provider:
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index e4ab0027220a1..df4bc7ea08362 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -647,24 +647,17 @@ def _setup_openlineage_integration(dot_env_file, tmp_dir):
def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
"""Set up the ts_sdk E2E test mode.
- Builds the ts-sdk example bundle with pnpm inside an ephemeral Node
- container (the host needs no Node toolchain); the example build runs
- ``airflow-ts-pack``, which embeds the airflow metadata in the bundle
- itself. The bundle is dropped into the directory ``NodeCoordinator``
- scans, the Python stub Dag is copied, and the coordinator
- configuration is written.
-
- The worker has no Node runtime of its own; the node-provider service in
- ``ts.yml`` copies the node binary out of the same image used here for the
- build, so the bundle executes on the runtime it was built for.
+ Builds the ts-sdk example bundle in an ephemeral Node container (no host
+ Node toolchain needed); the example build runs ``airflow-ts-pack``, which
+ embeds the airflow metadata in ``bundle.mjs``. The node-provider service
+ in ``ts.yml`` gives the worker the node binary from the same image, so
+ the bundle runs on the runtime it was built for.
"""
- # --user keeps build outputs owned by the current user (not root); HOME
- # points at a writable, gitignored dir so the pnpm store and corepack
- # cache persist between runs. corepack resolves the pnpm version pinned
- # by ts-sdk/package.json's packageManager field.
+ # --user keeps build outputs owned by the current user; HOME is a
+ # writable, gitignored dir so pnpm/corepack caches persist between runs.
TS_SDK_BUILD_HOME_PATH.mkdir(parents=True, exist_ok=True)
- # Shims go into a writable dir on PATH (the container user cannot write to
- # /usr/local/bin) so nested `pnpm run ...` invocations resolve pnpm too.
+ # corepack shims go in $HOME/bin (on PATH) because the container user
+ # cannot write to /usr/local/bin.
build_script = (
'export PATH="$HOME/bin:$PATH"'
' && mkdir -p "$HOME/bin"'
@@ -689,8 +682,6 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
"-e",
"COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
"-e",
- # Non-interactive pnpm: never prompt (e.g. before replacing a
- # node_modules left behind by a host-side install).
"CI=true",
"-v",
f"{AIRFLOW_ROOT_PATH}:/repo",
@@ -702,26 +693,18 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
check=True,
)
- # Copy the compose override into the temp directory.
copyfile(TS_COMPOSE_PATH, tmp_dir / "ts.yml")
- # Place the bundle where the compose bind-mount (./ts-bundles) exposes it
- # to the worker at /opt/airflow/ts-bundles. airflow-ts-pack embeds the
- # airflow metadata in bundle.mjs itself, so no sidecar is written — the
- # coordinator must resolve the schema version from the embedded manifest.
+ # Deliberately no metadata sidecar: the coordinator must resolve the schema
+ # version from the metadata airflow-ts-pack embedded in the bundle.
ts_bundles_dir = tmp_dir / "ts-bundles"
ts_bundles_dir.mkdir()
copyfile(TS_SDK_EXAMPLE_PATH / "dist" / "bundle.mjs", ts_bundles_dir / "bundle.mjs")
- # Copy the TS SDK example stub Dag so Airflow can discover and serialize it.
copyfile(
TS_SDK_EXAMPLE_PATH / "dags" / "typescript_example.py", tmp_dir / "dags" / "typescript_example.py"
)
- # Coordinator registry: maps the logical name "ts" to NodeCoordinator, which
- # scans bundles_root for bundle.mjs and launches it with the node binary
- # provided by the node-provider service.
- # Queue mapping: routes tasks on the "typescript" queue to "ts".
coordinator_config = json.dumps(
{
"ts": {
@@ -738,10 +721,9 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
dot_env_file.write_text(
f"AIRFLOW_UID={os.getuid()}\n"
f"NODE_IMAGE={NODE_IMAGE}\n"
- # Single-quote the JSON values so Docker Compose reads them literally.
+ # single-quoted so Docker Compose reads the JSON literally
f"AIRFLOW__SDK__COORDINATORS='{coordinator_config}'\n"
f"AIRFLOW__SDK__QUEUE_TO_COORDINATOR='{queue_to_coordinator}'\n"
- # Connection and variable read by the TS example bundle tasks.
"AIRFLOW_CONN_TYPESCRIPT_EXAMPLE_HTTP=http://user:pass@example.com/\n"
"AIRFLOW_VAR_TYPESCRIPT_EXAMPLE_GREETING=greetings from e2e\n"
)
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index 2b245a0021cc1..e389a8bc6b9bf 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -94,12 +94,10 @@
TS_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "ts-sdk"
TS_SDK_EXAMPLE_PATH = TS_SDK_ROOT_PATH / "example"
TS_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "ts.yml"
-# Node image used both to build the bundle and to provide the node binary to the
-# worker (ts.yml); keeping them identical guarantees the bundle runs on the same
-# runtime it was built for.
+# Builds the bundle and provides the worker's node binary (ts.yml), so the
+# bundle runs on the runtime it was built for.
NODE_IMAGE = os.environ.get("NODE_IMAGE", "node:22-slim")
-# Writable HOME for the containerized pnpm build; keeps the pnpm store and
-# corepack cache between runs (files/ is gitignored).
+# Writable HOME for the containerized pnpm build; gitignored, caches persist.
TS_SDK_BUILD_HOME_PATH = AIRFLOW_ROOT_PATH / "files" / "pnpm-home"
# Local provider sources are mounted into the airflow containers under this directory so
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
index 8fd7d4835ca4c..3c30810065472 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
@@ -21,37 +21,14 @@
E2E_TEST_MODE=ts_sdk uv run --project airflow-e2e-tests pytest \\
tests/airflow_e2e_tests/ts_sdk_tests/ -xvs
-What is verified
-----------------
-``conftest._setup_ts_sdk_integration`` builds the ``ts-sdk/example`` bundle with
-pnpm inside an ephemeral Node container — the example build runs
-``airflow-ts-pack``, which embeds the airflow metadata in ``bundle.mjs`` — and
-drops the bundle into the directory ``NodeCoordinator`` scans.
-The ``typescript_example`` Dag (``ts-sdk/example/dags/typescript_example.py``)
-mixes a native Python task with TypeScript stub tasks::
-
- python_start >> build_message; read_connection
-
-* ``build_message`` / ``read_connection`` are ``@task.stub(queue="typescript")``
- tasks whose handlers live in the bundle (``ts-sdk/example/src/main.ts``). The
- ``typescript`` queue is routed to ``NodeCoordinator``, which launches the
- bundle with the node binary provided by the ``node-provider`` compose service
- and drives it through the msgpack-over-IPC coordinator protocol.
-* ``python_start`` (Python) pushes an XCom; ``build_message`` (TS) pulls it,
- reads the ``typescript_example_greeting`` Variable, pushes the
- ``typescript_message`` XCom, and returns a message object; ``read_connection``
- (TS) fetches the ``typescript_example_http`` Connection.
-
-The Dag is triggered exactly once by the module-scoped ``completed_run``
-fixture; each test asserts a different facet of that single run. Together they
-confirm, end-to-end:
-
-1. ``NodeCoordinator`` locates the bundle, launches it with the volume-provided
- Node runtime, and every TypeScript task reports ``SucceedTask``.
-2. Variable / Connection reads and XCom writes work through the Task Execution
- API, and XCom crosses the Python <-> TypeScript boundary in both directions.
-3. Structured task logs emitted by the TypeScript runtime over the coordinator
- logs channel reach Airflow's task-log store.
+The ``typescript_example`` Dag mixes a Python task with ``@task.stub``
+TypeScript tasks whose handlers live in the ``airflow-ts-pack`` bundle built
+by ``conftest._setup_ts_sdk_integration``. Triggered once via the
+module-scoped ``completed_run`` fixture, the run confirms end-to-end that
+``NodeCoordinator`` launches the bundle on the volume-provided Node runtime,
+Variable/Connection reads and Python <-> TypeScript XCom round-trips work
+through the Task Execution API, and coordinator-channel logs reach the
+task-log store.
"""
from __future__ import annotations
@@ -76,8 +53,6 @@
@dataclass
class _CompletedRun:
- """The single ``typescript_example`` run shared across the assertions in this module."""
-
client: AirflowClient
run_id: str
state: str
@@ -89,7 +64,7 @@ def xcom(self, task_id: str, key: str = "return_value"):
)
def logs(self, task_id: str, try_number: int = 1) -> str:
- """Return the concatenated task-log records for *task_id*, retrying until present."""
+ """Fetch task logs, retrying until present (log upload is async)."""
deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
while True:
resp = self.client.get_task_logs(
@@ -103,11 +78,7 @@ def logs(self, task_id: str, try_number: int = 1) -> str:
@pytest.fixture(scope="module")
def completed_run() -> _CompletedRun:
- """Trigger ``typescript_example`` once and wait for it to finish.
-
- Module-scoped so the run happens a single time; every test in this module
- inspects the resulting states, XComs, and logs.
- """
+ """Trigger ``typescript_example`` once; every test inspects the same run."""
client = AirflowClient()
resp = client.trigger_dag(_DAG_ID, json={"logical_date": datetime.now(timezone.utc).isoformat()})
run_id = resp["dag_run_id"]
@@ -124,7 +95,6 @@ def test_dag_run_succeeded(completed_run: _CompletedRun):
def test_task_states(completed_run: _CompletedRun):
- """The Python task and both coordinator-mode TypeScript tasks end ``success``."""
expected = {
"python_start": "success",
"build_message": "success",
@@ -137,12 +107,8 @@ def test_task_states(completed_run: _CompletedRun):
def test_build_message_xcom_round_trip(completed_run: _CompletedRun):
- """XCom crosses Python -> TypeScript -> XCom, including the Variable read.
-
- ``build_message`` pulls ``python_start``'s XCom, prefixes it with the
- ``typescript_example_greeting`` Variable, pushes the combined text under the
- ``typescript_message`` key, and returns it as ``return_value``.
- """
+ """``build_message`` combines ``python_start``'s XCom with the Variable,
+ pushes it under ``typescript_message``, and returns it."""
assert completed_run.xcom("python_start") == "hello from Python"
message = "greetings from e2e; upstream=hello from Python"
@@ -154,7 +120,6 @@ def test_build_message_xcom_round_trip(completed_run: _CompletedRun):
def test_read_connection_xcom(completed_run: _CompletedRun):
- """The TypeScript task reads the ``typescript_example_http`` Connection."""
value = completed_run.xcom("read_connection")
assert value == {
"id": "typescript_example_http",
@@ -166,5 +131,4 @@ def test_read_connection_xcom(completed_run: _CompletedRun):
def test_coordinator_logs_reach_task_log_store(completed_run: _CompletedRun):
- """Runtime logs emitted over the coordinator logs channel land in the task logs."""
assert "[ts-sdk.runtime] Coordinator runtime started" in completed_run.logs("build_message")
From 7b2f74b01648988ae7a87f055c54de94b979d83e Mon Sep 17 00:00:00 2001
From: "Guan-Ming (Wesley) Chiu"
<105915352+guan404ming@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:38:22 +0800
Subject: [PATCH 5/5] Build TS SDK e2e example natively in CI and skip doc-only
changes
---
.github/workflows/airflow-e2e-tests.yml | 20 +++++++++++--
.../tests/airflow_e2e_tests/conftest.py | 29 ++++++++++---------
.../airflow_breeze/utils/selective_checks.py | 2 +-
dev/breeze/tests/test_selective_checks.py | 11 +++++++
4 files changed, 44 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/airflow-e2e-tests.yml b/.github/workflows/airflow-e2e-tests.yml
index 26f94e9740f39..3ce03733da04f 100644
--- a/.github/workflows/airflow-e2e-tests.yml
+++ b/.github/workflows/airflow-e2e-tests.yml
@@ -121,8 +121,9 @@ jobs:
use-uv: ${{ inputs.use-uv }}
make-mnt-writeable-and-cleanup: true
id: breeze
- # The java_sdk mode runs the Gradle builds and the go_sdk mode runs the Go
- # build; every other e2e mode skips the toolchain steps below.
+ # The java_sdk mode runs the Gradle builds, the go_sdk mode runs the Go
+ # build and the ts_sdk mode runs the pnpm build; every other e2e mode
+ # skips the toolchain steps below.
# LANG_SDK_NATIVE_TOOLCHAIN=true (the same switch the lang-SDK k8s job uses)
# makes the e2e conftest build with the host toolchain provisioned here
# instead of an ephemeral toolchain container, skipping the image pull; local
@@ -172,12 +173,25 @@ jobs:
key: e2e-go-sdk-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go-sdk/go.mod', 'go-sdk/go.sum') }} # yamllint disable-line rule:line-length
restore-keys: |
e2e-go-sdk-go-v1-${{ runner.os }}-${{ runner.arch }}-
+ - name: "Setup pnpm for the TypeScript SDK build"
+ if: inputs.e2e_test_mode == 'ts_sdk'
+ uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ with:
+ package_json_file: ts-sdk/package.json
+ run_install: false
+ - name: "Setup Node for the TypeScript SDK build"
+ if: inputs.e2e_test_mode == 'ts_sdk'
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+ cache: 'pnpm'
+ cache-dependency-path: 'ts-sdk/pnpm-lock.yaml'
- name: "Test e2e integration tests"
run: breeze testing airflow-e2e-tests
env:
DOCKER_IMAGE: "${{ inputs.docker-image-tag }}"
E2E_TEST_MODE: "${{ inputs.e2e_test_mode }}"
- LANG_SDK_NATIVE_TOOLCHAIN: "${{ (inputs.e2e_test_mode == 'java_sdk' || inputs.e2e_test_mode == 'go_sdk') && 'true' || 'false' }}" # yamllint disable-line rule:line-length
+ LANG_SDK_NATIVE_TOOLCHAIN: "${{ (inputs.e2e_test_mode == 'java_sdk' || inputs.e2e_test_mode == 'go_sdk' || inputs.e2e_test_mode == 'ts_sdk') && 'true' || 'false' }}" # yamllint disable-line rule:line-length
- name: "Save Java SDK Gradle dependency cache"
# Saved even when the e2e tests fail: the Gradle warm-up is independent of
# test outcome, and actions/cache's post step would drop it on every red run.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index df4bc7ea08362..b97add595fd11 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -66,6 +66,7 @@
TS_COMPOSE_PATH,
TS_SDK_BUILD_HOME_PATH,
TS_SDK_EXAMPLE_PATH,
+ TS_SDK_ROOT_PATH,
XCOM_BUCKET,
)
@@ -644,15 +645,14 @@ def _setup_openlineage_integration(dot_env_file, tmp_dir):
copyfile(OPENLINEAGE_COMPOSE_PATH, tmp_dir / "openlineage.yml")
-def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
- """Set up the ts_sdk E2E test mode.
-
- Builds the ts-sdk example bundle in an ephemeral Node container (no host
- Node toolchain needed); the example build runs ``airflow-ts-pack``, which
- embeds the airflow metadata in ``bundle.mjs``. The node-provider service
- in ``ts.yml`` gives the worker the node binary from the same image, so
- the bundle runs on the runtime it was built for.
- """
+def _build_ts_sdk_example_bundle(*, native=False):
+ build_commands = (
+ "pnpm install --frozen-lockfile && pnpm run build && cd example && pnpm install && pnpm run build"
+ )
+ if native:
+ console.print("[yellow]Building TypeScript SDK example bundle (host toolchain)...")
+ subprocess.run(["bash", "-c", build_commands], cwd=TS_SDK_ROOT_PATH, check=True)
+ return
# --user keeps build outputs owned by the current user; HOME is a
# writable, gitignored dir so pnpm/corepack caches persist between runs.
TS_SDK_BUILD_HOME_PATH.mkdir(parents=True, exist_ok=True)
@@ -663,11 +663,7 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
' && mkdir -p "$HOME/bin"'
' && corepack enable --install-directory "$HOME/bin"'
" && cd /repo/ts-sdk"
- " && pnpm install --frozen-lockfile"
- " && pnpm run build"
- " && cd example"
- " && pnpm install"
- " && pnpm run build"
+ f" && {build_commands}"
)
console.print(f"[yellow]Building TypeScript SDK example bundle ({NODE_IMAGE})...")
subprocess.run(
@@ -693,6 +689,11 @@ def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
check=True,
)
+
+def _setup_ts_sdk_integration(dot_env_file, tmp_dir):
+ """Set up the ts_sdk E2E test mode."""
+ _build_ts_sdk_example_bundle(native=LANG_SDK_NATIVE_TOOLCHAIN)
+
copyfile(TS_COMPOSE_PATH, tmp_dir / "ts.yml")
# Deliberately no metadata sidecar: the coordinator must resolve the schema
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index ae0ca7586035d..b0204836b7169 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -273,7 +273,7 @@ def __hash__(self):
r"^airflow-e2e-tests/docker/openlineage-compat\.Dockerfile$",
],
FileGroupForCi.TS_SDK_E2E_FILES: [
- r"^ts-sdk/.*",
+ r"^ts-sdk/(?!.*\.md$).*",
r"^airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/.*",
r"^airflow-e2e-tests/docker/ts\.yml$",
r"^task-sdk/src/airflow/sdk/coordinators/_subprocess\.py$",
diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py
index c901496baec3d..384ca066f9a48 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -826,6 +826,17 @@ def assert_outputs_are_printed(expected_outputs: dict[str, str], stderr: str):
id="TypeScript SDK files changed - TS SDK e2e tests and prod image build should run",
)
),
+ (
+ pytest.param(
+ ("ts-sdk/README.md",),
+ {
+ "prod-image-build": "false",
+ "run-ts-sdk-e2e-tests": "false",
+ "full-tests-needed": "false",
+ },
+ id="TypeScript SDK README-only change - TS SDK e2e tests should be skipped",
+ )
+ ),
(
pytest.param(
("airflow-ctl/src/airflowctl/random.py",),