From 22b3ca7916169d47c35f9e245995ea7c2117af2e Mon Sep 17 00:00:00 2001 From: Joshua Wink Date: Sat, 10 Oct 2026 09:04:57 -0500 Subject: [PATCH 1/2] feat(typescript): a TypeScript runtime, written to the contract packages/codeupipe-ts has the eight primitives in TypeScript, with no dependencies. The 27 conformance cases judge it as the target typescript:run, with the same judge as the Python targets. - One run, which returns a Promise. A step can be sync or async. - Names are the names of the contract in camelCase. Absent is null. - A step is reported by its label, the name of its class. A function is refused as a step, because every JavaScript function has a call method. - Each run writes to its own State. - No export command and no linter in this package. conformance/run.py runs the TypeScript target when the machine has Node 22.18 or later, which runs the .ts runner as it is. A target that is named and cannot run is now a failure. CI has a job for the package: type check, tests, build, example and conformance. The contract has a "Runtimes" section with the differences between the two runtimes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 28 ++ README.md | 23 +- conformance/API.md | 35 ++- conformance/MATRIX.md | 62 ++--- conformance/README.md | 5 + conformance/run.py | 37 ++- .../runners/typescript/cup-ops/add-filter.ts | 16 ++ .../typescript/cup-ops/append-filter.ts | 17 ++ .../runners/typescript/cup-ops/copy-filter.ts | 16 ++ .../typescript/cup-ops/equals-predicate.ts | 16 ++ .../runners/typescript/cup-ops/fail-filter.ts | 14 + .../runners/typescript/cup-ops/fail-tap.ts | 14 + .../runners/typescript/cup-ops/has-filter.ts | 16 ++ .../typescript/cup-ops/merge-filter.ts | 15 ++ .../typescript/cup-ops/mutate-filter.ts | 17 ++ .../typescript/cup-ops/nested-pipeline.ts | 4 + .../runners/typescript/cup-ops/noop-tap.ts | 6 + .../runners/typescript/cup-ops/record-hook.ts | 26 ++ .../runners/typescript/cup-ops/record-tap.ts | 16 ++ .../typescript/cup-ops/remove-filter.ts | 14 + .../runners/typescript/cup-ops/set-filter.ts | 16 ++ .../typescript/cup-ops/truthy-predicate.ts | 14 + conformance/runners/typescript/package.json | 4 + conformance/runners/typescript/runner.ts | 144 ++++++++++ packages/codeupipe-ts/.gitignore | 2 + packages/codeupipe-ts/LICENSE | 177 ++++++++++++ packages/codeupipe-ts/NOTICE | 4 + packages/codeupipe-ts/README.md | 154 +++++++++++ .../examples/pricing/at-least-predicate.ts | 20 ++ .../examples/pricing/audit-tap.ts | 11 + .../examples/pricing/discount-filter.ts | 15 ++ .../codeupipe-ts/examples/pricing/main.ts | 15 ++ .../examples/pricing/mark-bulk-filter.ts | 8 + .../examples/pricing/pricing-pipeline.ts | 17 ++ .../examples/pricing/print-steps-hook.ts | 13 + packages/codeupipe-ts/package-lock.json | 51 ++++ packages/codeupipe-ts/package.json | 35 +++ packages/codeupipe-ts/src/filter.ts | 6 + packages/codeupipe-ts/src/hook.ts | 18 ++ packages/codeupipe-ts/src/index.ts | 14 + packages/codeupipe-ts/src/payload.ts | 87 ++++++ packages/codeupipe-ts/src/pipeline.ts | 211 +++++++++++++++ packages/codeupipe-ts/src/predicate.ts | 11 + packages/codeupipe-ts/src/state.ts | 32 +++ packages/codeupipe-ts/src/step-label.ts | 9 + packages/codeupipe-ts/src/tap.ts | 9 + packages/codeupipe-ts/src/valve.ts | 18 ++ packages/codeupipe-ts/tests/payload.test.ts | 91 +++++++ packages/codeupipe-ts/tests/pipeline.test.ts | 254 ++++++++++++++++++ .../tests/pricing-example.test.ts | 20 ++ .../codeupipe-ts/tests/step-label.test.ts | 106 ++++++++ packages/codeupipe-ts/tsconfig.build.json | 11 + packages/codeupipe-ts/tsconfig.json | 19 ++ tests/conformance/test_typescript_target.py | 42 +++ 54 files changed, 2010 insertions(+), 45 deletions(-) create mode 100644 conformance/runners/typescript/cup-ops/add-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/append-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/copy-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/equals-predicate.ts create mode 100644 conformance/runners/typescript/cup-ops/fail-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/fail-tap.ts create mode 100644 conformance/runners/typescript/cup-ops/has-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/merge-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/mutate-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/nested-pipeline.ts create mode 100644 conformance/runners/typescript/cup-ops/noop-tap.ts create mode 100644 conformance/runners/typescript/cup-ops/record-hook.ts create mode 100644 conformance/runners/typescript/cup-ops/record-tap.ts create mode 100644 conformance/runners/typescript/cup-ops/remove-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/set-filter.ts create mode 100644 conformance/runners/typescript/cup-ops/truthy-predicate.ts create mode 100644 conformance/runners/typescript/package.json create mode 100644 conformance/runners/typescript/runner.ts create mode 100644 packages/codeupipe-ts/.gitignore create mode 100644 packages/codeupipe-ts/LICENSE create mode 100644 packages/codeupipe-ts/NOTICE create mode 100644 packages/codeupipe-ts/README.md create mode 100644 packages/codeupipe-ts/examples/pricing/at-least-predicate.ts create mode 100644 packages/codeupipe-ts/examples/pricing/audit-tap.ts create mode 100644 packages/codeupipe-ts/examples/pricing/discount-filter.ts create mode 100644 packages/codeupipe-ts/examples/pricing/main.ts create mode 100644 packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts create mode 100644 packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts create mode 100644 packages/codeupipe-ts/examples/pricing/print-steps-hook.ts create mode 100644 packages/codeupipe-ts/package-lock.json create mode 100644 packages/codeupipe-ts/package.json create mode 100644 packages/codeupipe-ts/src/filter.ts create mode 100644 packages/codeupipe-ts/src/hook.ts create mode 100644 packages/codeupipe-ts/src/index.ts create mode 100644 packages/codeupipe-ts/src/payload.ts create mode 100644 packages/codeupipe-ts/src/pipeline.ts create mode 100644 packages/codeupipe-ts/src/predicate.ts create mode 100644 packages/codeupipe-ts/src/state.ts create mode 100644 packages/codeupipe-ts/src/step-label.ts create mode 100644 packages/codeupipe-ts/src/tap.ts create mode 100644 packages/codeupipe-ts/src/valve.ts create mode 100644 packages/codeupipe-ts/tests/payload.test.ts create mode 100644 packages/codeupipe-ts/tests/pipeline.test.ts create mode 100644 packages/codeupipe-ts/tests/pricing-example.test.ts create mode 100644 packages/codeupipe-ts/tests/step-label.test.ts create mode 100644 packages/codeupipe-ts/tsconfig.build.json create mode 100644 packages/codeupipe-ts/tsconfig.json create mode 100644 tests/conformance/test_typescript_target.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b957ad..57ad35c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,34 @@ jobs: - name: Conformance run: python conformance/run.py + typescript: + name: TypeScript runtime + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/codeupipe-ts + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v5 + with: + node-version: "24" + - name: Install + run: npm ci + - name: Type check + run: npm run typecheck + - name: Tests + run: npm test + - name: Build + run: npm run build + - name: Example + run: node examples/pricing/main.ts + - name: Conformance + working-directory: . + run: python conformance/run.py typescript:run + export: name: Export against dynamic run runs-on: ubuntu-latest diff --git a/README.md b/README.md index bebed8c..43f8f78 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # codeupipe -codeupipe is a Python library for writing a program as a pipeline of small -steps. +codeupipe is a library for writing a program as a pipeline of small steps. +It is for Python. A TypeScript runtime is in this repository and is not on +npm yet. There are eight primitives. Each one does one thing. @@ -28,12 +29,13 @@ If you know Unix, these are close to things you know. | Hook | A git hook: code that runs before and after a step | | State | `$?` and `PIPESTATUS` | -This repository has two packages. +This repository has three packages. | Package | Contents | Reference | |---|---|---| | `codeupipe` | The eight primitives. An export command that writes a pipeline as plain Python with no import of codeupipe. A config file reader. No dependencies | [conformance/API.md](conformance/API.md), [docs/static-export.md](docs/static-export.md), [docs/config.md](docs/config.md) | | `codeupipe-lint` | Two linters for pipeline code. Depends on `codeupipe` only | [packages/codeupipe-lint/README.md](packages/codeupipe-lint/README.md) | +| `codeupipe` for TypeScript | The eight primitives. No dependencies. No export and no linter | [packages/codeupipe-ts/README.md](packages/codeupipe-ts/README.md) | ## Install @@ -150,7 +152,7 @@ An editable install needs pip 21.3 or later. | Command | What it does | |---|---| | `python -m pytest tests -q` | Runs the tests | -| `python conformance/run.py` | Runs the 27 conformance cases against the dynamic run and the three exports | +| `python conformance/run.py` | Runs the 27 conformance cases against the Python dynamic run and the three exports, and against the TypeScript run when the machine has Node 22.18 or later | | `python -m codeupipe_lint.anonymous_steps` | Runs the anonymous-step linter. Paths come from `cup.config.json` | | `python -m codeupipe_lint.invariants` | Runs the invariant linter. Paths come from `cup.config.json` | | `python -m codeupipe.lower examples.pricing.pricing_pipeline:PricingPipeline --out build/pricing` | Exports the example to `build/pricing/src/steps/` | @@ -163,8 +165,9 @@ An editable install needs pip 21.3 or later. |---|---| | `packages/codeupipe/` | The `codeupipe` package: `core/`, `lower/` (the export), `config.py` | | `packages/codeupipe-lint/` | The `codeupipe-lint` package: `anonymous_steps/`, `invariants/` | -| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runner, the benchmark | -| `tests/` | The tests for the two packages | +| `packages/codeupipe-ts/` | The TypeScript runtime, with its tests and its example | +| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runners, the benchmark | +| `tests/` | The tests for the two Python packages | | `examples/pricing/` | The example, with its test in `tests/examples/` | | `scorecard/` | The comparison of the export with the dynamic run on random pipelines | | `docs/` | Reference for the export and the config file | @@ -173,9 +176,11 @@ The root `pyproject.toml` is not a package. It holds the test settings. ## Other languages -This repository is Python only. The contract and the conformance cases do -not depend on Python. A runtime in another language must implement the -contract and add a conformance runner. +The contract and the conformance cases do not depend on a language. Python +is the first runtime and TypeScript is the second +([packages/codeupipe-ts/](packages/codeupipe-ts/)). The export and the +linters are for Python only. A runtime in another language must implement +the contract and add a conformance runner. ## License diff --git a/conformance/API.md b/conformance/API.md index d909af5..876fec8 100644 --- a/conformance/API.md +++ b/conformance/API.md @@ -14,9 +14,10 @@ parameters, their defaults, and how they fail. | State | records what happened | | Pipeline | runs the steps in order | -Python implements it. A runtime in another language implements the same -contract, with identifier casing in that language's style, and is judged by -the same conformance cases. +Python and TypeScript implement it. Each runtime has the same contract, +with identifier casing in the style of its language, and the same +conformance cases judge it. The tables below show the Python names. The +TypeScript names and differences are in "Runtimes". ## Principles @@ -78,7 +79,7 @@ Rules: `test(payload) -> bool`. -- A Predicate is an object. It is identified by its class like a step, and +- A Predicate is an object. Its class gives it its name, and any setting (a threshold, a flag) is on the instance: `AtLeastPredicate("qty", 10)`. Functions, lambdas and closures are not Predicates. @@ -202,3 +203,29 @@ map and fails the comparison. it changes timing for callers who wait on a Tap's side effect. - **Other runtimes.** A runtime in another language is written to this contract and added with its conformance runner. + +## Runtimes + +| | Python | TypeScript | +|---|---|---| +| Package | `packages/codeupipe` | `packages/codeupipe-ts` | +| Names | snake_case | camelCase: `addFilter`, `addValve`, `toDict`, `withTrace`, `traceId`, `onError` | +| Run | `run` is sync. `run_async` is opt-in | One `run`. It returns a Promise. A step can be sync or async | +| Absent | `None` | `null` | +| Label of a step | `type(step).__name__` | `step.constructor.name` | +| Export | `codeupipe.lower` | none | +| Linter | `codeupipe-lint` | none | + +Notes on TypeScript: + +- **Principle 3.** JavaScript always has an event loop, so there is no + second entry point. Inside `run`, a step that returns a value and not a + Promise is not awaited. +- **A function is not a step.** Every JavaScript function has a `call` + method, so a step must be an object. +- **Labels in minified code.** A minifier changes class names. A label is + then the changed name, unless the build keeps class names. A class with no + name has the label `anonymous`. +- **State and concurrent runs.** Two runs of one pipeline at the same time + each write to their own State. `state` is the State of the run that + started last. diff --git a/conformance/MATRIX.md b/conformance/MATRIX.md index 7be1505..7771eba 100644 --- a/conformance/MATRIX.md +++ b/conformance/MATRIX.md @@ -5,47 +5,49 @@ live copy is `results/latest.md` (not committed). The command exits non-zero if any target has a ❌. **Every target passes every case, and nothing is unsupported.** The targets -are the dynamic run, the async run, and the three static exports. +are the Python dynamic run, async run and three static exports, and the +TypeScript run. The two fast exports (`static-steps`, `static-inline`) expose no State or Hooks, so they pass those cases as ✅\*. `static-observe` (export with `observe=True`) records both and passes every case outright. See [docs/static-export.md](../docs/static-export.md). -| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe | -|---|---|---|---|---|---| -| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ | +| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe | typescript:run | +|---|---|---|---|---|---|---| +| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | - **python:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported - **python:run_async**: 27 pass, 0 pass*, 0 fail, 0 unsupported - **python:static-steps**: 11 pass, 16 pass*, 0 fail, 0 unsupported - **python:static-inline**: 11 pass, 16 pass*, 0 fail, 0 unsupported - **python:static-observe**: 27 pass, 0 pass*, 0 fail, 0 unsupported +- **typescript:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported ✅* passed everything the target can observe; it does not expose State or Hooks (the fast static export). ➖ the target cannot express the case. diff --git a/conformance/README.md b/conformance/README.md index 26cb6bd..43d2a75 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -42,6 +42,7 @@ load a number can leave the band. | `python:run`, `python:run_async` | `codeupipe.core` Pipeline, sync and async entry points | | `python:static-steps`, `python:static-inline` | `codeupipe.lower` fast export, loaded from disk (no State or Hooks: ✅\*) | | `python:static-observe` | `codeupipe.lower` export with `observe=True`: State and Hooks judged too | +| `typescript:run` | `packages/codeupipe-ts` Pipeline. Node runs the `.ts` runner as it is (Node 22.18 or later). With no arguments, `run.py` leaves this target out when the machine has no such Node | ## Statuses @@ -115,4 +116,8 @@ never halts the pipeline). Where it is silent, they encode Python 2. **Hooks around a skipped Valve.** Python fires `before:v` / `after:v` even when the Valve skips its filter. 3. **`equals` across types.** Python's `==` makes `1 == true`; JSON does not. + TypeScript uses `===`, which does not compare lists or maps by content. No case depends on it yet. +4. **`truthy` across languages.** An empty list or map is false in Python + and true in JavaScript. Each runner uses the rule of its language. No + case depends on it yet. diff --git a/conformance/run.py b/conformance/run.py index 1a11a15..3497db9 100644 --- a/conformance/run.py +++ b/conformance/run.py @@ -2,6 +2,10 @@ python conformance/run.py # all targets python conformance/run.py python:run python:static-inline + python conformance/run.py typescript:run + +With no arguments, the TypeScript target runs only when the machine has +Node 22.18 or later. A target that is named and cannot run is a failure. A runner executes cases and reports what happened. Only this file decides pass or fail, so every target is held to exactly the same comparison. @@ -18,6 +22,7 @@ import json import os import pathlib +import shutil import subprocess import sys @@ -37,15 +42,43 @@ def _python(target): } +def _typescript(target): + # Node runs the .ts file as it is. Nothing is built or downloaded. + return { + "cmd": ["node", str(HERE / "runners/typescript/runner.ts"), target], + "env": {}, + "cwd": REPO, + } + + +def _node_runs_typescript(): + """Whether there is a Node that runs a .ts file as it is (22.18 or later).""" + node = shutil.which("node") + if node is None: + return False + try: + version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout + major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2]) + except (OSError, ValueError, subprocess.SubprocessError): + return False + return (major, minor) >= (22, 18) + + TARGETS = { "python:run": _python("run"), "python:run_async": _python("run_async"), "python:static-steps": _python("static-steps"), "python:static-inline": _python("static-inline"), "python:static-observe": _python("static-observe"), + "typescript:run": _typescript("run"), } +def default_targets(): + """Every target that this machine can run. TypeScript needs Node.""" + return [name for name in TARGETS if not name.startswith("typescript:") or _node_runs_typescript()] + + def load_cases(): cases = [] for path in sorted(CASES.glob("*.json")): @@ -130,7 +163,7 @@ def judge(case, result): def main(argv): cases = load_cases() - names = argv or list(TARGETS) + names = argv or default_targets() report = {"cases": len(cases), "targets": {}} matrix = {c["id"]: {} for c in cases} @@ -156,7 +189,7 @@ def main(argv): (RESULTS / "latest.json").write_text(json.dumps(report, indent=2)) (RESULTS / "latest.md").write_text(_markdown(cases, names, matrix, report)) print(_markdown(cases, names, matrix, report)) - failed = any(t.get("FAIL") for t in report["targets"].values()) + failed = any(t.get("FAIL") or "error" in t for t in report["targets"].values()) return 1 if failed else 0 diff --git a/conformance/runners/typescript/cup-ops/add-filter.ts b/conformance/runners/typescript/cup-ops/add-filter.ts new file mode 100644 index 0000000..7e9ec65 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/add-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = get(key, 0) + by */ +export class AddFilter implements Filter { + readonly key: string; + readonly by: number; + + constructor(key: string, by: number) { + this.key = key; + this.by = by; + } + + call(payload: Payload): Payload { + return payload.insert(this.key, payload.get(this.key, 0) + this.by); + } +} diff --git a/conformance/runners/typescript/cup-ops/append-filter.ts b/conformance/runners/typescript/cup-ops/append-filter.ts new file mode 100644 index 0000000..0bdd068 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/append-filter.ts @@ -0,0 +1,17 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = a copy of get(key), or of an empty list, with value added */ +export class AppendFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + const list = payload.get(this.key) ?? []; + return payload.insert(this.key, [...list, this.value]); + } +} diff --git a/conformance/runners/typescript/cup-ops/copy-filter.ts b/conformance/runners/typescript/cup-ops/copy-filter.ts new file mode 100644 index 0000000..b0426db --- /dev/null +++ b/conformance/runners/typescript/cup-ops/copy-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert to = get(from), null if missing */ +export class CopyFilter implements Filter { + readonly from: string; + readonly to: string; + + constructor(from: string, to: string) { + this.from = from; + this.to = to; + } + + call(payload: Payload): Payload { + return payload.insert(this.to, payload.get(this.from)); + } +} diff --git a/conformance/runners/typescript/cup-ops/equals-predicate.ts b/conformance/runners/typescript/cup-ops/equals-predicate.ts new file mode 100644 index 0000000..81e018b --- /dev/null +++ b/conformance/runners/typescript/cup-ops/equals-predicate.ts @@ -0,0 +1,16 @@ +import type { Payload, Predicate } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** get(key) == value */ +export class EqualsPredicate implements Predicate { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + test(payload: Payload): boolean { + return payload.get(this.key) === this.value; + } +} diff --git a/conformance/runners/typescript/cup-ops/fail-filter.ts b/conformance/runners/typescript/cup-ops/fail-filter.ts new file mode 100644 index 0000000..29f5cf4 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/fail-filter.ts @@ -0,0 +1,14 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** throw an error that carries message */ +export class FailFilter implements Filter { + readonly message: string; + + constructor(message: string) { + this.message = message; + } + + call(_payload: Payload): Payload { + throw new Error(this.message); + } +} diff --git a/conformance/runners/typescript/cup-ops/fail-tap.ts b/conformance/runners/typescript/cup-ops/fail-tap.ts new file mode 100644 index 0000000..f9f40dc --- /dev/null +++ b/conformance/runners/typescript/cup-ops/fail-tap.ts @@ -0,0 +1,14 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** throw */ +export class FailTap implements Tap { + readonly message: string; + + constructor(message: string) { + this.message = message; + } + + observe(_payload: Payload): void { + throw new Error(this.message); + } +} diff --git a/conformance/runners/typescript/cup-ops/has-filter.ts b/conformance/runners/typescript/cup-ops/has-filter.ts new file mode 100644 index 0000000..6527f5f --- /dev/null +++ b/conformance/runners/typescript/cup-ops/has-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert to = has(key) */ +export class HasFilter implements Filter { + readonly key: string; + readonly to: string; + + constructor(key: string, to: string) { + this.key = key; + this.to = to; + } + + call(payload: Payload): Payload { + return payload.insert(this.to, payload.has(this.key)); + } +} diff --git a/conformance/runners/typescript/cup-ops/merge-filter.ts b/conformance/runners/typescript/cup-ops/merge-filter.ts new file mode 100644 index 0000000..8841cc6 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/merge-filter.ts @@ -0,0 +1,15 @@ +import { Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; +import type { Filter } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** merge(Payload(value)) */ +export class MergeFilter implements Filter { + readonly value: Record; + + constructor(value: Record) { + this.value = value; + } + + call(payload: Payload): Payload { + return payload.merge(new Payload(this.value)); + } +} diff --git a/conformance/runners/typescript/cup-ops/mutate-filter.ts b/conformance/runners/typescript/cup-ops/mutate-filter.ts new file mode 100644 index 0000000..9acc3a2 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/mutate-filter.ts @@ -0,0 +1,17 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert(key, value) as a statement of its own, then return the payload */ +export class MutateFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + payload.insert(this.key, this.value); + return payload; + } +} diff --git a/conformance/runners/typescript/cup-ops/nested-pipeline.ts b/conformance/runners/typescript/cup-ops/nested-pipeline.ts new file mode 100644 index 0000000..6db705d --- /dev/null +++ b/conformance/runners/typescript/cup-ops/nested-pipeline.ts @@ -0,0 +1,4 @@ +import { Pipeline } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** A named pipeline type, for a pipeline that is a step of another. */ +export class NestedPipeline extends Pipeline {} diff --git a/conformance/runners/typescript/cup-ops/noop-tap.ts b/conformance/runners/typescript/cup-ops/noop-tap.ts new file mode 100644 index 0000000..b9fbb55 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/noop-tap.ts @@ -0,0 +1,6 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** do nothing */ +export class NoopTap implements Tap { + observe(_payload: Payload): void {} +} diff --git a/conformance/runners/typescript/cup-ops/record-hook.ts b/conformance/runners/typescript/cup-ops/record-hook.ts new file mode 100644 index 0000000..057fced --- /dev/null +++ b/conformance/runners/typescript/cup-ops/record-hook.ts @@ -0,0 +1,26 @@ +import { Hook } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** + * Records before:, after: and error:. Events of the + * pipeline itself have an empty step. + */ +export class RecordHook extends Hook { + readonly events: string[]; + + constructor(events: string[]) { + super(); + this.events = events; + } + + override before(step: string | null): void { + this.events.push("before:" + (step ?? "")); + } + + override after(step: string | null): void { + this.events.push("after:" + (step ?? "")); + } + + override onError(step: string | null): void { + this.events.push("error:" + (step ?? "")); + } +} diff --git a/conformance/runners/typescript/cup-ops/record-tap.ts b/conformance/runners/typescript/cup-ops/record-tap.ts new file mode 100644 index 0000000..78dc53f --- /dev/null +++ b/conformance/runners/typescript/cup-ops/record-tap.ts @@ -0,0 +1,16 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** append get(key) to the runner's tap log */ +export class RecordTap implements Tap { + readonly key: string; + readonly log: unknown[]; + + constructor(key: string, log: unknown[]) { + this.key = key; + this.log = log; + } + + observe(payload: Payload): void { + this.log.push(payload.get(this.key)); + } +} diff --git a/conformance/runners/typescript/cup-ops/remove-filter.ts b/conformance/runners/typescript/cup-ops/remove-filter.ts new file mode 100644 index 0000000..462c657 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/remove-filter.ts @@ -0,0 +1,14 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** remove(key) */ +export class RemoveFilter implements Filter { + readonly key: string; + + constructor(key: string) { + this.key = key; + } + + call(payload: Payload): Payload { + return payload.remove(this.key); + } +} diff --git a/conformance/runners/typescript/cup-ops/set-filter.ts b/conformance/runners/typescript/cup-ops/set-filter.ts new file mode 100644 index 0000000..75fe72a --- /dev/null +++ b/conformance/runners/typescript/cup-ops/set-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = value */ +export class SetFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + return payload.insert(this.key, this.value); + } +} diff --git a/conformance/runners/typescript/cup-ops/truthy-predicate.ts b/conformance/runners/typescript/cup-ops/truthy-predicate.ts new file mode 100644 index 0000000..066cb51 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/truthy-predicate.ts @@ -0,0 +1,14 @@ +import type { Payload, Predicate } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** get(key) is truthy; a missing key is false */ +export class TruthyPredicate implements Predicate { + readonly key: string; + + constructor(key: string) { + this.key = key; + } + + test(payload: Payload): boolean { + return Boolean(payload.get(this.key)); + } +} diff --git a/conformance/runners/typescript/package.json b/conformance/runners/typescript/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/conformance/runners/typescript/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/conformance/runners/typescript/runner.ts b/conformance/runners/typescript/runner.ts new file mode 100644 index 0000000..ed36af6 --- /dev/null +++ b/conformance/runners/typescript/runner.ts @@ -0,0 +1,144 @@ +/** + * TypeScript conformance runner: runs the cases and reports what happened. + * + * node runner.ts run < cases.json > results.json + * + * The runner never judges; conformance/run.py compares the results of every + * target with the same expectations. Node runs this file as it is (Node + * 22.18 or later removes the types), so nothing is built or downloaded. + */ +import { readFileSync } from "node:fs"; + +import { Payload, Pipeline } from "../../../packages/codeupipe-ts/src/index.ts"; +import type { Filter, Predicate, Tap } from "../../../packages/codeupipe-ts/src/index.ts"; +import { AddFilter } from "./cup-ops/add-filter.ts"; +import { AppendFilter } from "./cup-ops/append-filter.ts"; +import { CopyFilter } from "./cup-ops/copy-filter.ts"; +import { EqualsPredicate } from "./cup-ops/equals-predicate.ts"; +import { FailFilter } from "./cup-ops/fail-filter.ts"; +import { FailTap } from "./cup-ops/fail-tap.ts"; +import { HasFilter } from "./cup-ops/has-filter.ts"; +import { MergeFilter } from "./cup-ops/merge-filter.ts"; +import { MutateFilter } from "./cup-ops/mutate-filter.ts"; +import { NestedPipeline } from "./cup-ops/nested-pipeline.ts"; +import { NoopTap } from "./cup-ops/noop-tap.ts"; +import { RecordHook } from "./cup-ops/record-hook.ts"; +import { RecordTap } from "./cup-ops/record-tap.ts"; +import { RemoveFilter } from "./cup-ops/remove-filter.ts"; +import { SetFilter } from "./cup-ops/set-filter.ts"; +import { TruthyPredicate } from "./cup-ops/truthy-predicate.ts"; + +type Spec = Record; +type Case = { id: string; hooks?: boolean; steps: Spec[]; input: Record }; + +class Unsupported extends Error {} + +function makeFilter(spec: Spec): Filter { + switch (spec.op) { + case "set": return new SetFilter(spec.key, spec.value); + case "copy": return new CopyFilter(spec.from, spec.to); + case "add": return new AddFilter(spec.key, spec.by); + case "append": return new AppendFilter(spec.key, spec.value); + case "remove": return new RemoveFilter(spec.key); + case "merge": return new MergeFilter(spec.value); + case "has": return new HasFilter(spec.key, spec.to); + case "mutate": return new MutateFilter(spec.key, spec.value); + case "fail": return new FailFilter(spec.message); + default: throw new Unsupported(`unknown filter op '${spec.op}'`); + } +} + +function makeTap(spec: Spec, taps: unknown[]): Tap { + switch (spec.op) { + case "record": return new RecordTap(spec.key, taps); + case "fail": return new FailTap(spec.message); + case "noop": return new NoopTap(); + default: throw new Unsupported(`unknown tap op '${spec.op}'`); + } +} + +function makePredicate(when: Spec): Predicate { + switch (when.op) { + case "equals": return new EqualsPredicate(when.key, when.value); + case "truthy": return new TruthyPredicate(when.key); + default: throw new Unsupported(`unknown valve op '${when.op}'`); + } +} + +function build(steps: Spec[], pipeline: Pipeline, taps: unknown[]): Pipeline { + for (const step of steps) { + if (step.kind === "filter") { + pipeline.addFilter(makeFilter(step)); + } else if (step.kind === "tap") { + pipeline.addTap(makeTap(step, taps)); + } else if (step.kind === "valve") { + pipeline.addValve(makePredicate(step.when), makeFilter(step.filter)); + } else if (step.kind === "pipeline") { + pipeline.addPipeline(build(step.steps, new NestedPipeline(), taps)); + } else { + throw new Unsupported(`unknown step kind '${step.kind}'`); + } + } + return pipeline; +} + +/** + * Label to the step name of the case. The cases name their steps, and the + * runtime reports labels (conformance/API.md). The steps are added in the + * order of the case, so the two lists are side by side. A label that the + * runtime reports and that is not in this map stays as it is, and fails the + * comparison. + */ +function namesByLabel(pipeline: Pipeline, steps: Spec[]): Map { + return new Map(pipeline.labels.map((label, index) => [label, String(steps[index]?.name)])); +} + +async function runCase(spec: Case): Promise> { + const taps: unknown[] = []; + const events: string[] = []; + const pipeline = build(spec.steps, new Pipeline(), taps); + const names = namesByLabel(pipeline, spec.steps); + const named = (label: string) => names.get(label) ?? label; + if (spec.hooks) pipeline.useHook(new RecordHook(events)); + + const data = structuredClone(spec.input); + const result: Record = { id: spec.id }; + try { + result.output = (await pipeline.run(new Payload(data))).toDict(); + } catch (error) { + result.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + } + const state = pipeline.state; + result.state = { + executed: state.executed.map(named), + skipped: state.skipped.map(named), + errors: state.errors.map(([label]) => named(label)), + }; + result.taps = taps; + if (spec.hooks) { + result.hooks = events.map((event) => { + const at = event.indexOf(":"); + return event.slice(0, at + 1) + named(event.slice(at + 1)); + }); + } + result.input_after = data; + return result; +} + +const target = process.argv[2]; +if (target !== "run") { + console.error(`unknown target '${target}'. The target is: run`); + process.exit(2); +} + +const cases: Case[] = JSON.parse(readFileSync(0, "utf8")); +const results: Record[] = []; +for (const spec of cases) { + try { + results.push(await runCase(spec)); + } catch (error) { + if (!(error instanceof Unsupported)) throw error; + results.push({ id: spec.id, unsupported: error.message }); + } +} +process.stdout.write(JSON.stringify(results)); diff --git a/packages/codeupipe-ts/.gitignore b/packages/codeupipe-ts/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/codeupipe-ts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/codeupipe-ts/LICENSE b/packages/codeupipe-ts/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/packages/codeupipe-ts/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/packages/codeupipe-ts/NOTICE b/packages/codeupipe-ts/NOTICE new file mode 100644 index 0000000..fd0d109 --- /dev/null +++ b/packages/codeupipe-ts/NOTICE @@ -0,0 +1,4 @@ +codeupipe +Copyright 2026 Orchestrate LLC + +Licensed under the Apache License, Version 2.0. See LICENSE. diff --git a/packages/codeupipe-ts/README.md b/packages/codeupipe-ts/README.md new file mode 100644 index 0000000..b1aa3d0 --- /dev/null +++ b/packages/codeupipe-ts/README.md @@ -0,0 +1,154 @@ +# codeupipe for TypeScript + +codeupipe is a library for writing a program as a pipeline of small steps. +This is the TypeScript runtime. It has no dependencies. The built JavaScript +runs in a browser and in Node. + +It is not on npm yet. It implements the same contract as the Python +package, and the same 27 conformance cases judge it: +[conformance/API.md](../../conformance/API.md). + +## The eight primitives + +| Primitive | What it does | Who writes it | +|---|---|---| +| **Payload** | Carries the data | The library | +| **Filter** | Changes the data | You | +| **Predicate** | Asks a yes-or-no question about the data | You | +| **Valve** | Joins a Predicate to a Filter: the Filter runs when the answer is yes | The library. You add one with `addValve(predicate, filter)` | +| **Tap** | Reads the data as it goes by | You | +| **Hook** | Is called before and after each step | You | +| **State** | Records which steps ran | The library | +| **Pipeline** | Runs the steps in order | You | + +## Example + +[examples/pricing/](examples/pricing/) has one class in each file. The +pipeline file imports them and adds them. + +```ts +// discount-filter.ts +import type { Filter, Payload } from "codeupipe"; + +export class DiscountFilter implements Filter { + readonly rate: number; + + constructor(rate: number) { + this.rate = rate; + } + + call(payload: Payload): Payload { + const price = payload.get("price", 0) * (1 - this.rate); + return payload.insert("price", Math.round(price * 100) / 100); + } +} +``` + +```ts +// at-least-predicate.ts +import type { Payload, Predicate } from "codeupipe"; + +export class AtLeastPredicate implements Predicate { + readonly key: string; + readonly minimum: number; + + constructor(key: string, minimum: number) { + this.key = key; + this.minimum = minimum; + } + + test(payload: Payload): boolean { + return payload.get(this.key, 0) >= this.minimum; + } +} +``` + +```ts +// pricing-pipeline.ts +import { Pipeline } from "codeupipe"; + +import { AtLeastPredicate } from "./at-least-predicate.ts"; +import { AuditTap } from "./audit-tap.ts"; +import { DiscountFilter } from "./discount-filter.ts"; +import { MarkBulkFilter } from "./mark-bulk-filter.ts"; + +export class PricingPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new DiscountFilter(0.1)); + // MarkBulkFilter runs only when the predicate is true. + this.addValve(new AtLeastPredicate("qty", 10), new MarkBulkFilter()); + // A Tap failure is recorded and does not stop the run. + this.addTap(new AuditTap()); + } +} +``` + +```ts +import { Payload } from "codeupipe"; + +const result = await new PricingPipeline().run(new Payload({ price: 100.0, qty: 12 })); +result.toDict(); +// { price: 90, qty: 12, bulk: true } +``` + +In this repository the example imports from `../../src/index.ts`, because +the package is not installed. + +## Names + +The names are the names of the contract, in camelCase. + +| Contract | TypeScript | +|---|---| +| `add_filter`, `add_valve`, `add_tap`, `add_pipeline`, `use_hook` | `addFilter`, `addValve`, `addTap`, `addPipeline`, `useHook` | +| `run`, `state`, `labels`, `observe` | `run`, `state`, `labels`, `observe({ timing, lineage })` | +| `get`, `has`, `insert`, `remove`, `merge`, `copy` | the same | +| `to_dict`, `with_trace`, `trace_id`, `lineage` | `toDict`, `withTrace`, `traceId`, `lineage` | +| Hook: `before`, `after`, `on_error` | `before`, `after`, `onError` | + +`Filter`, `Predicate` and `Tap` are interfaces: a class with the method is +one. `Hook` is a class with three methods that do nothing: extend it and +override the methods that you need. + +## Differences from Python + +- **There is one `run`, and it returns a Promise.** A Filter, a Tap or a + Hook method can be sync or async. Python has `run` (sync) and `run_async`. +- **Absent is `null`.** `get` of a key that is not there gives `null`, and a + Hook gets `null` as the step for the pipeline itself. +- **A function is not a step.** Every JavaScript function has a `call` + method, so `addFilter` refuses a function by its type. +- **Two runs of one pipeline at the same time** each write to their own + State. `pipeline.state` is the State of the run that started last. +- **This package has no export command and no linter.** Python has both. + +## Labels and minified code + +State and Hooks report a step by its label, which is the name of its class. +A minifier changes class names, so a label in minified code can be a name +such as `a`. To keep the names, turn on the option of your tool that keeps +class names (for example `keepNames` in esbuild, `keep_classnames` in +terser). A class with no name has the label `anonymous`. + +## Development + +Node 22.18 or later runs the `.ts` files as they are. + +```bash +npm install # the TypeScript compiler and the Node types, for development only +npm test # the tests +npm run typecheck # the type check of the source, tests, example and conformance runner +npm run build # JavaScript and type files in dist/ +node examples/pricing/main.ts +python ../../conformance/run.py typescript:run +``` + +The source uses only TypeScript syntax that can be removed without a +change to the code that runs: no `enum`, no parameter properties, no +`namespace`. One primitive is in each file, and the file name is the class +name in kebab-case. + +## License + +Apache License 2.0. diff --git a/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts b/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts new file mode 100644 index 0000000..3baa95d --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts @@ -0,0 +1,20 @@ +import type { Payload, Predicate } from "../../src/index.ts"; + +/** + * A Predicate: the question that a Valve asks. Its settings are on the + * instance, so the same class answers a different question in another + * pipeline. + */ +export class AtLeastPredicate implements Predicate { + readonly key: string; + readonly minimum: number; + + constructor(key: string, minimum: number) { + this.key = key; + this.minimum = minimum; + } + + test(payload: Payload): boolean { + return payload.get(this.key, 0) >= this.minimum; + } +} diff --git a/packages/codeupipe-ts/examples/pricing/audit-tap.ts b/packages/codeupipe-ts/examples/pricing/audit-tap.ts new file mode 100644 index 0000000..5ba2343 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/audit-tap.ts @@ -0,0 +1,11 @@ +import type { Payload, Tap } from "../../src/index.ts"; + +/** + * A Tap: reads the Payload and returns nothing. This one fails every time, + * to show that a Tap failure is recorded and never stops the run. + */ +export class AuditTap implements Tap { + observe(_payload: Payload): void { + throw new Error("audit sink is down"); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/discount-filter.ts b/packages/codeupipe-ts/examples/pricing/discount-filter.ts new file mode 100644 index 0000000..19f657e --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/discount-filter.ts @@ -0,0 +1,15 @@ +import type { Filter, Payload } from "../../src/index.ts"; + +/** A Filter: takes the Payload, writes to it, returns it. */ +export class DiscountFilter implements Filter { + readonly rate: number; + + constructor(rate: number) { + this.rate = rate; + } + + call(payload: Payload): Payload { + const price = payload.get("price", 0) * (1 - this.rate); + return payload.insert("price", Math.round(price * 100) / 100); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/main.ts b/packages/codeupipe-ts/examples/pricing/main.ts new file mode 100644 index 0000000..9f59a54 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/main.ts @@ -0,0 +1,15 @@ +// node examples/pricing/main.ts: run the pipeline with a Hook and show its State. +import { Payload } from "../../src/index.ts"; +import { PricingPipeline } from "./pricing-pipeline.ts"; +import { PrintStepsHook } from "./print-steps-hook.ts"; + +const pipeline = new PricingPipeline(); +pipeline.useHook(new PrintStepsHook()); + +console.log("steps:"); +const result = await pipeline.run(new Payload({ price: 100.0, qty: 12 })); + +console.log("result: ", result.toDict()); +console.log("executed:", pipeline.state.executed); +console.log("skipped: ", pipeline.state.skipped); +console.log("errors: ", pipeline.state.errors.map(([label, error]) => [label, String(error)])); diff --git a/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts b/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts new file mode 100644 index 0000000..8777283 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts @@ -0,0 +1,8 @@ +import type { Filter, Payload } from "../../src/index.ts"; + +/** A Filter. */ +export class MarkBulkFilter implements Filter { + call(payload: Payload): Payload { + return payload.insert("bulk", true); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts b/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts new file mode 100644 index 0000000..6b8a617 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts @@ -0,0 +1,17 @@ +import { Pipeline } from "../../src/index.ts"; +import { AtLeastPredicate } from "./at-least-predicate.ts"; +import { AuditTap } from "./audit-tap.ts"; +import { DiscountFilter } from "./discount-filter.ts"; +import { MarkBulkFilter } from "./mark-bulk-filter.ts"; + +/** The pipeline file: the place where the steps come together. */ +export class PricingPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new DiscountFilter(0.1)); + // MarkBulkFilter runs only when the predicate is true. + this.addValve(new AtLeastPredicate("qty", 10), new MarkBulkFilter()); + // A Tap failure is recorded and does not stop the run. + this.addTap(new AuditTap()); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts b/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts new file mode 100644 index 0000000..bc5a933 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts @@ -0,0 +1,13 @@ +import { Hook } from "../../src/index.ts"; + +/** + * A Hook: sees the lifecycle. `step` is the label of the step, which is the + * name of its class. It is null for the pipeline itself. + */ +export class PrintStepsHook extends Hook { + override before(step: string | null): void { + if (step !== null) { + console.log(" " + step); + } + } +} diff --git a/packages/codeupipe-ts/package-lock.json b/packages/codeupipe-ts/package-lock.json new file mode 100644 index 0000000..3ab9a4f --- /dev/null +++ b/packages/codeupipe-ts/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "codeupipe", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeupipe", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/codeupipe-ts/package.json b/packages/codeupipe-ts/package.json new file mode 100644 index 0000000..e02595b --- /dev/null +++ b/packages/codeupipe-ts/package.json @@ -0,0 +1,35 @@ +{ + "name": "codeupipe", + "version": "0.1.0", + "private": true, + "description": "A TypeScript library for writing a program as a pipeline of small steps.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/orchestrate-solutions/codeupipe.git", + "directory": "packages/codeupipe-ts" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist", "LICENSE", "NOTICE", "README.md"], + "sideEffects": false, + "engines": { + "node": ">=22.18" + }, + "scripts": { + "test": "node --test \"tests/*.test.ts\"", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/codeupipe-ts/src/filter.ts b/packages/codeupipe-ts/src/filter.ts new file mode 100644 index 0000000..9b130b2 --- /dev/null +++ b/packages/codeupipe-ts/src/filter.ts @@ -0,0 +1,6 @@ +import type { Payload } from "./payload.ts"; + +/** Filter: changes the data. A failure throws. `call` can be async. */ +export interface Filter { + call(payload: Payload): Payload | Promise; +} diff --git a/packages/codeupipe-ts/src/hook.ts b/packages/codeupipe-ts/src/hook.ts new file mode 100644 index 0000000..6303679 --- /dev/null +++ b/packages/codeupipe-ts/src/hook.ts @@ -0,0 +1,18 @@ +import type { Payload } from "./payload.ts"; + +/** + * Hook: sees the lifecycle of a run. Extend it and override what you need. + * + * `step` is the label of the step, or null for the pipeline itself. A Hook + * reads the Payload and does not write to it. Its methods can be async. + */ +export class Hook { + /** Before a step, or before the pipeline starts (`step` is null). */ + before(step: string | null, payload: Payload): void | Promise {} + + /** After a step, or after the pipeline ends (`step` is null). */ + after(step: string | null, payload: Payload): void | Promise {} + + /** When a Filter fails: for the step, then for the pipeline (`step` is null). */ + onError(step: string | null, error: unknown, payload: Payload): void | Promise {} +} diff --git a/packages/codeupipe-ts/src/index.ts b/packages/codeupipe-ts/src/index.ts new file mode 100644 index 0000000..d1a5888 --- /dev/null +++ b/packages/codeupipe-ts/src/index.ts @@ -0,0 +1,14 @@ +/** + * codeupipe: the eight primitives. + * + * Payload, Filter, Predicate, Valve, Tap, Hook, State and Pipeline, as the + * contract (conformance/API.md) defines them. A Valve is added with + * `pipeline.addValve(predicate, filter)`; its class is not a public name. + */ +export type { Filter } from "./filter.ts"; +export { Hook } from "./hook.ts"; +export { Payload } from "./payload.ts"; +export { Pipeline } from "./pipeline.ts"; +export type { Predicate } from "./predicate.ts"; +export { State } from "./state.ts"; +export type { Tap } from "./tap.ts"; diff --git a/packages/codeupipe-ts/src/payload.ts b/packages/codeupipe-ts/src/payload.ts new file mode 100644 index 0000000..c77fbe1 --- /dev/null +++ b/packages/codeupipe-ts/src/payload.ts @@ -0,0 +1,87 @@ +/** + * Payload: the data that moves through a pipeline. + * + * One keyed container. Every write changes it in place and returns it, so a + * step can write and return in one line. `copy()` gives a separate Payload + * when you want to keep the one you have. + */ +export class Payload { + #data: Map; + #traceId: string | null = null; + #lineage: string[] = []; + + /** Keys are strings; values are JSON-shaped. The map that is given is copied. */ + constructor(data?: Record | null) { + this.#data = new Map(data ? Object.entries(data) : []); + } + + /** The value for `key`, or `fallback` when the key is absent. */ + get(key: string): T | null; + get(key: string, fallback: T): T; + get(key: string, fallback: unknown = null): unknown { + return this.#data.has(key) ? this.#data.get(key) : fallback; + } + + /** Whether `key` is present (also when its value is null). */ + has(key: string): boolean { + return this.#data.has(key); + } + + /** Set `key` to `value` in this Payload, and return it. */ + insert(key: string, value: unknown): this { + this.#data.set(key, value); + return this; + } + + /** Remove `key` from this Payload (nothing happens if it is absent), and return it. */ + remove(key: string): this { + this.#data.delete(key); + return this; + } + + /** Write every key of `other` into this Payload (`other` wins), and return it. */ + merge(other: Payload): this { + for (const [key, value] of other.#data) { + this.#data.set(key, value); + } + this.#traceId = this.#traceId ?? other.#traceId; + this.#lineage.push(...other.#lineage); + return this; + } + + /** A separate Payload with the same contents, trace id and lineage. Values are shared. */ + copy(): Payload { + const copy = new Payload(); + copy.#data = new Map(this.#data); + copy.#traceId = this.#traceId; + copy.#lineage = [...this.#lineage]; + return copy; + } + + /** The contents as a new map. */ + toDict(): Record { + return Object.fromEntries(this.#data); + } + + /** Set the trace id on this Payload, and return it. */ + withTrace(traceId: string): this { + this.#traceId = traceId; + return this; + } + + /** The trace id, for following one Payload across systems. */ + get traceId(): string | null { + return this.#traceId; + } + + /** The steps this Payload has passed through, when lineage is on. */ + get lineage(): string[] { + return [...this.#lineage]; + } + + /** Record a step in the lineage. For the Pipeline. */ + _stamp(label: string): this { + this.#lineage.push(label); + return this; + } +} diff --git a/packages/codeupipe-ts/src/pipeline.ts b/packages/codeupipe-ts/src/pipeline.ts new file mode 100644 index 0000000..1436487 --- /dev/null +++ b/packages/codeupipe-ts/src/pipeline.ts @@ -0,0 +1,211 @@ +import type { Filter } from "./filter.ts"; +import type { Hook } from "./hook.ts"; +import type { Payload } from "./payload.ts"; +import type { Predicate } from "./predicate.ts"; +import { State } from "./state.ts"; +import { labelOf } from "./step-label.ts"; +import type { Tap } from "./tap.ts"; +import { Valve } from "./valve.ts"; + +type Step = + | { kind: "filter"; label: string; filter: Filter } + | { kind: "valve"; label: string; valve: Valve } + | { kind: "tap"; label: string; tap: Tap } + | { kind: "pipeline"; label: string; pipeline: Pipeline }; + +// The clock of browsers and of Node. Declared here so that this file needs +// the types of neither. +declare const performance: { now(): number }; + +function isThenable(value: unknown): value is PromiseLike { + return typeof (value as { then?: unknown } | null | undefined)?.then === "function"; +} + +/** Whether `value` is an object with `method`. A function is not a step: every function has `call`. */ +function has(value: unknown, method: string): boolean { + return typeof value === "object" && value !== null + && typeof (value as Record)[method] === "function"; +} + +/** Refuse a step that does not have the method of its role. */ +function require(step: unknown, method: string, what: string): void { + if (!has(step, method)) { + const given = step === null || step === undefined ? String(step) + : typeof step === "function" ? "a function" : `a ${labelOf(step)}`; + throw new TypeError(`${what} has ${method}(payload). The value that was given (${given}) does not.`); + } +} + +/** + * Pipeline: runs the steps in order. + * + * Filters change the Payload, Valves gate a Filter, Taps read, Hooks see the + * lifecycle, and State records what ran. Add steps with `addFilter`, + * `addValve`, `addTap` and `addPipeline`, add Hooks with `useHook`, and run + * with `await pipeline.run(payload)`. + */ +export class Pipeline { + #steps: Step[] = []; + #uses: Map = new Map(); + #hooks: Hook[] = []; + #state: State = new State(); + #timing = false; + #lineage = false; + + /** What happened in the run that started last. */ + get state(): State { + return this.#state; + } + + /** The label of each step, in order. */ + get labels(): string[] { + return this.#steps.map((step) => step.label); + } + + /** The class name of the step, with a number from its second use in this pipeline. */ + #label(step: unknown): string { + const base = labelOf(step); + const count = (this.#uses.get(base) ?? 0) + 1; + this.#uses.set(base, count); + return count === 1 ? base : `${base}#${count}`; + } + + /** Add a Filter. */ + addFilter(filter: Filter): void { + require(filter, "call", "A Filter"); + this.#steps.push({ kind: "filter", label: this.#label(filter), filter }); + } + + /** + * Add a Valve: `filter` runs only when `predicate.test(payload)` is true. + * + * The condition comes first, then the Filter that it controls. When the + * test is false the Payload passes through unchanged and the step is + * recorded as skipped. The step has the label of its Filter. + */ + addValve(predicate: Predicate, filter: Filter): void { + if (!has(predicate, "test") && has(predicate, "call") && has(filter, "test")) { + throw new TypeError("The arguments are in the wrong order: addValve(predicate, filter). " + + "The condition comes first, then the Filter."); + } + require(predicate, "test", "A Predicate"); + require(filter, "call", "A Filter"); + this.#steps.push({ kind: "valve", label: this.#label(filter), valve: new Valve(predicate, filter) }); + } + + /** Add a Tap. */ + addTap(tap: Tap): void { + require(tap, "observe", "A Tap"); + this.#steps.push({ kind: "tap", label: this.#label(tap), tap }); + } + + /** Nest a Pipeline as one step of this Pipeline. */ + addPipeline(pipeline: Pipeline): void { + if (!(pipeline instanceof Pipeline)) { + throw new TypeError("addPipeline takes a Pipeline. The value that was given is not one."); + } + this.#steps.push({ kind: "pipeline", label: this.#label(pipeline), pipeline }); + } + + /** Add a Hook. */ + useHook(hook: Hook): void { + this.#hooks.push(hook); + } + + /** Turn on timing (seconds for each step, in State) and lineage (labels on the Payload). */ + observe(options: { timing?: boolean; lineage?: boolean } = {}): void { + this.#timing = options.timing ?? true; + this.#lineage = options.lineage ?? false; + } + + /** + * Run the steps in order on a copy of `initial`, and return the result. + * + * A step can be sync or async. A Filter failure stops the run and is + * thrown again after the Hooks have seen it. A Tap failure is recorded in + * State and the run continues. + */ + async run(initial: Payload): Promise { + // Each run writes to its own State, so two runs at the same time do not mix. + const state = new State(); + this.#state = state; + const hooks = this.#hooks; + const timed = this.#timing; + let payload = initial.copy(); // the caller's Payload is not changed + + for (const hook of hooks) { + const done = hook.before(null, payload); + if (isThenable(done)) await done; + } + + let failing: string | null = null; // the step that is running when an error escapes + let started: number | null = null; + try { + for (const step of this.#steps) { + const label = step.label; + failing = null; + started = null; + + if (step.kind === "tap") { + try { + const done = step.tap.observe(payload); + if (isThenable(done)) await done; + } catch (error) { + state.recordError(label, error); + } + state.markExecuted(label); + continue; + } + + if (timed) started = performance.now(); + failing = label; + for (const hook of hooks) { + const done = hook.before(label, payload); + if (isThenable(done)) await done; + } + + if (step.kind === "pipeline") { + payload = await step.pipeline.run(payload); + state.markExecuted(label); + } else if (step.kind === "valve" && !step.valve.predicate.test(payload)) { + state.markSkipped(label); + } else { + const filter = step.kind === "valve" ? step.valve.filter : step.filter; + const result = filter.call(payload); + payload = isThenable(result) ? await result : result; + state.markExecuted(label); + } + + for (const hook of hooks) { + const done = hook.after(label, payload); + if (isThenable(done)) await done; + } + if (timed && started !== null) state.recordTiming(label, (performance.now() - started) / 1000); + if (this.#lineage) payload._stamp(label); + } + } catch (error) { + if (timed && started !== null && failing !== null) { + state.recordTiming(failing, (performance.now() - started) / 1000); + } + if (failing !== null) { + // The failing step first, then the pipeline: onError fires at both + // levels, as before and after do. + for (const hook of hooks) { + const done = hook.onError(failing, error, payload); + if (isThenable(done)) await done; + } + } + for (const hook of hooks) { + const done = hook.onError(null, error, payload); + if (isThenable(done)) await done; + } + throw error; + } + + for (const hook of hooks) { + const done = hook.after(null, payload); + if (isThenable(done)) await done; + } + return payload; + } +} diff --git a/packages/codeupipe-ts/src/predicate.ts b/packages/codeupipe-ts/src/predicate.ts new file mode 100644 index 0000000..12336e8 --- /dev/null +++ b/packages/codeupipe-ts/src/predicate.ts @@ -0,0 +1,11 @@ +import type { Payload } from "./payload.ts"; + +/** + * Predicate: asks a yes-or-no question about the data. + * + * A Predicate is an object. Its settings (a threshold, a flag) are on the + * instance. It reads the Payload and does not write to it. + */ +export interface Predicate { + test(payload: Payload): boolean; +} diff --git a/packages/codeupipe-ts/src/state.ts b/packages/codeupipe-ts/src/state.ts new file mode 100644 index 0000000..b96dbee --- /dev/null +++ b/packages/codeupipe-ts/src/state.ts @@ -0,0 +1,32 @@ +/** + * State: what happened in the last run. + * + * Which steps executed, which a Valve skipped, the errors, and the timings + * when timing is on. Read it after a run through `pipeline.state`. + */ +export class State { + /** Step labels, in order. */ + readonly executed: string[] = []; + /** Steps that a Valve did not run. */ + readonly skipped: string[] = []; + /** Pairs of label and error. */ + readonly errors: Array<[string, unknown]> = []; + /** Label to seconds, when timing is on. */ + readonly timings: Map = new Map(); + + markExecuted(label: string): void { + this.executed.push(label); + } + + markSkipped(label: string): void { + this.skipped.push(label); + } + + recordTiming(label: string, seconds: number): void { + this.timings.set(label, seconds); + } + + recordError(label: string, error: unknown): void { + this.errors.push([label, error]); + } +} diff --git a/packages/codeupipe-ts/src/step-label.ts b/packages/codeupipe-ts/src/step-label.ts new file mode 100644 index 0000000..9b83e94 --- /dev/null +++ b/packages/codeupipe-ts/src/step-label.ts @@ -0,0 +1,9 @@ +/** + * The label of a step: the name of its class. State and Hooks report a step + * by this label. + */ +export function labelOf(step: unknown): string { + const type = (step as { constructor?: { name?: unknown } } | null | undefined)?.constructor; + const name = type?.name; + return typeof name === "string" && name !== "" ? name : "anonymous"; +} diff --git a/packages/codeupipe-ts/src/tap.ts b/packages/codeupipe-ts/src/tap.ts new file mode 100644 index 0000000..2ccbd56 --- /dev/null +++ b/packages/codeupipe-ts/src/tap.ts @@ -0,0 +1,9 @@ +import type { Payload } from "./payload.ts"; + +/** + * Tap: reads the data as it goes by. It does not write to the Payload. + * A Tap failure is recorded in State and never stops the run. + */ +export interface Tap { + observe(payload: Payload): void | Promise; +} diff --git a/packages/codeupipe-ts/src/valve.ts b/packages/codeupipe-ts/src/valve.ts new file mode 100644 index 0000000..27d3038 --- /dev/null +++ b/packages/codeupipe-ts/src/valve.ts @@ -0,0 +1,18 @@ +import type { Filter } from "./filter.ts"; +import type { Predicate } from "./predicate.ts"; + +/** + * Valve: joins a Predicate to a Filter. The Filter runs only when the + * Predicate is true. + * + * `Pipeline.addValve(predicate, filter)` makes one. It is not a public name. + */ +export class Valve { + readonly predicate: Predicate; + readonly filter: Filter; + + constructor(predicate: Predicate, filter: Filter) { + this.predicate = predicate; + this.filter = filter; + } +} diff --git a/packages/codeupipe-ts/tests/payload.test.ts b/packages/codeupipe-ts/tests/payload.test.ts new file mode 100644 index 0000000..8170547 --- /dev/null +++ b/packages/codeupipe-ts/tests/payload.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Payload } from "../src/index.ts"; + +test("the map that the caller gives is copied", () => { + const data = { a: 1 }; + const payload = new Payload(data); + payload.insert("b", 2); + assert.deepEqual(data, { a: 1 }); + assert.deepEqual(payload.toDict(), { a: 1, b: 2 }); +}); + +test("an empty Payload has no keys", () => { + assert.deepEqual(new Payload().toDict(), {}); +}); + +test("get gives the value, the fallback, or null", () => { + const payload = new Payload({ a: 1, n: null }); + assert.equal(payload.get("a"), 1); + assert.equal(payload.get("missing"), null); + assert.equal(payload.get("missing", 7), 7); + assert.equal(payload.get("n", 7), null); +}); + +test("has is true for a key with a null value", () => { + const payload = new Payload({ n: null }); + assert.equal(payload.has("n"), true); + assert.equal(payload.has("missing"), false); +}); + +test("insert, remove, merge and withTrace change the Payload and return it", () => { + const payload = new Payload({ a: 1 }); + assert.equal(payload.insert("b", 2), payload); + assert.equal(payload.remove("a"), payload); + assert.equal(payload.merge(new Payload({ c: 3 })), payload); + assert.equal(payload.withTrace("t-1"), payload); + assert.deepEqual(payload.toDict(), { b: 2, c: 3 }); + assert.equal(payload.traceId, "t-1"); +}); + +test("remove of a key that is not there does nothing", () => { + assert.deepEqual(new Payload({ a: 1 }).remove("zz").toDict(), { a: 1 }); +}); + +test("in a merge the other Payload wins", () => { + const payload = new Payload({ a: 1, b: 1 }).merge(new Payload({ b: 2 })); + assert.deepEqual(payload.toDict(), { a: 1, b: 2 }); +}); + +test("a merge keeps the trace id it has, and takes one when it has none", () => { + const other = new Payload({}).withTrace("other"); + assert.equal(new Payload({}).withTrace("mine").merge(other).traceId, "mine"); + assert.equal(new Payload({}).merge(other).traceId, "other"); +}); + +test("copy gives a separate Payload with the same contents", () => { + const payload = new Payload({ a: 1 }).withTrace("t-1"); + const copy = payload.copy(); + copy.insert("a", 2); + assert.equal(payload.get("a"), 1); + assert.equal(copy.traceId, "t-1"); +}); + +test("copy is shallow: the two Payloads share their values", () => { + const list = [1]; + const payload = new Payload({ list }); + assert.equal(payload.copy().get("list"), list); +}); + +test("toDict gives a new map each time", () => { + const payload = new Payload({ a: 1 }); + const out = payload.toDict(); + out.a = 2; + assert.equal(payload.get("a"), 1); +}); + +test("the trace id is null until it is set, and the lineage starts empty", () => { + const payload = new Payload({}); + assert.equal(payload.traceId, null); + assert.deepEqual(payload.lineage, []); +}); + +test("a key with the name of an Object property is only a key", () => { + const payload = new Payload({}); + assert.equal(payload.has("constructor"), false); + assert.equal(payload.get("toString"), null); + payload.insert("__proto__", 1); + assert.equal(payload.get("__proto__"), 1); + assert.deepEqual(Object.keys(payload.toDict()), ["__proto__"]); +}); diff --git a/packages/codeupipe-ts/tests/pipeline.test.ts b/packages/codeupipe-ts/tests/pipeline.test.ts new file mode 100644 index 0000000..9cef7d3 --- /dev/null +++ b/packages/codeupipe-ts/tests/pipeline.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Hook, Payload, Pipeline } from "../src/index.ts"; +import type { Filter, Predicate, Tap } from "../src/index.ts"; + +class AddFilter implements Filter { + call(payload: Payload): Payload { + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class AddLaterFilter implements Filter { + async call(payload: Payload): Promise { + await Promise.resolve(); + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class FailFilter implements Filter { + call(): Payload { + throw new Error("boom"); + } +} + +class IsOnPredicate implements Predicate { + test(payload: Payload): boolean { + return payload.get("on") === true; + } +} + +class SeeTap implements Tap { + seen: unknown[] = []; + observe(payload: Payload): void { + this.seen.push(payload.get("n")); + } +} + +class FailTap implements Tap { + observe(): void { + throw new Error("sink is down"); + } +} + +class FailLaterTap implements Tap { + async observe(): Promise { + await Promise.resolve(); + throw new Error("sink is down later"); + } +} + +class InnerPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new AddFilter()); + this.addFilter(new AddFilter()); + } +} + +class RecordHook extends Hook { + events: string[] = []; + override before(step: string | null): void { + this.events.push("before:" + (step ?? "")); + } + override after(step: string | null): void { + this.events.push("after:" + (step ?? "")); + } + override onError(step: string | null): void { + this.events.push("error:" + (step ?? "")); + } +} + +test("an empty pipeline returns the data it was given", async () => { + const out = await new Pipeline().run(new Payload({ a: 1 })); + assert.deepEqual(out.toDict(), { a: 1 }); +}); + +test("steps run in order, and State records them", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddLaterFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 2); + assert.deepEqual(pipeline.state.executed, ["AddFilter", "AddLaterFilter"]); + assert.deepEqual(pipeline.state.skipped, []); + assert.deepEqual(pipeline.state.errors, []); +}); + +test("run works on a copy: the caller's Payload is not changed", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + const given = new Payload({ n: 1 }); + const out = await pipeline.run(given); + assert.equal(given.get("n"), 1); + assert.equal(out.get("n"), 2); +}); + +test("a Valve runs its Filter when the Predicate is true", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + const out = await pipeline.run(new Payload({ on: true })); + assert.equal(out.get("n"), 1); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); + assert.deepEqual(pipeline.state.skipped, []); +}); + +test("a Valve skips its Filter when the Predicate is false", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + const out = await pipeline.run(new Payload({ on: false })); + assert.equal(out.has("n"), false); + assert.deepEqual(pipeline.state.executed, []); + assert.deepEqual(pipeline.state.skipped, ["AddFilter"]); +}); + +test("a Filter failure stops the run, and State keeps the steps before it", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new FailFilter()); + pipeline.addFilter(new AddFilter()); + await assert.rejects(pipeline.run(new Payload({})), /boom/); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); +}); + +test("a Tap reads the Payload and counts as executed", async () => { + const tap = new SeeTap(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(tap); + await pipeline.run(new Payload({})); + assert.deepEqual(tap.seen, [1]); + assert.deepEqual(pipeline.state.executed, ["AddFilter", "SeeTap"]); +}); + +test("a Tap failure is recorded and does not stop the run", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addTap(new FailTap()); + pipeline.addTap(new FailLaterTap()); + pipeline.addFilter(new AddFilter()); + pipeline.useHook(hook); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 1); + assert.deepEqual(pipeline.state.executed, ["FailTap", "FailLaterTap", "AddFilter"]); + assert.deepEqual(pipeline.state.errors.map(([label]) => label), ["FailTap", "FailLaterTap"]); + assert.match(String(pipeline.state.errors[0]?.[1]), /sink is down/); + assert.deepEqual(hook.events, ["before:", "before:AddFilter", "after:AddFilter", "after:"]); +}); + +test("a nested pipeline is one step", async () => { + const pipeline = new Pipeline(); + pipeline.addPipeline(new InnerPipeline()); + pipeline.addFilter(new AddFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 3); + assert.deepEqual(pipeline.state.executed, ["InnerPipeline", "AddFilter"]); +}); + +test("Hooks see the pipeline and each Filter or Valve, in order", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addValve(new IsOnPredicate(), new AddLaterFilter()); + pipeline.addTap(new SeeTap()); + pipeline.useHook(hook); + await pipeline.run(new Payload({})); + assert.deepEqual(hook.events, [ + "before:", "before:AddFilter", "after:AddFilter", + "before:AddLaterFilter", "after:AddLaterFilter", "after:", + ]); +}); + +test("on a Filter failure a Hook gets the step, then the pipeline, and no after", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new FailFilter()); + pipeline.useHook(hook); + await assert.rejects(pipeline.run(new Payload({}))); + assert.deepEqual(hook.events, [ + "before:", "before:AddFilter", "after:AddFilter", + "before:FailFilter", "error:FailFilter", "error:", + ]); +}); + +test("a Hook gets the error itself", async () => { + const seen: unknown[] = []; + class KeepErrorHook extends Hook { + override onError(_step: string | null, error: unknown): void { + seen.push(error); + } + } + const pipeline = new Pipeline(); + pipeline.addFilter(new FailFilter()); + pipeline.useHook(new KeepErrorHook()); + await assert.rejects(pipeline.run(new Payload({}))); + assert.equal(seen.length, 2); + assert.ok(seen[0] instanceof Error); + assert.equal(seen[0], seen[1]); +}); + +test("a Hook method can be async", async () => { + const events: string[] = []; + class SlowHook extends Hook { + override async before(step: string | null): Promise { + await Promise.resolve(); + events.push("before:" + (step ?? "")); + } + } + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.useHook(new SlowHook()); + await pipeline.run(new Payload({})); + assert.deepEqual(events, ["before:", "before:AddFilter"]); +}); + +test("timing and lineage are off by default", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(pipeline.state.timings.size, 0); + assert.deepEqual(out.lineage, []); +}); + +test("observe turns timing on, and lineage when asked", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(new SeeTap()); + pipeline.addValve(new IsOnPredicate(), new AddLaterFilter()); + pipeline.observe({ lineage: true }); + const out = await pipeline.run(new Payload({})); + assert.deepEqual([...pipeline.state.timings.keys()], ["AddFilter", "AddLaterFilter"]); + assert.ok((pipeline.state.timings.get("AddFilter") ?? -1) >= 0); + assert.deepEqual(out.lineage, ["AddFilter", "AddLaterFilter"]); +}); + +test("each run starts with a new State", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + await pipeline.run(new Payload({ on: false })); + await pipeline.run(new Payload({ on: true })); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); + assert.deepEqual(pipeline.state.skipped, []); +}); + +test("two runs at the same time do not write into the same State", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddLaterFilter()); + pipeline.addFilter(new AddLaterFilter()); + const [a, b] = await Promise.all([pipeline.run(new Payload({})), pipeline.run(new Payload({}))]); + assert.equal(a.get("n"), 2); + assert.equal(b.get("n"), 2); + assert.deepEqual(pipeline.state.executed, ["AddLaterFilter", "AddLaterFilter#2"]); +}); diff --git a/packages/codeupipe-ts/tests/pricing-example.test.ts b/packages/codeupipe-ts/tests/pricing-example.test.ts new file mode 100644 index 0000000..995372f --- /dev/null +++ b/packages/codeupipe-ts/tests/pricing-example.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { PricingPipeline } from "../examples/pricing/pricing-pipeline.ts"; +import { Payload } from "../src/index.ts"; + +test("a large order gets the discount and the bulk mark", async () => { + const pipeline = new PricingPipeline(); + const out = await pipeline.run(new Payload({ price: 100.0, qty: 12 })); + assert.deepEqual(out.toDict(), { price: 90, qty: 12, bulk: true }); + assert.deepEqual(pipeline.state.executed, ["DiscountFilter", "MarkBulkFilter", "AuditTap"]); + assert.deepEqual(pipeline.state.errors.map(([label]) => label), ["AuditTap"]); +}); + +test("a small order gets the discount only", async () => { + const pipeline = new PricingPipeline(); + const out = await pipeline.run(new Payload({ price: 100.0, qty: 2 })); + assert.deepEqual(out.toDict(), { price: 90, qty: 2 }); + assert.deepEqual(pipeline.state.skipped, ["MarkBulkFilter"]); +}); diff --git a/packages/codeupipe-ts/tests/step-label.test.ts b/packages/codeupipe-ts/tests/step-label.test.ts new file mode 100644 index 0000000..58de4d6 --- /dev/null +++ b/packages/codeupipe-ts/tests/step-label.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import * as codeupipe from "../src/index.ts"; +import { Payload, Pipeline } from "../src/index.ts"; + +class AddFilter { + call(payload: Payload): Payload { + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class SeeTap { + observe(): void {} +} + +class IsOnPredicate { + test(payload: Payload): boolean { + return payload.get("on") === true; + } +} + +class InnerPipeline extends Pipeline {} + +test("the label is the name of the class", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(new SeeTap()); + assert.deepEqual(pipeline.labels, ["AddFilter", "SeeTap"]); +}); + +test("the second use of a class gets a number", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddFilter()); + assert.deepEqual(pipeline.labels, ["AddFilter", "AddFilter#2", "AddFilter#3"]); +}); + +test("a Valve has the label of its Filter", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + assert.deepEqual(pipeline.labels, ["AddFilter", "AddFilter#2"]); +}); + +test("a nested pipeline has the label of its class", () => { + const pipeline = new Pipeline(); + pipeline.addPipeline(new InnerPipeline()); + pipeline.addPipeline(new Pipeline()); + assert.deepEqual(pipeline.labels, ["InnerPipeline", "Pipeline"]); +}); + +test("a class defined in a function works", () => { + function make() { + class LocalFilter { + call(payload: Payload): Payload { + return payload; + } + } + return new LocalFilter(); + } + const pipeline = new Pipeline(); + pipeline.addFilter(make()); + assert.deepEqual(pipeline.labels, ["LocalFilter"]); +}); + +test("a step from a class with no name has the label 'anonymous'", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new (class { + call(payload: Payload): Payload { + return payload; + } + })()); + assert.deepEqual(pipeline.labels, ["anonymous"]); +}); + +test("labels gives a new list each time", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.labels.push("x"); + assert.deepEqual(pipeline.labels, ["AddFilter"]); +}); + +const untyped = (value: unknown) => value as never; + +test("a step without the method of its role is refused when it is added", () => { + const pipeline = new Pipeline(); + assert.throws(() => pipeline.addFilter(untyped((p: Payload) => p)), { name: "TypeError", message: /A Filter has call\(payload\)/ }); + assert.throws(() => pipeline.addFilter(untyped(new SeeTap())), { name: "TypeError", message: /call\(payload\)/ }); + assert.throws(() => pipeline.addTap(untyped(new AddFilter())), { name: "TypeError", message: /A Tap has observe\(payload\)/ }); + assert.throws(() => pipeline.addValve(untyped(() => true), new AddFilter()), { name: "TypeError", message: /A Predicate has test\(payload\)/ }); + assert.throws(() => pipeline.addValve(new IsOnPredicate(), untyped(null)), { name: "TypeError", message: /A Filter has call\(payload\)/ }); + assert.throws(() => pipeline.addPipeline(untyped(new AddFilter())), { name: "TypeError", message: /addPipeline takes a Pipeline/ }); + assert.deepEqual(pipeline.labels, []); +}); + +test("addValve says so when the arguments are in the wrong order", () => { + const pipeline = new Pipeline(); + assert.throws(() => pipeline.addValve(untyped(new AddFilter()), untyped(new IsOnPredicate())), + { name: "TypeError", message: /wrong order/ }); +}); + +test("the package exports the primitives a user needs, and no Valve", () => { + assert.deepEqual(Object.keys(codeupipe).sort(), ["Hook", "Payload", "Pipeline", "State"]); +}); diff --git a/packages/codeupipe-ts/tsconfig.build.json b/packages/codeupipe-ts/tsconfig.build.json new file mode 100644 index 0000000..4842647 --- /dev/null +++ b/packages/codeupipe-ts/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/codeupipe-ts/tsconfig.json b/packages/codeupipe-ts/tsconfig.json new file mode 100644 index 0000000..de9cc28 --- /dev/null +++ b/packages/codeupipe-ts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "tests", "examples", "../../conformance/runners/typescript"] +} diff --git a/tests/conformance/test_typescript_target.py b/tests/conformance/test_typescript_target.py new file mode 100644 index 0000000..02f3021 --- /dev/null +++ b/tests/conformance/test_typescript_target.py @@ -0,0 +1,42 @@ +"""TypeScript must pass the same conformance cases as Python. + +The test needs a Node that runs a .ts file as it is (22.18 or later). On a +machine with no such Node it is skipped. +""" + +import json +import pathlib +import sys + +import pytest + +CONFORMANCE = pathlib.Path(__file__).resolve().parents[2] / "conformance" +sys.path.insert(0, str(CONFORMANCE)) + +import run as conformance_run # noqa: E402 + +pytestmark = pytest.mark.skipif(not conformance_run._node_runs_typescript(), + reason="needs Node 22.18 or later") + + +def test_typescript_passes_every_case(tmp_path): + cases = conformance_run.load_cases() + results, error = conformance_run.run_target( + "typescript:run", conformance_run.TARGETS["typescript:run"], cases) + assert error is None, error + verdicts = {case["id"]: conformance_run.judge(case, results.get(case["id"])) for case in cases} + not_passed = {case_id: verdict for case_id, verdict in verdicts.items() if verdict[0] != "pass"} + assert not_passed == {}, json.dumps(not_passed, indent=2) + + +def test_a_target_that_cannot_run_is_a_failure(monkeypatch, tmp_path): + monkeypatch.setattr(conformance_run, "RESULTS", tmp_path) + monkeypatch.setitem(conformance_run.TARGETS, "broken:run", + {"cmd": [sys.executable, "-c", "raise SystemExit(3)"], "env": {}, "cwd": tmp_path}) + assert conformance_run.main(["broken:run"]) == 1 + + +def test_node_is_not_needed_for_the_default_targets(monkeypatch): + monkeypatch.setattr(conformance_run, "_node_runs_typescript", lambda: False) + assert [name for name in conformance_run.default_targets() if name.startswith("typescript:")] == [] + assert len(conformance_run.default_targets()) == 5 # every Python target From a7a3c85495d3a6c74ac69a2bc03b2c9c6dee0d26 Mon Sep 17 00:00:00 2001 From: Joshua Wink Date: Sat, 10 Oct 2026 14:47:25 -0500 Subject: [PATCH 2/2] test(typescript): the pricing example in a bundle, plain and minified A test makes a browser bundle of the example with esbuild and runs it: plain, minified, and minified with class names kept. The result is the same in all three. The labels change only in the minified bundle that does not keep names, as the README says. Co-Authored-By: Claude Opus 5.5 --- packages/codeupipe-ts/README.md | 9 +- .../examples/pricing/browser/entry.ts | 22 + packages/codeupipe-ts/package-lock.json | 485 ++++++++++++++++++ packages/codeupipe-ts/package.json | 8 +- packages/codeupipe-ts/tests/bundle.test.ts | 53 ++ 5 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 packages/codeupipe-ts/examples/pricing/browser/entry.ts create mode 100644 packages/codeupipe-ts/tests/bundle.test.ts diff --git a/packages/codeupipe-ts/README.md b/packages/codeupipe-ts/README.md index b1aa3d0..5bde8b8 100644 --- a/packages/codeupipe-ts/README.md +++ b/packages/codeupipe-ts/README.md @@ -127,16 +127,21 @@ override the methods that you need. State and Hooks report a step by its label, which is the name of its class. A minifier changes class names, so a label in minified code can be a name -such as `a`. To keep the names, turn on the option of your tool that keeps +such as `b`. To keep the names, turn on the option of your tool that keeps class names (for example `keepNames` in esbuild, `keep_classnames` in terser). A class with no name has the label `anonymous`. +`tests/bundle.test.ts` checks this with esbuild: the pricing example gives +the same result in a plain bundle, a minified bundle and a minified bundle +that keeps names, and the labels change only in the second. +`examples/pricing/browser/entry.ts` is the entry of that bundle. + ## Development Node 22.18 or later runs the `.ts` files as they are. ```bash -npm install # the TypeScript compiler and the Node types, for development only +npm install # the TypeScript compiler, the Node types and esbuild, for development only npm test # the tests npm run typecheck # the type check of the source, tests, example and conformance runner npm run build # JavaScript and type files in dist/ diff --git a/packages/codeupipe-ts/examples/pricing/browser/entry.ts b/packages/codeupipe-ts/examples/pricing/browser/entry.ts new file mode 100644 index 0000000..360d291 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/browser/entry.ts @@ -0,0 +1,22 @@ +// The entry of the browser check. A bundler makes one script from this file. +// The script runs the pricing pipeline and puts the result on the page and +// on globalThis, where a test can read it. +import { Payload } from "../../../src/index.ts"; +import { PricingPipeline } from "../pricing-pipeline.ts"; + +// The page of a browser. Node has none. +declare const document: { body: { textContent: string | null } } | undefined; + +const pipeline = new PricingPipeline(); +const output = (await pipeline.run(new Payload({ price: 100.0, qty: 12 }))).toDict(); +const result = { + output, + labels: pipeline.labels, + executed: pipeline.state.executed, + errors: pipeline.state.errors.map(([label, error]) => [label, String(error)]), +}; + +(globalThis as { cupResult?: unknown }).cupResult = result; +if (typeof document !== "undefined") { + document.body.textContent = JSON.stringify(result); +} diff --git a/packages/codeupipe-ts/package-lock.json b/packages/codeupipe-ts/package-lock.json index 3ab9a4f..4e31871 100644 --- a/packages/codeupipe-ts/package-lock.json +++ b/packages/codeupipe-ts/package-lock.json @@ -10,12 +10,455 @@ "license": "Apache-2.0", "devDependencies": { "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0" }, "engines": { "node": ">=22.18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "24.19.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", @@ -26,6 +469,48 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/packages/codeupipe-ts/package.json b/packages/codeupipe-ts/package.json index e02595b..069bece 100644 --- a/packages/codeupipe-ts/package.json +++ b/packages/codeupipe-ts/package.json @@ -18,7 +18,12 @@ "default": "./dist/index.js" } }, - "files": ["dist", "LICENSE", "NOTICE", "README.md"], + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md" + ], "sideEffects": false, "engines": { "node": ">=22.18" @@ -30,6 +35,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0" } } diff --git a/packages/codeupipe-ts/tests/bundle.test.ts b/packages/codeupipe-ts/tests/bundle.test.ts new file mode 100644 index 0000000..9418b33 --- /dev/null +++ b/packages/codeupipe-ts/tests/bundle.test.ts @@ -0,0 +1,53 @@ +// The built package in a bundle, as a web app has it: plain, minified, and +// minified with class names kept. The README says what a minifier does to +// labels; this file checks it. +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const ENTRY = join(import.meta.dirname, "../examples/pricing/browser/entry.ts"); +const LABELS = ["DiscountFilter", "MarkBulkFilter", "AuditTap"]; + +type Result = { output: unknown; labels: string[]; executed: string[] }; + +async function bundle(options: { minify: boolean; keepNames: boolean }): Promise<{ code: string; result: Result }> { + const built = await build({ + entryPoints: [ENTRY], bundle: true, write: false, format: "esm", platform: "browser", + target: "es2022", ...options, + }); + const code = built.outputFiles[0]?.text ?? ""; + const file = join(mkdtempSync(join(tmpdir(), "cup-bundle-")), "bundle.mjs"); + writeFileSync(file, code); + await import(pathToFileURL(file).href); + return { code, result: (globalThis as unknown as { cupResult: Result }).cupResult }; +} + +test("the bundle for a browser has no import of Node", async () => { + const { code } = await bundle({ minify: false, keepNames: false }); + assert.doesNotMatch(code, /from\s*"node:|require\(/); +}); + +test("a plain bundle gives the same result and the same labels", async () => { + const { result } = await bundle({ minify: false, keepNames: false }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.deepEqual(result.labels, LABELS); + assert.deepEqual(result.executed, LABELS); +}); + +test("a minified bundle gives the same result, but the labels are changed", async () => { + const { result } = await bundle({ minify: true, keepNames: false }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.equal(result.labels.length, 3); + assert.notDeepEqual(result.labels, LABELS); +}); + +test("a minified bundle that keeps names has the same labels", async () => { + const { result } = await bundle({ minify: true, keepNames: true }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.deepEqual(result.labels, LABELS); +});