diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000000..8a6bc55892d9 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,56 @@ +name: Setup +description: Node, bun, workspace dependencies and the turbo remote cache. Shared by every CI job. + +inputs: + turbo-s3-bucket: + description: S3 bucket for the turbo remote cache + required: true + turbo-s3-endpoint: + description: S3 endpoint for the turbo remote cache + required: true + turbo-s3-access-key-id: + description: S3 access key id for the turbo remote cache + required: true + turbo-s3-secret-access-key: + description: S3 secret access key for the turbo remote cache + required: true + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-bun- + + # redis-memory-server compiles Redis from source in its postinstall (about 3 minutes) unless + # the binary for the version pinned in the root package.json is already in this folder. + - uses: actions/cache@v4 + with: + path: node_modules/.cache/redis-memory-server + key: ${{ runner.os }}-redis-memory-server-${{ hashFiles('package.json') }} + restore-keys: ${{ runner.os }}-redis-memory-server- + + - run: bun install --frozen-lockfile + shell: bash + + - name: Turbo Cache + uses: rharkor/caching-for-turbo@v2.4.2 + with: + provider: s3 + s3-bucket: ${{ inputs.turbo-s3-bucket }} + s3-region: auto + s3-endpoint: ${{ inputs.turbo-s3-endpoint }} + s3-access-key-id: ${{ inputs.turbo-s3-access-key-id }} + s3-secret-access-key: ${{ inputs.turbo-s3-secret-access-key }} + max-age: 3w + max-size: 20gb diff --git a/.github/workflows/ci-v2.yml b/.github/workflows/ci-v2.yml new file mode 100644 index 000000000000..bc14cdf9c2ba --- /dev/null +++ b/.github/workflows/ci-v2.yml @@ -0,0 +1,232 @@ +name: CI v2 +on: + pull_request: + +permissions: + actions: read + contents: read + +concurrency: + group: ci-v2-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + # turbo --affected diffs against this ref. CI checkouts have origin/ but no local main. + TURBO_SCM_BASE: origin/${{ github.base_ref }} + TURBO_TELEMETRY_DISABLED: "1" + +jobs: + changes: + runs-on: ubuntu-latest + if: github.repository == 'activepieces/activepieces' + outputs: + packages: ${{ steps.affected.outputs.packages }} + all: ${{ steps.affected.outputs.all }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Find affected packages + id: affected + run: | + set -euo pipefail + ALL=false + # Files outside any package (workflows, root config, tools) are invisible to turbo's + # graph, so a change there runs everything. Docs and markdown never do. + OUTSIDE_PACKAGES=$(git diff --name-only "$TURBO_SCM_BASE...HEAD" | grep -vE '^(packages/|docs/|brain/|\.agents/|\.claude/)|\.md$' || true) + if [ -n "$OUTSIDE_PACKAGES" ]; then + ALL=true + fi + # Pieces follow the old ci.yml rule: a piece runs when its own files changed, and every + # piece runs when pieces/framework or pieces/common changed. A change to a core package + # (core-piece-types, core-utils, ...) does not pull the 700+ pieces in, even though they + # depend on it; a break there is caught when pieces are published, not in the PR. + if ! DIRECT=$(npx --yes turbo@2.9.14 ls --filter="[$TURBO_SCM_BASE...HEAD]" --output=json | jq -c '[.packages.items[].name]') \ + || ! AFFECTED=$(npx --yes turbo@2.9.14 ls --affected --output=json | jq -c '[.packages.items[].name]'); then + ALL=true + PACKAGES='[]' + else + PACKAGES=$(jq -cn --argjson direct "$DIRECT" --argjson affected "$AFFECTED" ' + ($direct | any(. == "@activepieces/pieces-framework" or . == "@activepieces/pieces-common")) as $allPieces + | [$affected[] | select((startswith("@activepieces/piece-") | not) or $allPieces or IN($direct[]))]') + fi + echo "all=$ALL" >> "$GITHUB_OUTPUT" + echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" + echo "all=$ALL" + echo "packages=$PACKAGES" + + lint: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} + + - name: Lint core packages + if: needs.changes.outputs.all == 'true' + run: npm run lint-core + + # Also on full runs: a PR that edits a workflow and pieces/framework in one go still needs + # every piece linted, and the changes job's list is what knows that. + - name: Lint affected packages + env: + AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} + run: | + set -euo pipefail + FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | "--filter=" + .' | tr '\n' ' ') + if [ -n "$FILTERS" ]; then + npx turbo run lint $FILTERS + fi + + build-test: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' + services: + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + env: + AP_REDIS_HOST: localhost + AP_REDIS_PORT: "6379" + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} + + # One turbo invocation so every package builds once: the affected packages from the changes + # job, plus the core set on full runs. --affected and --filter intersect in turbo, so the + # union is built here as explicit filters. api is excluded because its `test` script runs + # the three integration suites serially; the api-tests matrix covers those. + - name: Typecheck, build and test + env: + AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} + RUN_ALL: ${{ needs.changes.outputs.all }} + run: | + set -euo pipefail + FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | select(. != "api") | "--filter=" + .' | tr '\n' ' ') + if [ "$RUN_ALL" = "true" ]; then + FILTERS="$FILTERS $(npx tsx tools/scripts/test-filters.ts)" + fi + npx turbo run typecheck build test $FILTERS + + - name: Check new migrations are rollback-safe + run: npx tsx tools/scripts/check-migration-rollback.ts + + # The api e2e tests import the worker's source, and the worker resolves @activepieces/sandbox + # through its built dist, so the api jobs build worker too and also run when worker is affected. + api-tests: + name: api (${{ matrix.name }}) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') || contains(needs.changes.outputs.packages, '"worker"') + strategy: + fail-fast: false + matrix: + include: + - name: ce + tasks: test-ce + - name: cloud + tasks: test-cloud + - name: ee, unit, migrations + tasks: test-ee test-unit check-migrations + services: + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + env: + # .env.tests pins AP_REDIS_HOST=redis, the alias inside the job's docker network, which a + # non-container job is not on. dotenv does not override values already in the environment. + AP_REDIS_HOST: localhost + AP_REDIS_PORT: "6379" + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} + + - run: npx turbo run build ${{ matrix.tasks }} --filter=api --filter=worker + + # Only the tool-search integration tests, against real PostgreSQL + pgvector. The api-tests + # matrix runs them on PGLITE, which papers over node-postgres driver behaviour. + tool-search-postgres: + name: tool-search (postgres) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: activepieces + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + env: + AP_DB_TYPE: POSTGRES + AP_POSTGRES_HOST: localhost + AP_POSTGRES_PORT: "5432" + AP_POSTGRES_DATABASE: activepieces + AP_POSTGRES_USERNAME: postgres + AP_POSTGRES_PASSWORD: postgres + AP_POSTGRES_USE_SSL: "false" + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} + + - name: Enable pgvector extension + run: | + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d activepieces \ + -c 'CREATE EXTENSION IF NOT EXISTS vector;' + + - run: npx turbo run build --filter=api + + # Serial: every file migrates the one shared service DB on DataSource.initialize(). + - name: Run tool-search integration tests on Postgres + working-directory: packages/server/api + run: npx vitest run test/integration/ce/tool-search --bail 1 --passWithNoTests=false --no-file-parallelism diff --git a/.github/workflows/warm-ci-cache.yml b/.github/workflows/warm-ci-cache.yml new file mode 100644 index 000000000000..5ad0b7c67300 --- /dev/null +++ b/.github/workflows/warm-ci-cache.yml @@ -0,0 +1,22 @@ +name: Warm CI cache +# GitHub only lets a pull request restore caches created on the same PR or on the base branch. +# CI v2 runs on pull requests only, so this seeds the base-branch copies (bun downloads and the +# compiled redis-memory-server binary) that every new PR's first run restores. +on: + push: + branches: + - main + +jobs: + warm: + runs-on: ubuntu-latest + if: github.repository == 'activepieces/activepieces' + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} diff --git a/brain/knowledge/data-storage-observability/tables.md b/brain/knowledge/data-storage-observability/tables.md index 082103c442ec..48c24ff1bc50 100644 --- a/brain/knowledge/data-storage-observability/tables.md +++ b/brain/knowledge/data-storage-observability/tables.md @@ -27,6 +27,7 @@ A built-in relational database inside Activepieces: users store structured data - `record.create()` bulk insert caps at 50 per batch, transactional. - When adding any new table/field/record route, the `permission` arg to `securityAccess.project(...)` is required — passing `undefined` silently allows any project member. - The per-create `field.validateCount()` check alone races on bulk paths: all concurrent creates read the same pre-save count and pass. Bulk/import paths (`table.create` with fields, project-state/project-replace apply) MUST call it once up front with the batch size **before** their `Promise.all`. +- The web table store keys each cell by the column's position (`fieldIndex`), not by field id, so every field mutator in `ap-tables-client-state.tsx` has to renumber surviving cells (`deleteField` didn't, see ENG-537). A cell edit sends only the edited cell, and `record.update` upserts just those cells, so stale indexes can't blank other columns on the server. - Concurrent field reorders are last-write-wins, same as rename — no distributed lock. Reordering *existing* fields through project-release apply is not supported: `FieldState` carries no position, so array order only applies to newly created fields. - The web client stores positional `cell.fieldIndex` references, so it must remap every record's cells when fields move. diff --git a/brain/knowledge/engineering/ci-pipeline.md b/brain/knowledge/engineering/ci-pipeline.md new file mode 100644 index 000000000000..acdbd443c5aa --- /dev/null +++ b/brain/knowledge/engineering/ci-pipeline.md @@ -0,0 +1,36 @@ +--- +icon: ⏱️ +--- + +# CI Pipeline + +The PR checks that decide whether a change builds and passes tests. Lives in `.github/workflows/ci-v2.yml` (`ci.yml` runs beside it until the side-by-side comparison ends). The gates that shape *how a PR is reviewed* are on *CI PR Review Hygiene*. + +**changes job** — the first job. Runs `turbo ls --affected` to list the packages the PR touches plus everything that depends on them, and sets `all=true` when a file outside any package changed (workflows, root config, `tools/`). Every other job keys off those two outputs. + +**affected** — turbo's own `--affected` filter: the dependency graph decides what runs, not path globs. A `shared` change runs api, web and worker; a piece change runs that piece and, if api depends on it, api. When turbo cannot diff, it runs everything, so the failure mode is slow, never skipped. _Avoid_: hand-written `git diff | grep pieces/` filters, the graph already knows. + +**api matrix** — the api suites split one per runner (`test-ce`, `test-cloud`, `test-ee test-unit check-migrations`) and only when `api` is affected. They were the whole critical path when they shared one 4-core runner. + +**setup action** — `.github/actions/setup`: node, bun, `bun install --frozen-lockfile`, turbo remote cache. Every job uses it; change install behaviour there once. + +## Gotchas + +- `redis-memory-server` downloads and compiles Redis from source in its postinstall (about 3 minutes, most of the old 3.5-minute `bun install`). Api tests, unit tests included, start an in-memory Redis from that binary at runtime, so skipping the postinstall (`REDISMS_DISABLE_POSTINSTALL=1`) only moves the compile into the first test that needs it and trips the 60 s and 120 s timeouts. Cache `node_modules/.cache/redis-memory-server` (keyed on the root `package.json`, which pins the version under `redisMemoryServer.version`) instead. Do not swap the in-memory Redis for the service container to avoid the compile: each vitest fork gets its own Redis today, a shared one lets parallel test files interfere. +- Every `build` task is `cache: false` in `turbo.json`, so each separate `turbo run` invocation rebuilds its whole dependency chain. Tasks that share dependencies belong in one invocation. +- Api integration tests boot the full server once per test file (`pool: forks`, isolated). The number of test files is the CI cost driver: 122 files in June 2026 → 310 in September moved the test step from 6 to 19 minutes on one runner. +- The GitHub org is on the free plan: 20 concurrent jobs org-wide, all workflows included. More jobs per PR means queueing at busy hours. `cancel-in-progress` per PR number keeps superseded runs from holding slots. +- api's `test` script runs ce+ee+cloud serially. Any `turbo run test` over many packages must exclude api (`--filter='!api'`; `tools/scripts/test-filters.ts` does the same). +- The api e2e tests (`execute-flow-e2e`, `test-step-e2e`, `piece-options-e2e`) import the worker's source, and `worker.ts` imports `@activepieces/sandbox`, which resolves to `packages/server/sandbox/dist`. A job that builds only api fails those three files with "Failed to resolve entry for package @activepieces/sandbox". The api jobs build `worker` as well and also run when worker is affected. +- A cache saved during a pull request run is visible only to that PR and to nothing else. Caches every PR should hit (bun downloads, the compiled Redis binary) must be created on `main`; `warm-ci-cache.yml` does that on every push to main. +- Two turbo invocations running at the same time in one job race on `cache: false` builds: the old `ci.yml` failed `api#build` with "@activepieces/shared has no exported member" for a member that exists, because a second invocation was rewriting `shared/dist` while api's tsc read it. One invocation per job. +- `--affected` and `--filter` intersect in turbo; there is no union flag. To run "the affected packages plus this fixed set" in one invocation, pass the affected names (the `changes` job outputs them as JSON) as explicit `--filter=` arguments alongside the fixed ones. Two invocations rebuild the shared dependency chain twice because `build` is uncached. +- Pieces are the one place the pipeline overrides the dependency graph, by decision (2026-09-24). A piece runs when its own files changed; every piece runs when `pieces/framework` or `pieces/common` changed. A change to `core-piece-types`, `core-utils`, `core-formula` or `core-execution` does not pull the 700+ pieces in, although they depend on it, because that cost about 20 minutes per such PR (5 of the last 60). The accepted blind spot: a core type change that breaks piece compilation is caught when pieces are published (`release-pieces.yml`), not in the PR. The `changes` job computes this with two turbo listings, `ls --filter='[base...HEAD]'` (changed directly) and `ls --affected` (plus dependents). +- `TURBO_SCM_BASE` must be `origin/` in CI. turbo's default base is `main`, which a CI checkout does not have, so without it every run degrades to "everything affected". + +## Key files + +- `.github/workflows/ci-v2.yml` — the PR pipeline +- `.github/actions/setup` — shared install and cache steps +- `tools/scripts/test-filters.ts` — every package with a `test` script, minus api +- `tools/scripts/check-migration-rollback.ts` — new migrations must declare `breaking` and `down()` diff --git a/brain/knowledge/engineering/index.md b/brain/knowledge/engineering/index.md index 82eac033dbfe..369abbb99184 100644 --- a/brain/knowledge/engineering/index.md +++ b/brain/knowledge/engineering/index.md @@ -34,6 +34,7 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b - **Web Navigation Shell** — the rail, the three sidebars and the layouts that choose between them, which primitives are shared, and why platform admin renders without the rail - **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both - **Helm Chart** — the Kubernetes install we ship to self-hosters, its two competing paths for an `AP_*` variable, and the secrets it never creates +- **CI Pipeline** — the PR checks that build and test a change: the affected-packages gate, the api matrix, and why install and test time grew - **CI PR Review Hygiene** — draft-first Greptile review, the per-area PR size gate, and the workflow conventions reviewers keep re-litigating - **E2E Tests & Monitors** — the one Playwright suite behind CI, the Checkly monitors that run it against production Cloud, and the BetterStack script the repo pushes on merge - **Architecture Spine** — the load-bearing structure of the codebase, and the gotchas that come with it: request-body `.max()` as data loss, TypeORM soft-delete across a canary window, and canary not proxying websockets diff --git a/brain/knowledge/eventing-webhooks/webhooks.md b/brain/knowledge/eventing-webhooks/webhooks.md index 905be3e104aa..a61bfcdaf251 100644 --- a/brain/knowledge/eventing-webhooks/webhooks.md +++ b/brain/knowledge/eventing-webhooks/webhooks.md @@ -37,6 +37,7 @@ Webhooks are the primary entry point for event-driven flow execution from outsid - **`workerHandlerId` and `httpRequestId` are persisted in exactly one place: `waitpoint.workerHandlerId` and `waitpoint.httpRequestId`, and only for paused runs.** `flow_run` has no such column and no migration ever added one, so for a plain sync run the two ids live only in the BullMQ job payload. That is why answering a waiting caller from the app side means carrying them back on the request rather than looking them up. A useful consequence of the waitpoint columns: an async resume inherits them (`resume-service.ts`), so a resumed run that then fails answers the original sync caller too. - **Splitting a sync timeout into engine-class and worker-class tells you who can still answer.** Joining timed-out sync runs to their worker `job.execute` event over 3h gave 5,685 with outcome `success` (the engine reported the terminal status itself) against 141 `failed` (137 `SANDBOX_INTERNAL_ERROR`, the rest RPC timeout or socket exit). Note no `flowRun.status` attribute is shipped to ClickHouse, so separating a genuinely failed run from a silent success inside that 5,685 needs a Postgres query on `flow_run.status`. - **The sync response reaches the caller before the run row says it failed.** `uploadRunLog` only *enqueues* the status onto `runsMetadataQueue`; the Postgres write happens later in that queue's worker, behind a distributed lock. The 500 is published in the same call, so a caller that gets its 500 and immediately reads the run over the API can still see `RUNNING`. Do not assert the persisted status straight after a sync response in a test: poll for it. An e2e assertion written that way failed on exactly this while the response itself was already correct. +- **A trigger that rejects a request must throw, never return `[]`.** On async, `[]` just drops the event (no run, counted COMPLETED). On `/sync` it used to mark the trigger SUCCEEDED with an undefined payload and run every downstream step, so a Catch Webhook with a wrong secret still executed the flow (GIT-1749). `runOrReturnPayload` now fails the run on empty output. A throw gives a FAILED run with the reason on `/sync` (500), and a FAILED trigger-run count plus a worker warn on async. Async callers still get 200 because the API answers before any piece code runs; a real 401 is GIT-628. - **A retry can never answer a sync caller.** Flow jobs run `attempts: 2` with exponential backoff starting at eight minutes (`job-queue.ts`), so the second attempt lands long after any 30s caller is gone. Publishing a 500 the moment a run fails therefore costs nothing, and waiting for a retry to maybe succeed would buy nothing. ### Editions diff --git a/brain/knowledge/memory.md b/brain/knowledge/memory.md index 225fb5f76e3c..62309b2f627e 100644 --- a/brain/knowledge/memory.md +++ b/brain/knowledge/memory.md @@ -1,3 +1,4 @@ +- 2026-09-24 — `test-unit` (api unit tests) only joined the PR pipeline on 2026-09-20 (#15653). A branch whose last CI run predates that has never had its unit tests run in CI; expect first-time failures when it merges `main`, as #15550 did (a test mock disagreeing with the real code, not a merge problem). - 2026-09-06 — Follow-up (Option B, "stub-writer cascade"): fix `engine-run-callback-service.ts` `ensureLogsFileExists` (line 84-86) so a fresh log-file stub isn't written with `{ steps: {}, tags: [] }` when the underlying file is missing — that stub is what the next BullMQ retry downloads and trips `EmptyResumeStateError` on. Options: (a) store `internalError` on `flow_run` itself so no stub is needed; (b) use a distinguishable stub shape (`executionState: null`) that the engine reader rejects fast; (c) `UnrecoverableError` in worker on state-loss errors so BullMQ doesn't retry the stub. Handles manual-admin-ops case (case #5 in the retention-hunt discussion) and any non-retention internal-error that would otherwise loop. Ping user once C.2/W.1/#2/#4 have landed. - 2026-09-10 — `bun.lock` on main is out of sync with four workspaces (three piece version bumps, plus `packages/pieces/community/sage-accounting` missing entirely), so `bun install --frozen-lockfile` — and therefore the Dockerfile — fails on main until someone runs `bun install` and commits the lockfile. - 2026-09-10 — Measured embed analytics cost inside the embedded builder (headless Chromium, M-series Mac, 12-step flow, scripted zoom/pan/4 step-panel opens over ~16 s, medians of 3, baseline noise ±7%): Clarity → main-thread +100 ms (+6%, consistent every round), heap +4 to +22 MB depending on GC timing, ~6k extra DOM nodes and ~1.9k extra listeners (its DOM mirror), 76 KB download once, ~120 KB uploaded per 30 s active (~4 KB/s); GTM (container `GTM-5KZNZGMN`, no firing tags) → indistinguishable from baseline at runtime, 331 KB download once; both together → additive. Time-to-canvas unchanged in every scenario. Bench script lives in the session scratchpad `bench/bench.js`; drives `/embed` top-level by answering `CLIENT_INIT` with a synthetic `VENDOR_INIT` from an init script, measures via CDP `Performance.getMetrics`. Worst case (155-step flow with 5 loops, Clarity+GTM with a published GA4 tag, heavy scripted use, medians of 3): normal CPU → main-thread +0.2%, 45 fps both, p95 input latency 1288→1296 ms, heap +40 MB (Clarity mirror takes DOM-tracked nodes 9k→33k), 0.9 MB download once (mostly gtm.js+gtag.js), ~280 KB uploaded per ~50 s; 4× CPU throttle → main-thread +4%, 25 fps both, p95 input 4.47→4.45 s, heap +17 MB. Analytics never changed frame rate or input latency; the builder itself is the bottleneck on huge flows (1.3 s to open a step panel at normal CPU, ~4.5 s throttled, with or without tracking). diff --git a/packages/pieces/core/webhook/package.json b/packages/pieces/core/webhook/package.json index 6b5a23246bc0..9d2e6b7e1342 100644 --- a/packages/pieces/core/webhook/package.json +++ b/packages/pieces/core/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-webhook", - "version": "0.1.41", + "version": "0.1.42", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/core/webhook/src/lib/triggers/catch-hook.ts b/packages/pieces/core/webhook/src/lib/triggers/catch-hook.ts index 452cce5b09b5..652f8a74a1a7 100644 --- a/packages/pieces/core/webhook/src/lib/triggers/catch-hook.ts +++ b/packages/pieces/core/webhook/src/lib/triggers/catch-hook.ts @@ -34,7 +34,7 @@ If it takes more than {{webhookTimeoutSeconds}} seconds, it will return a 408 Re To return data, add an Webhook step to your flow with the Return Response action. `; -enum AuthType { +export enum AuthType { NONE = 'none', BASIC = 'basic', HEADER = 'header', @@ -47,7 +47,7 @@ export const catchWebhook = createTrigger({ description: 'Receive incoming HTTP/webhooks using any HTTP method such as GET, POST, PUT, DELETE, etc.', aiMetadata: { - description: 'Fires once for every request delivered to the unique webhook URL generated for this flow, under any HTTP method. Use it as the entry point for any system that can call a URL; Authentication must be set explicitly to None, Basic credentials, a shared header value, or an HMAC signature over the raw body, and requests that fail verification are silently dropped without starting a run. Appending /sync to the URL makes the caller wait for a Return Response action, while /test generates sample data without triggering the published flow.', + description: 'Fires once for every request delivered to the unique webhook URL generated for this flow, under any HTTP method. Use it as the entry point for any system that can call a URL; Authentication must be set explicitly to None, Basic credentials, a shared header value, or an HMAC signature over the raw body, and requests that fail verification are rejected and recorded as a failed run instead of starting the flow. Appending /sync to the URL makes the caller wait for a Return Response action, while /test generates sample data without triggering the published flow.', }, props: { liveMarkdown: Property.MarkDown({ @@ -199,7 +199,9 @@ export const catchWebhook = createTrigger({ context.payload.rawBody ); if (!verified) { - return []; + throw new Error( + `Webhook authentication failed: request did not pass the configured ${authenticationType} check` + ); } return [context.payload]; }, diff --git a/packages/pieces/core/webhook/test/auth-rejection.test.ts b/packages/pieces/core/webhook/test/auth-rejection.test.ts new file mode 100644 index 000000000000..f862c814806c --- /dev/null +++ b/packages/pieces/core/webhook/test/auth-rejection.test.ts @@ -0,0 +1,102 @@ +/// + +import { createHmac } from 'crypto'; +import { createMockActionContext, TriggerStrategy } from '@activepieces/pieces-framework'; +import { AuthType, catchWebhook } from '../src/lib/triggers/catch-hook'; + +const rawBody = JSON.stringify({ event: 'push' }); +const basic = (password: string) => + `Basic ${Buffer.from(`user:${password}`).toString('base64')}`; + +const authCases: AuthCase[] = [ + { + authType: AuthType.BASIC, + authFields: { username: 'user', password: 'pass' }, + goodHeaders: { authorization: basic('pass') }, + badHeaders: { authorization: basic('wrong') }, + }, + { + authType: AuthType.HEADER, + authFields: { headerName: 'x-secret', headerValue: 'good' }, + goodHeaders: { 'x-secret': 'good' }, + badHeaders: { 'x-secret': 'bad' }, + }, + { + authType: AuthType.HMAC, + authFields: { + hmacHeaderName: 'x-signature', + hmacSecret: 'secret', + hmacAlgorithm: 'sha256', + hmacEncoding: 'hex', + hmacSignaturePrefix: '', + }, + goodHeaders: { + 'x-signature': createHmac('sha256', 'secret').update(rawBody).digest('hex'), + }, + badHeaders: { 'x-signature': 'deadbeef' }, + }, +]; + +describe('catchWebhook.run authentication', () => { + test('passes the request through when no auth is configured', async () => { + const payload = buildPayload({}); + await expect( + runCatchWebhook({ authType: AuthType.NONE, authFields: {}, payload }) + ).resolves.toEqual([payload]); + }); + + test.each(authCases)( + 'passes the request through when $authType auth matches', + async ({ authType, authFields, goodHeaders }) => { + const payload = buildPayload(goodHeaders); + await expect(runCatchWebhook({ authType, authFields, payload })).resolves.toEqual([payload]); + } + ); + + test.each(authCases)( + 'throws instead of dropping the request when $authType auth fails', + async ({ authType, authFields, badHeaders }) => { + await expect( + runCatchWebhook({ authType, authFields, payload: buildPayload(badHeaders) }) + ).rejects.toThrow('Webhook authentication failed'); + } + ); +}); + +function buildPayload(headers: Record) { + return { body: {}, queryParams: {}, rawBody, headers }; +} + +function runCatchWebhook({ + authType, + authFields, + payload, +}: { + authType: AuthType; + authFields: Record; + payload: ReturnType; +}) { + if (catchWebhook.type !== TriggerStrategy.WEBHOOK) { + throw new Error('catchWebhook must be a webhook trigger'); + } + return catchWebhook.run({ + ...createMockActionContext({ + propsValue: { + liveMarkdown: undefined, + syncMarkdown: undefined, + testMarkdown: undefined, + authType, + authFields, + }, + }), + webhookUrl: 'http://localhost:3000/api/v1/webhooks/flow-id', + payload, + }); +} + +type AuthCase = { + authType: AuthType; + authFields: Record; + goodHeaders: Record; + badHeaders: Record; +}; diff --git a/packages/server/engine/src/lib/operations/flow.operation.ts b/packages/server/engine/src/lib/operations/flow.operation.ts index 99cf6b385e33..49846d41b17f 100644 --- a/packages/server/engine/src/lib/operations/flow.operation.ts +++ b/packages/server/engine/src/lib/operations/flow.operation.ts @@ -193,6 +193,9 @@ async function runOrReturnPayload(input: ResolvedBeginExecuteFlowOperation, cons }, constants, }) as ExecuteTriggerResponse + if (newPayload.output.length === 0) { + throw new ExecutionError('EmptyTriggerPayload', 'The trigger returned no payload, so the flow did not run', ExecutionErrorType.USER) + } return newPayload.output[0] as TriggerPayload } diff --git a/packages/server/engine/test/operations/flow-operation-invariants.test.ts b/packages/server/engine/test/operations/flow-operation-invariants.test.ts index 4043cedf2f69..789c87179165 100644 --- a/packages/server/engine/test/operations/flow-operation-invariants.test.ts +++ b/packages/server/engine/test/operations/flow-operation-invariants.test.ts @@ -586,6 +586,25 @@ describe('flow operation invariants', () => { expect(finalCtx.verdict.failedStep.name).toBe('trigger_1') expect(finalCtx.steps.trigger_1.errorMessage).toEqual(expect.stringContaining('toLowerCase')) }) + + it('fails the run without executing any step when the trigger run() returns no payload', async () => { + mockSendUpdate.mockClear() + mockExecuteTrigger.mockResolvedValue({ output: [] }) + const operation = makeBeginOperation({ + flowVersion: makeFlowVersionWithTwoApprovals(), + triggerPayload: { type: 'inline', value: {} }, + executeTrigger: true, + }) + + const response = await flowOperation.execute(operation) + + expect(response.status).toBe(EngineResponseStatus.OK) + const finalCtx = mockSendUpdate.mock.calls[mockSendUpdate.mock.calls.length - 1][0].flowExecutorContext + expect(finalCtx.verdict.status).toBe(FlowRunStatus.FAILED) + expect(finalCtx.verdict.failedStep.name).toBe('trigger_1') + expect(finalCtx.steps.trigger_1.errorMessage).toEqual(expect.stringContaining('no payload')) + expect(finalCtx.steps.step_1).toBeUndefined() + }) }) describe('trigger success output shape', () => { diff --git a/packages/server/worker/src/lib/execute/jobs/execute-polling.ts b/packages/server/worker/src/lib/execute/jobs/execute-polling.ts index e0c4943ca6a7..80b57d4933ee 100644 --- a/packages/server/worker/src/lib/execute/jobs/execute-polling.ts +++ b/packages/server/worker/src/lib/execute/jobs/execute-polling.ts @@ -61,7 +61,7 @@ export const executePollingJob: JobHandler { +export async function recordTriggerRun({ apiClient, log, flowVersion, platformId, status, error: triggerError }: RecordTriggerRunParams): Promise { if (flowVersion.trigger.type !== FlowTriggerType.PIECE) { return } const pieceName = flowVersion.trigger.settings.pieceName - const triggerRunStatus = status === EngineResponseStatus.OK ? TriggerRunStatus.COMPLETED : TriggerRunStatus.FAILED + const succeeded = status === EngineResponseStatus.OK + if (!succeeded) { + log.warn({ error: tryParseFriendlyPieceError(triggerError)?.message, status, piece: { name: pieceName }, flow: { id: flowVersion.flowId }, flowVersion: { id: flowVersion.id } }, 'Trigger run did not complete successfully') + } + const triggerRunStatus = succeeded ? TriggerRunStatus.COMPLETED : TriggerRunStatus.FAILED const { error } = await tryCatch(() => apiClient.recordTriggerRun({ platformId, pieceName, status: triggerRunStatus })) if (error) { log.warn({ error: String(error), piece: { name: pieceName }, flowVersion: { id: flowVersion.id } }, 'Failed to record trigger run stats') @@ -20,4 +24,5 @@ type RecordTriggerRunParams = { flowVersion: FlowVersion platformId: string status: EngineResponseStatus + error?: string } diff --git a/packages/server/worker/test/lib/execute/utils/trigger-run-recorder.test.ts b/packages/server/worker/test/lib/execute/utils/trigger-run-recorder.test.ts index 18d63a0c2113..438502a849aa 100644 --- a/packages/server/worker/test/lib/execute/utils/trigger-run-recorder.test.ts +++ b/packages/server/worker/test/lib/execute/utils/trigger-run-recorder.test.ts @@ -1,4 +1,5 @@ import { EngineResponseStatus, FlowTriggerType, FlowVersion, FlowVersionState, LATEST_FLOW_SCHEMA_VERSION, TriggerRunStatus, WorkerToApiContract } from '@activepieces/shared' +import { formatPieceError } from '@activepieces/core-utils' import { describe, expect, it, vi } from 'vitest' import { recordTriggerRun } from '../../../../src/lib/execute/utils/trigger-run-recorder' @@ -72,4 +73,14 @@ describe('recordTriggerRun', () => { await expect(recordTriggerRun({ apiClient, log, flowVersion: buildPieceFlowVersion('@activepieces/piece-slack'), platformId: 'p1', status: EngineResponseStatus.OK })).resolves.toBeUndefined() }) + + it('logs only the failure message with the flow id, never the raw error', async () => { + const apiClient = { recordTriggerRun: vi.fn(async () => undefined) } as unknown as WorkerToApiContract + const engineError = JSON.stringify(formatPieceError(new Error('Webhook authentication failed'), { raw: 'headers: { Authorization: Bearer SECRET123 }' })) + + await recordTriggerRun({ apiClient, log, flowVersion: buildPieceFlowVersion('@activepieces/piece-webhook'), platformId: 'p1', status: EngineResponseStatus.USER_FAILURE, error: engineError }) + + expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ error: 'Webhook authentication failed', flow: { id: 'flow1' }, status: EngineResponseStatus.USER_FAILURE }), 'Trigger run did not complete successfully') + expect(JSON.stringify(vi.mocked(log.warn).mock.calls)).not.toContain('SECRET123') + }) }) diff --git a/packages/web/src/features/tables/components/cell-context.tsx b/packages/web/src/features/tables/components/cell-context.tsx index 8e6b98219f58..53d52fbbe418 100644 --- a/packages/web/src/features/tables/components/cell-context.tsx +++ b/packages/web/src/features/tables/components/cell-context.tsx @@ -44,11 +44,7 @@ export const CellProvider = ({ setIsEditing, disabled, }: CellProviderProps) => { - const [updateRecord, fields, records] = useTableState((state) => [ - state.updateRecord, - state.fields, - state.records, - ]); + const updateRecord = useTableState((state) => state.updateRecord); const focustContainer = () => { // need to refocus container so keyboard navigation between cells works // if it was done immediately, the cell would be blurred and call handleRowChange @@ -58,20 +54,8 @@ export const CellProvider = ({ }; const handleCellChange = (newCellValue: string) => { - const record = records[rowIdx]; - const newRecrodValues = fields.map((_, fIndex) => { - // values order isn't guaranteed to be the same as fields order - const fieldValue = record.values.find( - (value) => value.fieldIndex === fIndex, - )?.value; - return { - fieldIndex: fIndex, - value: fieldValue ?? '', - }; - }); - newRecrodValues[columnIdx].value = newCellValue; updateRecord(rowIdx, { - values: newRecrodValues, + values: [{ fieldIndex: columnIdx, value: newCellValue }], }); setIsEditing(false); focustContainer(); diff --git a/packages/web/src/features/tables/stores/store/ap-tables-client-state.tsx b/packages/web/src/features/tables/stores/store/ap-tables-client-state.tsx index b6b47e0712b2..df2ea95c8c53 100644 --- a/packages/web/src/features/tables/stores/store/ap-tables-client-state.tsx +++ b/packages/web/src/features/tables/stores/store/ap-tables-client-state.tsx @@ -169,10 +169,23 @@ export const createApTableStore = ( recordData: Pick, ) => { serverState.updateRecord(recordIndex, recordData); + const updatedFieldIndexes = new Set( + recordData.values.map((cell) => cell.fieldIndex), + ); return set((state) => { return { records: state.records.map((record, index) => - index === recordIndex ? { ...record, ...recordData } : record, + index === recordIndex + ? { + ...record, + values: [ + ...record.values.filter( + (cell) => !updatedFieldIndexes.has(cell.fieldIndex), + ), + ...recordData.values, + ].sort((a, b) => a.fieldIndex - b.fieldIndex), + } + : record, ), }; }); @@ -212,7 +225,15 @@ export const createApTableStore = ( return { records: state.records.map((record) => ({ ...record, - values: record.values.filter((_, index) => index !== fieldIndex), + values: record.values + .filter((cell) => cell.fieldIndex !== fieldIndex) + .map((cell) => ({ + ...cell, + fieldIndex: + cell.fieldIndex > fieldIndex + ? cell.fieldIndex - 1 + : cell.fieldIndex, + })), })), fields: state.fields.filter((_, index) => index !== fieldIndex), }; diff --git a/packages/web/test/features/tables/ap-tables-client-state.test.ts b/packages/web/test/features/tables/ap-tables-client-state.test.ts index dd73cbea1a0c..8859faaafea8 100644 --- a/packages/web/test/features/tables/ap-tables-client-state.test.ts +++ b/packages/web/test/features/tables/ap-tables-client-state.test.ts @@ -1,10 +1,17 @@ -import { Table, TableAutomationStatus } from '@activepieces/shared'; +import { + Field, + FieldType, + Table, + TableAutomationStatus, +} from '@activepieces/shared'; import { describe, expect, it, vi } from 'vitest'; import { createApTableStore } from '@/features/tables/stores/store/ap-tables-client-state'; +const serverUpdateRecord = vi.hoisted(() => vi.fn()); + vi.mock('@/features/tables/stores/store/ap-tables-server-state', () => ({ - createServerState: () => ({}), + createServerState: () => ({ updateRecord: serverUpdateRecord }), })); const table: Table = { @@ -33,4 +40,50 @@ describe('createApTableStore', () => { expect(listener).toHaveBeenCalledTimes(1); expect(store.getState().selectedCell).toEqual({ rowIdx: 1, columnIdx: 2 }); }); + + it('saves only the edited cell and keeps the rest of the row', () => { + const fields = ['a', 'b', 'c'].map( + (id, position): Field => ({ + id, + created: table.created, + updated: table.updated, + name: id, + externalId: id, + type: FieldType.TEXT, + tableId: table.id, + projectId: table.projectId, + position, + }), + ); + const cell = (fieldName: string, value: string) => ({ + created: table.created, + updated: table.updated, + value, + fieldName, + }); + const store = createApTableStore(table, fields, [ + { + id: 'record-1', + created: table.created, + updated: table.updated, + tableId: table.id, + projectId: table.projectId, + cells: { + c: cell('c', 'c-value'), + a: cell('a', 'a-value'), + b: cell('b', 'b-value'), + }, + }, + ]); + + const edit = { values: [{ fieldIndex: 0, value: 'a-edited' }] }; + store.getState().updateRecord(0, edit); + + expect(serverUpdateRecord).toHaveBeenCalledWith(0, edit); + expect(store.getState().records[0].values).toEqual([ + { fieldIndex: 0, value: 'a-edited' }, + { fieldIndex: 1, value: 'b-value' }, + { fieldIndex: 2, value: 'c-value' }, + ]); + }); }); diff --git a/packages/web/test/features/tables/cell-context.test.tsx b/packages/web/test/features/tables/cell-context.test.tsx new file mode 100644 index 000000000000..590bc475b112 --- /dev/null +++ b/packages/web/test/features/tables/cell-context.test.tsx @@ -0,0 +1,47 @@ +/** @vitest-environment jsdom */ +import { FieldType } from '@activepieces/shared'; +import { render } from '@testing-library/react'; +import { useEffect } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CellProvider, + useCellContext, +} from '@/features/tables/components/cell-context'; + +const updateRecord = vi.hoisted(() => vi.fn()); + +vi.mock('@/features/tables/components/ap-table-state-provider', () => ({ + useTableState: (selector: (state: { updateRecord: unknown }) => T) => + selector({ updateRecord }), +})); + +function CommitEdit({ value }: { value: string }) { + const { handleCellChange } = useCellContext(); + useEffect(() => handleCellChange(value), [handleCellChange, value]); + return null; +} + +describe('CellProvider', () => { + it('saves only the edited cell', () => { + render( + undefined} + handleCellChange={() => undefined} + disabled={false} + containerRef={{ current: null }} + > + + , + ); + + expect(updateRecord).toHaveBeenCalledWith(3, { + values: [{ fieldIndex: 1, value: 'new' }], + }); + }); +}); diff --git a/packages/web/test/features/tables/delete-middle-field.test.tsx b/packages/web/test/features/tables/delete-middle-field.test.tsx new file mode 100644 index 000000000000..6bf1bb0d4c69 --- /dev/null +++ b/packages/web/test/features/tables/delete-middle-field.test.tsx @@ -0,0 +1,195 @@ +/** @vitest-environment jsdom */ +import { + Field, + FieldType, + PopulatedRecord, + Table, + TableAutomationStatus, +} from '@activepieces/shared'; +import { render } from '@testing-library/react'; +import { useEffect } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CellProvider, + useCellContext, +} from '@/features/tables/components/cell-context'; +import { mapRecordsToRows } from '@/features/tables/components/table-columns'; +import { + createApTableStore, + type ApTableStore, +} from '@/features/tables/stores/store/ap-tables-client-state'; + +const harness = vi.hoisted(() => ({ + store: undefined as undefined | ApTableStore, +})); + +vi.mock('@/features/tables/stores/store/ap-tables-server-state', () => ({ + createServerState: ( + _table: unknown, + fields: unknown[], + records: unknown[], + ) => ({ + deleteField: vi.fn(), + createField: vi.fn(), + fields, + records, + }), +})); + +vi.mock('@/features/tables/components/ap-table-state-provider', () => ({ + useTableState: (selector: (state: unknown) => T) => + selector(harness.store!.getState()), +})); + +const table: Table = { + id: 'table-1', + created: '2026-09-16T00:00:00.000Z', + updated: '2026-09-16T00:00:00.000Z', + name: 'Table', + projectId: 'project-1', + externalId: 'table-1', + status: TableAutomationStatus.ENABLED, + trigger: null, +}; + +const buildField = (id: string, name: string, position: number): Field => ({ + id, + created: '2026-09-16T00:00:00.000Z', + updated: '2026-09-16T00:00:00.000Z', + name, + externalId: id, + type: FieldType.TEXT, + tableId: table.id, + projectId: table.projectId, + position, +}); + +const buildCell = (value: string, fieldName: string) => ({ + created: '2026-09-16T00:00:00.000Z', + updated: '2026-09-16T00:00:00.000Z', + value, + fieldName, +}); + +const fields = [ + buildField('field-a', 'A', 0), + buildField('field-b', 'B', 1), + buildField('field-c', 'C', 2), +]; + +const records: PopulatedRecord[] = [ + { + id: 'record-1', + created: '2026-09-16T00:00:00.000Z', + updated: '2026-09-16T00:00:00.000Z', + tableId: table.id, + projectId: table.projectId, + cells: { + 'field-c': buildCell('c-value', 'C'), + 'field-a': buildCell('a-value', 'A'), + 'field-b': buildCell('b-value', 'B'), + }, + }, +]; + +const createStore = () => createApTableStore(table, fields, records); + +const cellsByIndex = (store: ApTableStore) => + [...store.getState().records[0].values].sort( + (a, b) => a.fieldIndex - b.fieldIndex, + ); + +describe('deleteField remaps surviving cells (ENG-537)', () => { + it('keeps the values of every column to the right of the deleted one', () => { + const store = createStore(); + + store.getState().deleteField(1); + + expect(store.getState().fields.map((field) => field.name)).toEqual([ + 'A', + 'C', + ]); + expect(cellsByIndex(store)).toEqual([ + { fieldIndex: 0, value: 'a-value' }, + { fieldIndex: 1, value: 'c-value' }, + ]); + }); + + it('still renders the shifted column in the grid', () => { + const store = createStore(); + + store.getState().deleteField(1); + + const state = store.getState(); + const [row] = mapRecordsToRows(state.records, state.fields); + expect(row['field-a']).toBe('a-value'); + expect(row['field-c']).toBe('c-value'); + }); + + it('does not off-by-one when the last column is deleted', () => { + const store = createStore(); + + store.getState().deleteField(2); + + expect(store.getState().fields.map((field) => field.name)).toEqual([ + 'A', + 'B', + ]); + expect(cellsByIndex(store)).toEqual([ + { fieldIndex: 0, value: 'a-value' }, + { fieldIndex: 1, value: 'b-value' }, + ]); + }); + + it('leaves no index collision when a column is added after a middle delete', () => { + const store = createStore(); + + store.getState().deleteField(1); + store + .getState() + .createField({ uuid: 'field-d', name: 'D', type: FieldType.TEXT }); + + expect(cellsByIndex(store).map((cell) => cell.fieldIndex)).toEqual([ + 0, 1, 2, + ]); + }); + + it('does not overwrite the shifted column when a cell is edited afterwards', () => { + const store = createStore(); + harness.store = store; + store.getState().deleteField(1); + + const updateRecord = vi.fn(); + store.setState({ updateRecord }); + + function Editor() { + const { handleCellChange } = useCellContext(); + useEffect(() => { + handleCellChange('a-edited'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; + } + + render( + undefined} + handleCellChange={() => undefined} + disabled={false} + containerRef={{ current: null }} + > + + , + ); + + expect(updateRecord).toHaveBeenCalledWith(0, { + values: [{ fieldIndex: 0, value: 'a-edited' }], + }); + }); +});