Skip to content

feat: run the Playwright test healer on demand on PRs [ED-25918] - #65

Merged
Svitlana-Dykun merged 6 commits into
mainfrom
feat/ED-25918-pr-test-healer
Nov 3, 2026
Merged

Svitlana-Dykun merged 6 commits into
mainfrom
feat/ED-25918-pr-test-healer

Conversation

@Svitlana-Dykun

@Svitlana-Dykun Svitlana-Dykun commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds PR mode to the Playwright test healer: a reusable .github/workflows/playwright-pr-test-healer.yml that Core and Pro call from a thin workflow when the heal label is added to a PR (ED-25918).

Every test that failed in the PR's latest Playwright run gets exactly one verdict, with a reason and evidence, in one comment on the PR (updated in place on each run):

Verdict When
❌ Broken by this PR Linked to the diff before anything runs: the PR changes its spec, snapshots, a file it imports (transitively), the Playwright config/setup, the module it covers, or a selector/text from its error in product code. Never re-run or fixed.
🔁 Flaky Not linked, and passed at least 1 of 3 re-runs on a fresh PR build.
🟠 Already failing on main Failed all 3 re-runs on the PR and all 3 on a fresh main build.
👀 Needs a human look Failed every re-run on the PR but not on main, or the healer could not finish checking it.

Flaky tests: one Cloud agent per test (existing prompt + skill, starting from main), each fix verified 10x on the main build with the existing scope check, then the verified fixes are merged into one branch and opened as one PR against main — never pushed to the author's branch. The comment links it; Slack announces it. No failures → "nothing to heal", no PR. The label is removed at the end so re-adding it starts a new run.

Pieces

  • scripts/find-pr-playwright-run.js — newest PR-workflow run on the head SHA that ran Playwright, excluding the run the labeled event itself starts (pr.yml listens to labeled).
  • scripts/collect-pr-failures.js — every hard failure, with shard, result dirs, spec from error-context.md, error text.
  • scripts/triage-pr-failures.js — diff linkage + evidence lines.
  • scripts/rerun-pr-failures.js — N re-runs per test on its shard's command, retries off.
  • scripts/pr-healer-report.js — verdicts, PR comment, fix-PR body, Slack payload.
  • scripts/open-pr-fix-pr.js — combines verified fixes into one PR.
  • Profiles gain prWorkflow and moduleRoots; dispatch-cloud-agent.js takes an optional HEAL_CONTEXT.

Safety

  • Fork PRs are refused (re-runs execute PR code next to the Pro licence).
  • Builds happen in jobs with no secrets and no cache saves; agent code runs only in verify-fix (read token + licence).
  • Off by default: TEST_HEALER_ENABLED must be manual or all.
  • Limits: 20 re-runs and 5 fixes per PR (inputs).

Testing

✨ PR Description

1. Problem & Context

Implements on-demand Playwright test healing for PRs (ED-25918) to differentiate between regressions and flakes. This allows developers to trigger a triage and automated fix process via a GitHub label.

2. What Changed (Where)

File/Module Change
.github/workflows/playwright-pr-test-healer.yml Orchestrates triage, re-runs, agent dispatch, verification, and reporting.
scripts/triage-pr-failures.js Links failures to PR diffs via spec imports, modules, and error tokens.
scripts/rerun-pr-failures.js Executes targeted re-runs on PR and baseline builds to identify flakes.
scripts/collect-pr-failures.js Extracts hard failures and error context from artifacts and logs.
scripts/find-pr-playwright-run.js Locates the correct historic run to analyze failures.
scripts/open-pr-fix-pr.js Merges verified fixes into a single PR against the target branch.
scripts/pr-healer-report.js Generates the PR summary comment, fix PR body, and Slack payload.
scripts/dispatch-cloud-agent.js Enhances prompts with untrusted data warnings and capped context.
scripts/unpack-healer-build.sh Safely unpacks build artifacts with path validation.
profiles/*.json Updated with prWorkflow and moduleRoots for triage mapping.
tests/*.test.js Comprehensive unit tests for all new scripts and logic.

3. How It Works

The workflow is triggered by a label, initiating a pipeline that:

  1. Triage: Analyzes the PR diff to mark tests as "Broken by PR" (linked) or candidates for re-run.
  2. Verification: Re-runs unlinked tests on PR and base branches; failures passing once are marked "Flaky."
  3. Healing: Dispatches Cursor agents to fix flakes on the target branch, verifies those fixes, and opens a consolidated fix PR.
  4. Reporting: Posts a verdict table (Broken, Flaky, Already Failing, Needs Human) to the original PR.

4. Risks

  • Prompt Injection: Mitigation includes treating evidence as "untrusted data" and sanitizing test titles in prompts.
  • Artifact Forgery: Mitigation includes strict path validation in unpack-healer-build.sh and verifying commit SHAs before merging fixes.

Generated by LinearB AI and added by gitStream.
AI-generated content may contain inaccuracies. Please verify before using.
💡 Tip: You can customize your AI Description using Guidelines Learn how

Adds a reusable playwright-pr-test-healer.yml that Core and Pro call when
the `heal` label is added to a PR. Every failed test in the PR's latest
Playwright run gets one verdict in a single PR comment: broken by this PR
(linked to the diff, never re-run), flaky (passed a re-run; fixed by a
Cloud agent, verified on main, and opened as one PR against main), already
failing on main, or needs a human look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +220 to +228
- name: Check out the healer
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: actions/playwright-test-healer
persist-credentials: false
- uses: actions/setup-node@v6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

Comment on lines +287 to +295
- name: Check out the PR head
if: steps.failures.outputs.failure_count != '0' && steps.failures.outputs.failure_count != ''
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-input.outputs.head_sha }}
path: pr
persist-credentials: false

- name: List the PR's changed files

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended, and read-only: the PR head is checked out only so triage can read its spec files and imports. Nothing from it is executed in this job, and credentials are not persisted.

Comment on lines +344 to +355
- name: Check out the healer
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: |
actions/playwright-test-healer
actions/resolve-core-nightly
persist-credentials: false

- uses: actions/setup-node@v6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

Comment on lines +425 to +430
- uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-input.outputs.head_sha }}
persist-credentials: false

- name: Check out the healer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: re-running the PR's failed tests means running the PR's code, as its own pr.yml run does with the same licence. Only same-repository PRs reach this job (writers only); the job has a read token, no persisted credentials and no cache saves.

Comment on lines +626 to +634
- name: Check out the healer
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: actions/playwright-test-healer
persist-credentials: false
- uses: actions/setup-node@v6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

Comment on lines +750 to +757
- uses: actions/checkout@v6
if: steps.agent.outputs.branch != ''
with:
ref: ${{ steps.agent.outputs.branch }}
fetch-depth: 0
persist-credentials: false

- name: Check out the healer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: this verifies the agent's fix branch, which the healer itself had the Cloud agent push (heal/..., name validated in the step above). Same trust model as the nightly healer's verify-fix: read token, no persisted credentials, no caches, and open-fix-pr re-checks the branch is still at the verified SHA before opening a PR, which then goes through normal review.

Comment on lines +757 to +767
- name: Check out the healer
if: steps.agent.outputs.branch != ''
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: actions/playwright-test-healer
persist-credentials: false

- name: Enforce fix scope

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

Comment on lines +936 to +944
- name: Check out the healer
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: actions/playwright-test-healer
persist-credentials: false
- uses: actions/setup-node@v6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

Comment on lines +1000 to +1008
- name: Check out the healer
uses: actions/checkout@v6
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .healer
sparse-checkout: actions/playwright-test-healer
persist-credentials: false
- uses: actions/setup-node@v6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this checks out the healer's own scripts from this workflow's commit (job.workflow_repository at job.workflow_sha), not the pull request. Same pattern as the nightly healer, whose identical alerts are open on main.

@wiz-9a149474ff

wiz-9a149474ff Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 21 Medium 22 Low
Software Management Finding Software Management Findings -
Total 21 Medium 22 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

…ED-25918]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Svitlana-Dykun and others added 3 commits October 30, 2026 15:12
… [ED-25918]

Bots merge main into PR branches, leaving the head on a commit with no
finished run; the healer now falls back to the newest finished run on the
branch and names that commit in its comment. `label` is a jq keyword, which
broke removing the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ED-25918]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…up sources [ED-25918]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
davseve
davseve previously approved these changes Nov 2, 2026

@Ntnelbaba Ntnelbaba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed write-up. This is large (about 4.5k lines), so it would be easier to review and safer to land in three parts: (1) the shared pieces (profile fields, the rank-nightly-failures / run-healer-attempts exports, HEAL_CONTEXT in dispatch-cloud-agent); (2) the PR healer scripts and their tests; (3) the workflow YAML on its own, since that is where the security-sensitive wiring is.

npx vitest run passes (327 tests) and root lint is clean.

Findings on lines outside the diff hunks, or without a single line:

  • .github/workflows/playwright-pr-test-healer.yml: Medium: There is no top-level concurrency: group keyed by PR number. Two overlapping heal runs can race on comment upsert (tail -1 on matching comments), duplicate agent dispatches, and push competing heal/pr-* branches. Add concurrency: group: pr-healer-${{ inputs.pr_number }}, cancel-in-progress: false (or true, if you prefer superseding).
  • actions/playwright-test-healer/scripts/open-pr-fix-pr.js: Nit: No unit tests for mergeVerifiedFixes, branch move detection, or conflict abort paths—only readOutcomes is exported/tested indirectly. Add tests with a fake git/gh layer.
  • actions/playwright-test-healer/tests/find-pr-playwright-run.test.js: Nit: No test for main() integration: workflow file name from profile, pagination gap, or head-SHA wait vs older completed run—only unit tests for helpers.
  • actions/playwright-test-healer/tests/pr-healer-report.test.js: Nit: Covers verdict matrix and Slack/fix body well, but no test for comment upsert marker edge cases or resolvePrVerdicts when mainRerun is empty JSON vs missing file (report job artifact merge).

if: inputs.product == 'pro'
timeout-minutes: 30
env:
E2E_PRO_LICENSE: ${{ secrets.E2E_PRO_LICENSE }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High (security): rerun-on-pr checks out the PR head and runs setup-pro-playwright.sh with E2E_PRO_LICENSE in the environment, so arbitrary PR branch code can read the licence. That matches the stated same-repo-only model, but callers should treat this like full privileged E2E CI: restrict who can push branches and add heal, and keep a non-production licence as documented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change: this is policy, not code. It is the same exposure the PR's own pr.yml run has with the licence. The workflow header now states it: anyone who can push a branch and add the label can reach the licence, so pass a non-production one.

rerun-on-pr:
name: Re-run on the PR build
needs: [resolve-input, collect, prepare-builds]
if: always() && needs.collect.outputs.has_reruns == 'true'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security): rerun-on-pr uses if: always() && needs.collect.outputs.has_reruns == 'true' and does not require needs.prepare-builds.result == 'success'. If the PR build job failed, this job still starts, fails on missing healer-builds-pr, and burns runner time while relying on PR_RERUN_RESULT for verdicts. Gate on needs.prepare-builds.result == 'success' (and optionally skip when prepare-builds was cancelled).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: rerun-on-pr and the baseline re-runs now require needs.prepare-builds.result == 'success'. A failed build leaves the unlinked failures as needing a human look.

rerun-on-pr:
name: Re-run on the PR build
needs: [resolve-input, collect, prepare-builds]
if: always() && needs.collect.outputs.has_reruns == 'true'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Same condition lets plan-fixes / fix-agent / verify-fix proceed when flaky JSON outputs exist but main/PR build artifacts never uploaded, producing long failing verify paths instead of a clear early exit. Consider requiring successful prepare-builds anywhere tests or licences run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: plan-fixes and everything after it depend on has_flaky, which is only set when rerun-on-pr ran, and that job now requires a successful build.

- name: Collect the failures
id: failures
if: steps.run.outputs.found == 'true'
run: node "$HEALER_DIR/scripts/collect-pr-failures.js"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security): The collect job downloads playwright-test-results-* artifacts from the source CI run (produced under PR/test code) and feeds error-context.md into triage. A crafted error string could steer token-based linkage (false broken vs false flake). Risk is verdict quality, not RCE; mitigations already exclude snapshot sections—consider capping token matches to quoted locator strings only, or ignoring tokens not present in the # Error details block after sanity checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed in 4e6d0be: tokens now match whole words only, which narrows what a crafted error string can link. I did not restrict them to quoted locators. As you say the risk is verdict quality, and a linked failure is only labelled, not acted on.

evidence.push(fileEvidence(first, changedFiles));
}

const globalChanges = [...changed].filter(isGlobalTestFile);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Any change to a GLOBAL_TEST_FILES path links every failure as broken by this PR. Touching shared Playwright config on a large PR will skip re-runs for all other failures. Worth a comment in the PR table or splitting global-config linkage into a softer signal unless you truly want a hard stop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change: this is deliberate. A change to the shared config can break any test, and re-running would risk a real regression looking flaky. The reason in the comment names the file, so the author can see why every failure was linked.

return { error: 'The test could not be found to re-run it.' };
}

if (listed > MAX_LISTED_MATCHES) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Re-run aborts when --list matches more than 3 tests, marking the test as needs-human via rerun.error. Document that shared titles across specs require a unique title or a known spec file in error-context Location.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change: the reason in the PR comment already says the title matches N tests and can't be re-run on its own, so the verdict is needs a human look.

? `Failed shard: ${shardIndex} — the evidence is in that run's playwright-test-results artifact for this shard ("playwright-test-results-${shardIndex}", named as the Repository notes describe). Download that one; do not guess from the Allure title.`
: 'Failed shard: unknown — find the failed job in that run and download its playwright-test-results-* artifact.',
`Matching test-results directories inside that artifact to inspect for error-context.md/trace.zip: ${matchedDirs}`,
...(context ? ['', context] : []),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Optional HEAL_CONTEXT is appended to the agent prompt with no length cap; a huge context env could bloat dispatch or hit API limits. Truncate in buildPrompt (e.g. 4k chars) with a note that details are in the linked run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: context is capped at 4,000 characters, with a note pointing to the evidence run.

# One agent per flaky test, each on its own branch off main. Nothing from
# the agent runs here: this job holds the Cursor key.
fix-agent:
name: Fix ${{ matrix.fix.testName }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Matrix job name Fix ${{ matrix.fix.testName }} can break Actions UI or logs when titles contain : or very long strings. Use matrix.fix.index in the name and keep the title in step output only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: the matrix jobs are now named by index (Fix flaky test N, Verify fix N).

flattenAllureFailures,
hasTraceInDirectory,
matchResultDirectories,
readHardFailureSource,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Exporting readHardFailureSource / readResultDirs is a pure refactor for PR collect—no nightly behavior change—but nightly and PR now share ranking helpers; add a one-line comment in rank-nightly-failures.js that PR healer depends on these exports so refactors run both test suites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: added the note above the exports.

echo "base_sha=$(jq -r '.base.sha' <<< "$PR")"
echo "base_ref=$(jq -r '.base.ref' <<< "$PR")"
echo "pr_url=$(jq -r '.html_url' <<< "$PR")"
echo "main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq '.sha')"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: The comparison baseline is always main (commits/main here), not the PR's base branch. base_ref/base_sha are read on L191-192 but only used for triage and the PR build leg. The non-PR build leg (L341-345, L374, L381), rerun-on-main (L509-564), the agents (HEAL_STARTING_REF/HEAL_BASE_REF: main, L670/L690), verify (BASE_REF: main, L778) and the fix PR (L939, open-pr-fix-pr.js L74/L150-151) all target main. For a PR into release/*, a failure can be labelled 'already failing on main' when it is new on the release line (or the reverse), and flaky fixes get verified and opened against the wrong branch. Nothing refuses non-main bases either.

Until releases move to main-only, this should check both: when base_ref != 'main', add a build and re-run leg on base_sha next to the main one, let the verdict say which baseline(s) it failed on, and pass the fix target ref (the PR base) through the agent env, verify-fix, gh pr list --base (L595, pr-healer-report.js L599) and open-pr-fix-pr.js --base. When the base is main, the current single leg is enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: resolve-input now builds the baselines: main always, plus the PR's base when it isn't main. A PR into a release branch is judged against its base, and the reason says what main did. The agent, verify, gh pr list --base and fix PR all use that target. check_pr_base: 'false' goes back to main only, for when PRs only target main. Unit tests only so far, not run live yet.

@Ntnelbaba Ntnelbaba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more findings on how artifacts and agent input are trusted. The first one should be fixed before the healer is enabled.

  • Info: the head SHA is resolved when the workflow starts rather than pinned at label time, so a push between labelling and resolve-input changes what gets healed. Worth noting in the PR comment which SHA was used.


return {
testName: fix.testName,
branch: agent?.branch || '',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security): open-fix-pr trusts the branch, SHA and pass counts read from the pr-healer-agent-N / pr-healer-verify-N artifacts. Those names are predictable, and PR code running earlier in the same workflow run (the pr leg of prepare-builds, rerun-on-pr) can reach ACTIONS_RUNTIME_TOKEN and upload artifacts with those names first. A same-repo author could then get the bot to merge an arbitrary branch into a "verified" fix PR to main, and opened with GITHUB_TOKEN, that PR triggers no CI. Before merging, rebuild the expected heal/pr-<N>-<slug>-<run_id>-<index> name from trusted inputs and require an exact match, fetch by the verified SHA and assert FETCH_HEAD equals it, and re-run check-fix-scope.js against origin/main here. Please fix this before TEST_HEALER_ENABLED is turned on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: the branch must match the one rebuilt from the plan, which is a job output rather than an artifact. The commit is fetched by the verified SHA and FETCH_HEAD must equal it, and check-fix-scope.js runs again against the target. Remaining limit: a forged record can still claim the test passed, so review of the fix PR stays mandatory. Unit tests only so far.


for (const outcome of merged) {
try {
git(['push', 'origin', '--delete', outcome.branch]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (security): branch cleanup should only delete branches that match the exact expected heal/pr-… pattern. Otherwise a poisoned outcome artifact can name an unprotected branch to delete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: cleanup only uses the branch from the plan, never one named in an artifact.

@@ -135,6 +137,7 @@ function buildPrompt({
? `Failed shard: ${shardIndex} — the evidence is in that run's playwright-test-results artifact for this shard ("playwright-test-results-${shardIndex}", named as the Repository notes describe). Download that one; do not guess from the Allure title.`
: 'Failed shard: unknown — find the failed job in that run and download its playwright-test-results-* artifact.',
`Matching test-results directories inside that artifact to inspect for error-context.md/trace.zip: ${matchedDirs}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security): test names, matched dirs and error-context.md text from the PR's own run go straight into the agent prompt (also workflow L673-678). Crafted text can steer the agent, for example into adding a helper under tests/playwright/ that still passes the scope check. Mark this block in the prompt as untrusted data the agent must not follow as instructions, and keep human review of the fix PR mandatory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: the prompt now marks the test title, directory names and everything in the evidence run as untrusted data not to be followed, and titles and names are kept on one line. This line is also in the nightly healer's prompt, since they share buildPrompt. It lowers the risk rather than removing it, so review of the fix PR stays mandatory.

path: ${{ runner.temp }}/healer-builds

- name: Unpack the build
run: tar -xzf "$RUNNER_TEMP/healer-builds/healer-builds.tgz"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (security): the healer-builds-main tarball is unpacked into the workspace root (also L804). A poisoned artifact with the same name could overwrite files and forge results. Unpack into RUNNER_TEMP with a path allowlist or check a hash recorded by the trusted build job.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: unpack-healer-build.sh checks every path against an allowlist, unpacks to a temp folder, then moves only the allowed entries. It checks paths only, not a hash, so a replaced build with valid paths would still get through. That code would have the same access the PR's code already has in those jobs.

outputs:
fix_pr_url: ${{ steps.open.outputs.fix_pr_url }}
steps:
- uses: actions/checkout@v6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (security): this checkout keeps the default persist-credentials, unlike every other checkout here. Set persist-credentials: false and pass the token only to the push step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: persist-credentials: false. The token goes only to the git calls that reach the remote (ls-remote, fetch, push), not just the push, because private repos need it for those too.

exit 1
fi
for N in "$MAX_RERUNS" "$MAX_FIXES" "$VERIFY_REPEAT"; do
if ! [[ "$N" =~ ^[0-9]{1,2}$ ]]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: verify_repeat accepts 0, which would mark a fix as verified without running it. Validate it as at least 1, like the other limits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: verify_repeat must now be 1-99.

}

function buildSlackPayload({ repo, prNumber, prUrl, fixPrUrl, fixes }) {
const tests = fixes.map((fix) => `• \`${fix.testName}\``).join('\n');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: testName goes into the Slack mrkdwn unescaped, so &, <, > or backticks in a test title can break the message or inject links. Escape them before building the payload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e6d0be: &, <, > and backticks in test titles are now escaped. Covered by a test.

…ed artifacts [ED-25918]

A PR into a release branch is now judged against that branch as well as
checked on main, and its fixes go there. check_pr_base=false goes back to
main only, for when PRs only target main.

The fix PR step no longer trusts the agent and verify artifacts: the branch
must match the plan, the fetched commit must be the verified SHA, and the
scope check runs again. Also marks evidence as untrusted in the agent prompt,
checks build archive paths before unpacking, keeps credentials out of the
fix PR checkout, refuses verify_repeat=0, escapes Slack text, matches error
tokens as whole words, and skips re-runs when a build failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +382 to +387
- uses: actions/checkout@v6
with:
ref: ${{ matrix.build.sha }}
persist-credentials: false

- name: Check out the healer
Comment on lines +550 to +555
- uses: actions/checkout@v6
with:
ref: ${{ matrix.baseline.sha }}
persist-credentials: false

- name: Check out the healer
Comment on lines +970 to +977
- uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-input.outputs.target_ref }}
fetch-depth: 0
# The script gives the token to the git calls that reach the remote.
persist-credentials: false

- name: Check out the healer
@Svitlana-Dykun

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The fixes are in 4e6d0be, and I've replied on each inline comment with what changed or why it didn't.

Your two big points

  • Splitting: I'm keeping this as one PR. 4e6d0be holds only the review fixes, so it can be reviewed on its own.
  • Starting automatically when Playwright fails: it stays label-only for now.

Findings without a line

  • Concurrency: no change. Both callers set job-level concurrency keyed by PR number (elementor/elementor-pro#7876, Internal: Run the Playwright test healer on demand on PRs [ED-25918] elementor#37907).
  • Tests for open-pr-fix-pr.js: added, using injected git functions. They cover branch moves, a wrong fetched commit, scope failures, conflicts and forged records.
  • find-pr-playwright-run main() test: not added. Pagination is changed, but it has no unit test.
  • Comment marker and empty vs missing main re-run tests: not added.
  • Head SHA: the comment footer now names the commit that was checked.

Testing: 348 unit tests pass. The live run on elementor/elementor-pro#7860 came before these changes, so the base-branch check, the artifact checks and the unpack check have only unit tests so far.

@Ntnelbaba Ntnelbaba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 4e6d0be. The base-branch check, the build gate, the artifact checks and the escaping fixes look right, and the 348 unit tests pass.

Still open from the earlier reviews:

  • Security: the Pro licence reaching PR code in rerun-on-pr (documented, not changed); prompt injection into the agent, which is mitigated but not removed; a forged verify record can still claim a pass, so review of the fix PR stays the gate.
  • Partly fixed: collect artifact trust in triage, and label removal on disabled/fork/error/no-run.
  • Test gaps: find-pr-playwright-run main() and pagination, and comment marker plus empty-vs-missing main re-run in pr-healer-report.
  • Splitting the PR.

rerun-on-pr:
name: Re-run on the PR build
needs: [resolve-input, collect, prepare-builds]
if: always() && needs.collect.outputs.has_reruns == 'true' && needs.prepare-builds.result == 'success'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: needs.prepare-builds.result == 'success' is false when any matrix leg fails. On a release-branch PR there are now three builds, so a failing main build (which only adds a side note there) skips the PR re-runs and every baseline, and every unlinked failure ends up as needing a human look. Gate each job on the build it actually uses, e.g. per-build outputs or checking for the artifact, and let a missing baseline build become an error on that baseline only.

PR=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")
HEAD_REPOSITORY=$(jq -r '.head.repo.full_name // ""' <<< "$PR")
HEAD_SHA=$(jq -r '.head.sha' <<< "$PR")
BASE_SHA=$(jq -r '.base.sha' <<< "$PR")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: The base baseline uses the PR's base.sha, while main uses the live tip. base.sha can lag behind the branch, but the agent branches from target_ref and the fix PR targets its tip, so verify-fix can test against a different base than the fix lands on. Resolve it with commits/${BASE_REF} like main.

BASELINES=$(jq -cn --arg sha "$MAIN_SHA" '[{key: "main", ref: "main", sha: $sha}]')
TARGET_REF=main
TARGET_KEY=main
if [ "$CHECK_PR_BASE" = "true" ] && [ "$BASE_REF" != "main" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Any value of check_pr_base other than exactly true (True, yes, a typo) quietly switches to main only. Validate it as true or false in the input check step, like the other inputs.

HEAL_BRANCH_NAME: ${{ matrix.fix.branch }}
HEAL_SHARD_INDEX: ${{ matrix.fix.shardIndex }}
HEAL_MATCHED_DIRS: ${{ matrix.fix.matchedDirs }}
HEAL_STARTING_REF: ${{ needs.resolve-input.outputs.target_ref }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: For a PR into a release branch, the flaky fix goes only to that branch. If the same flake exists on main, nothing fixes it there while both are supported. Say so in the PR comment and the fix PR body, or note it as a known limit in the header.

fi

UNPACKED=$(mktemp -d "${RUNNER_TEMP:-/tmp}/healer-build.XXXXXX")
tar -xzf "$ARCHIVE" -C "$UNPACKED"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (security): The check covers paths only, so symbolic links pass it. An archive holding build/x -> /etc (or a link to $HOME) unpacks and is moved into the workspace as is. Reject any entry that is not a regular file or a directory, either from tar -tvzf or with find "$UNPACKED" ! -type f ! -type d before the move, and add a symlink case to unpack-healer-build.test.js.

@Svitlana-Dykun
Svitlana-Dykun merged commit 6a6f0c6 into main Nov 3, 2026
19 of 20 checks passed
@Svitlana-Dykun
Svitlana-Dykun deleted the feat/ED-25918-pr-test-healer branch November 3, 2026 09:03
Svitlana-Dykun added a commit to elementor/elementor that referenced this pull request Nov 3, 2026
…37907)

## What

Adds `.github/workflows/pr-test-healer.yml`: adding the `heal` label to
a PR calls the shared [Playwright PR test
healer](elementor/elementor-editor-github-actions#65)
([ED-25918](https://elementor.atlassian.net/browse/ED-25918)).

Every test that failed in the PR's latest Playwright run gets one
verdict — ❌ broken by this PR, 🔁 flaky, 🟠 already failing on main, or 👀
needs a human look — with a reason and evidence, in one comment on the
PR. Tests linked to the PR's diff are never re-run or changed. Flaky
tests are fixed by an agent, verified on main, and opened as a separate
PR against `main`, never pushed to the PR's branch. The label is removed
when the run ends; add it again to re-run.

- Fork PRs are skipped.
- Job-level concurrency per PR, so adding another label does not cancel
a heal in progress.
- Identical to the Pro caller (elementor/elementor-pro#7876) except
`product` and the Pro licence secret.

## Before it works

- [ ] elementor/elementor-editor-github-actions#65 merged (this calls it
at `@main`).
- [ ] `TEST_HEALER_ENABLED` is `manual` or `all` (already used by the
nightly healer).
- [ ] A `heal` label exists.
- [ ] Confirm the Slack channel (`C0C0TEZN45C`, the nightly healer's) is
#flaky-fixes.
[ED-25918]:
https://elementor.atlassian.net/browse/ED-25918?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

## Visual proof

#skip_proof
This PR only adds a GitHub Actions workflow; there is no editor UI to
verify on Playground.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: ElementorBot <48412871+elementorbot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants