@W-23385030@ feat(e2e): run Playwright specs against the Code Builder image - - #7718
Draft
jonnyhork wants to merge 25 commits into
Draft
@W-23385030@ feat(e2e): run Playwright specs against the Code Builder image - #7718jonnyhork wants to merge 25 commits into
jonnyhork wants to merge 25 commits into
Conversation
…385030 Add a container e2e harness that drives the desktop extension build over a browser at the real code-builder-images code-server runtime, swapping freshly -built (unreleased) monorepo VSIX in at runtime so specs catch bugs before marketplace publish. - playwright-vscode-ext: createContainerConfig + createContainerTest - core: containerFixtures, seed container spec, config, test:container script - workflow codeBuilderE2E.yml (dispatch/call) + swap/verify scripts - ADR 0022 and doc updates
…85030 The App ID is a public identifier, not a credential — move it to an org variable (vars.CB_GHCR_APP_ID). Only the App private key stays a secret.
…W-23385030 Match the desktop e2e leaves: test the artifacts about to ship, not a rebuild. The Code Builder workflow now takes runId + artifactName inputs and downloads the VSIX from an upstream Build All run, then swaps those into the container. Version gate asserts the shipping version. Docs/ADR updated to match.
…- W-23385030 The workbench-poll loops sleep 2s per iteration but logged the iteration counter as seconds. Multiply by 2 so "up after Ns" is real elapsed time.
…e workflow Revert before merge.
…sion registration
…prefix collisions
…out of Restricted Mode
…5030 Port the three Code Builder e2e helpers from bash to ts-node scripts in scripts/, matching the repo's script convention. Behavior is unchanged: same pull -> swap -> restart -> version-gate -> run pipeline. - scripts/codeBuilderLocalE2E.ts one-command local loop (test:container:local) - scripts/codeBuilderSwapExtensions.ts runtime extension swap - scripts/codeBuilderVerifyExtensions.ts version gate The workflow swap/gate steps and the test:container:local npm script now invoke the .ts files via ts-node. Uses execFileSync with arg arrays instead of shell interpolation, removing the shell-injection surface on the docker/gh/sf calls.
The local Code Builder e2e loop no longer leans on each dev's gh credential (with a read:packages scope refresh) to pull the private CB image. It now fetches a shared SVC_IDEE bot pull token from 1Password via the op CLI, so there's no per-dev PAT to manage and no browser scope-refresh flow. - Preflight requires op (1Password CLI); collects it alongside docker in the consolidated missing-prereqs report with a copy-paste install command. - Token resolves at runtime from OP_GHCR_ITEM (SVC_IDE_BOT_GHCR_READ_TOKEN in the "Platform Dev Tools Team" vault), never landing in the repo. Overridable via OP_GHCR_ITEM / OP_ACCOUNT. - CR_PAT stays as the escape hatch for devs who prefer their own classic PAT. - gh is now only needed for the --run-id CI-artifact path, not the image pull. CI is unchanged — it still pulls with the repo GITHUB_TOKEN.
…- W-23385030 The extension VSIX version isn't release-bumped, so it matches the marketplace build too — the semver alone can't tell a reader whether the assets under test are shipping bytes or the unreleased pre-release build. CI: the "List VSIX under test" step becomes "Provenance of VSIX under test", resolving the source Build All runId via `gh run view` and printing a banner (workflow, trigger, branch, commit + title, build timestamp, run URL) before the file listing. Local loop: --run-id logs the same upstream-run provenance; a working-tree build logs the current git branch + HEAD (flagging a dirty tree). Both note the semver is not release-bumped.
…ns - W-23385030 The runtime extension swap and the version gate both lean on extension semver, which is fragile in two ways: the swap rides code-builder-images' strictly-newer relink rule (a contract that team owns), and the gate compares installed semver against the artifact semver rather than bytes — so an unreleased build sharing a version with the baked marketplace copy can false-green if the swap silently no-ops. Records the unconditional-wipe option (drops the semver-precedence dependency) and the residual content-identity gap (needs a content hash or build stamp) as Considered Options / Consequences. No mechanism change in this PR.
jonnyhork
marked this pull request as draft
July 10, 2026 18:11
…W-23385030 The workbench opens whatever the image's sfdx-setup.sh seeds on first boot (SFDX_COBU_PROJECTNAME -> bare generated project; the GitHub-clone branch is dormant), gated behind ~/.codebuilder so it never re-runs on our swap restart. Nothing is seeded today, so specs needing real metadata have nothing to drive. Records two paths: create metadata mid-spec via extension commands, or the candidate volume-mount of an in-repo fixture project (writing coder.json via docker exec, decoupled from the image's first-boot SFDX_COBU_* env path). Notes the disable-trust / workbench-wait must track the opened folder. No code change.
…roject - W-23385030
…ub user - W-23385030
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@W-23385030@
This adds an e2e harness that runs our Playwright specs against the real Code Builder (Agentforce Vibes) image, so we can catch bugs in the desktop extension build before it publishes to the marketplace instead of testing that path by hand.
The key idea: Code Builder runs a Node extension host over a browser UI, so it's the desktop build, not the web build. We drive a browser at the container's code-server URL, and swap the unreleased monorepo VSIX into the running container at runtime — no changes needed in code-builder-images. Like the desktop e2e leaves, we test the artifacts the release is about to ship, not a rebuild: the workflow downloads the VSIX from an upstream Build All run by
runId. A filesystem version gate then asserts every in-scope extension resolves to exactly one dir at that version before any spec runs, so we can't false-green against stale code. The design and the rejected alternatives are written up in ADR 0022.Whats in here:
playwright-vscode-ext:createContainerConfig+createContainerTest(container config factory and fixture, reusing the existing Electron-decoupled page objects)salesforcedx-vscode-core: atest:containerwireit script, container fixtures, two specs (configList.container.spec.tsfor the CLI-shellout path,seededWorkspace.container.spec.tsfor the mounted fixture), a checked-in fixture project undertest/playwright/fixtures/container-workspace/, and its config.github/workflows/codeBuilderE2E.ymlplus the swap, version-gate, and workspace-seed scriptsscripts/codeBuilderLocalE2E.ts— a one-command local twin of the workflow (npm run test:container:local), so you can run the whole pull → swap → restart → gate → specs loop against your working tree without CIIts manual-only for now (
workflow_dispatch/workflow_call), so merging this runs nothing automatically — it just registers the dispatch button on develop. To dispatch it you pass therunIdof a recent Build All run (the one whose VSIX you want to test) and, if the artifact isnt the defaultVS Code Extensionslabel, theartifactName. It slots straight into thee2e.ymlfan-out later, since that already threads a buildrunId. I kept the spec set small on purpose — one CLI-shellout spec and one that proves the seeded fixture opens — to prove the concept; the team can decide which specs to port from there.The scripts (
codeBuilderLocalE2E.ts,codeBuilderSwapExtensions.ts,codeBuilderVerifyExtensions.ts,codeBuilderSeedWorkspace.ts) are ts-node, matching the repo convention, and useexecFileSyncwith arg arrays rather than shell interpolation so there's no shell-injection surface on the docker/gh/sf calls.Heads up that Im expecting the first manual dispatch to be a shakeout — the container mechanics (swap, restart, org re-auth) are the parts weve only run by hand so far. Merging registers the workflow so I can dispatch it against develop and iterate.
How CI is allowed to pull the image
The Code Builder image (
ghcr.io/forcedotcom/code-builder-images/workspace-manager/codebuilder) is a private GHCR package in the sameforcedotcomorg as this repo, so the pull needs authentication — but no App, PAT, or private key lives in the workflow.The job requests
packages: readpermission, then logs in toghcr.iowith the run's built-inGITHUB_TOKEN(the ephemeral token GitHub mints per run,-u ${{ github.actor }}). That token is allowed to read the package because thecodebuilderpackage grants Manage Actions access tosalesforcedx-vscode— a package-level setting on the code-builder-images side that lets this repo's Actions token pull it. So the chain is: package grants this repo Actions read → the per-runGITHUB_TOKENinherits that read →docker login+docker pullsucceed. Nothing to rotate, nothing stored as a secret.That grant is scoped to the repo's Actions token only — it does not authorize a human pulling from their laptop. But the same repo-permissions model covers local: the team has read on the image repo and the
codebuilderpackage inherits repo permissions, so each dev pulls as their own GitHub user (below) — no shared bot user or PAT.Dev-hub auth is unrelated to the image and reuses the existing
SFDX_AUTH_URL_E2Esecret.Running it locally
npm run test:container:localruns the same pull → swap → restart → gate → specs pipeline against your working tree. What you need:gh(GitHub CLI) logged in to github.com. The script pulls the image as your own GitHub user —gh auth tokenfor the credential,gh api userfor the username — so no shared token and nothing to rotate. The one catch is scope: ghcr requires theread:packagesscope, and a defaultgh auth logindoesnt request it, so if the login fails the script prints the one-line fix (gh auth refresh -h github.com -s read:packages). Escape hatch: setCR_PATto your own classic PAT (read:packages, SSO-authorized for forcedotcom) and the script uses that instead of gh.sflogged in to a dev hub (the script reusesminimalTestOrgif present, else creates it). Ifsfisnt installed globally it falls back tonpx @salesforce/cli.--run-idadditionally needsgh(to download the CI artifact) — already covered unless youre on theCR_PATpath.The preflight assumes a fresh box — it checks docker (+ a running daemon) and gh (installed + logged in) up front and, if anything's missing, prints one consolidated report with copy-paste fixes rather than failing partway through a pull. Provenance for whatever it ends up testing (upstream run metadata for
--run-id, or your git branch + HEAD for a local build) is logged at the top of the run.Known limitation: the swap trusts semver, and semver can lie
Worth calling out before merge, because it shapes where this goes next. The swap and the version gate currently lean on the extension semver, and there are two ways that bites us.
First, how the swap takes at all. The image bakes a curated extension set into
/base/extension-overrides, and on boot itsstart.shsymlinks each override into the runtime dironly when the override is a strictly-newer semver than whats already linked. So a same-or-lower VSIX would never re-link. We work around that byrm -rf-ing both the baked override dir and the runtime symlink before installing ours, sostart.shtreats ours as new. That works, but it rides two contracts thecode-builder-images` team owns and can change under us: the strictly-newer relink rule, and semver moving forward.Second, and the sharper one: the gate compares installed semver against the artifact's semver, not the bytes. Extension versions arent bumped until release, so last nights unreleased build and the marketplace build can both read
67.4.0. If the swap ever silently no-ops (glob miss, a path the image renamed, a permissions hiccup), the host keeps the baked production copy, the gate compares67.4.0 == 67.4.0, and the run goes green while testing production. Thats exactly the false-green the gate was meant to stop. TheOK ...@67.4.0lines prove the dirs exist at that version — they dont prove the bytes are the build we meant to test.Options to close this, cheapest first (details + trade-offs in ADR 0022):
salesforce.*extension from the runtime + override locations unconditionally, then install ours as the only copy present. Removes the dependency on semver precedence entirely — with nothing to compare against, ours is the only thing that can load. Trade-off: tests a clean install rather than an upgrade-over-existing, and it should wipe by publisher glob rather than the hardcoded ID list so a baked extension under an unlisted ID cant survive.These arent mutually exclusive: wipe proves only ours can load; a content/stamp check proves the bytes are the ones intended. The likely direction is wipe + a content check, which makes correctness independent of how the image team handles versioning. Official pre-release versions from
code-builder-images(a future intent) would satisfy todays semver gate — but by fixing the version contract rather than removing our dependence on it, so its a nice-to-have on top, not a substitute. Filing follow-up work; this PR keeps the semver gate and documents the gap.Seeding the workspace with metadata
The container now opens a real project instead of a bare one, so specs can open a class, run a test, or deploy. A version-controlled SFDX project lives at
packages/salesforcedx-vscode-core/test/playwright/fixtures/container-workspace/(sfdx-project.json+ aforce-appApex class and its test to start; grow it as specs need). Thedocker runbind-mounts it into the container, andscripts/codeBuilderSeedWorkspace.tswritescoder.jsonviadocker execso code-server opens it — samedocker execlever we already use to disable workspace trust and swap extensions. Its wired into both CI and the local loop, with one seeded spec (seededWorkspace.container.spec.ts) that opens the fixture Apex class from the Explorer to prove the mount reached the editor.This is the fixture-project approach Shane flagged — the closest prior art was
test-workspaces/sfdx-workspace/(a checked-in SFDX fixture, but consumed in place by the LWC/Aura language-server unit tests, and LWC-only with no Apex), so I modeled a small container-specific fixture on it rather than reusing that one.Why a mount and not the image's own seeding: what opens in the workbench is normally decided by the image. On first boot it runs
sfdx-setup.sh(once — gated behind a~/.codebuildermarker, so it does not re-run on our swapdocker restart); givenSFDX_COBU_PROJECTNAMEitsfdx project generates a bare project and writescoder.json. Theres also a dormantSFDX_COBU_GITHUB_PROJECT_URLgit-clone branch (nothing sends it, untouched since 2023). We deliberately avoid both: they fire only on first boot behind the.codebuildergate and live in image code the CB team owns and can change, whereas a mount is applied by Docker at run time, survives restart, and depends on nothing inside the image except thecoder.jsonfolder query. Same decoupling principle as the swap options above.Two container-path details worth knowing: the mount targets
/home/codebuilder/fixture-project, not theSFDX_COBU_PROJECTNAMEpath (~/e2e-project) — mounting over that would collide withsfdx project generate --output-dir /home/codebuilder, which aborts on a non-empty dir. And if you change which folder opens, the disable-workspace-trust step and the workbench-ready wait have to stay consistent (the trust setting is workspace-agnostic, so it already covers the mount). Details in ADR 0022.Creating metadata mid-spec via
createApexClass/createAndDeployApexTestClass(the desktop pattern) is still available for specs that want to exercise the create path, but the mounted fixture is the default since it gives a fixed starting state with no image involvement.Order of operations
Heres what one dispatched CI run does end to end, for anyone reading this cold:
flowchart TD A["Download VSIX under test from the Build All runId (the bytes about to ship)"] --> B["docker login ghcr.io with the repo GITHUB_TOKEN"] B --> C["docker pull the private Code Builder image"] C --> D["Auth dev hub (SFDX_AUTH_URL_E2E) + create scratch org"] D --> E["docker run: code-server on 58080 to 8123, bind-mount fixture project, org auth from scratch-org token"] E --> F["Wait for the workbench to answer at localhost:8123"] F --> F2["Disable workspace trust (else Restricted Mode, extensions never activate)"] F2 --> F3["Seed: write coder.json so code-server opens the mounted fixture project"] F3 --> G["Swap: remove baked override dirs, install downloaded VSIX into extension-overrides"] G --> H["docker restart: extension host reloads new VSIX AND org re-auths"] H --> I{"Version gate: exactly one dir per extension at the shipping version?"} I -->|no| X["Fail the run — wrong or mixed versions"] I -->|yes| J["npm run test:container: Playwright drives the browser at the container"] J --> K["Always: upload report + container logs, tear down container, delete scratch org"]Same thing as a numbered list if the diagram doesnt render for you:
runId) — these are the exact bytes about to ship, not a rebuild.The run logs the artifact's provenance here — the source run's workflow, branch, commit, and build timestamp — because the VSIX semver isnt release-bumped and so also matches the marketplace build. The semver alone cant tell you whether youre testing shipping or pre-release bytes; the provenance banner (and the artifact SHA256 the download step prints) is the source of truth for what's under test.
docker login ghcr.iowith the repoGITHUB_TOKENand pull the private Code Builder image.docker runthe image (code-server 58080 published to 8123), bind-mounting the fixture project to/home/codebuilder/fixture-project; the container auths the org from the scratch-org token on start.localhost:8123.--disable-workspace-trust; this is the code-server equivalent).coder.jsonviadocker execso code-server opens the mounted fixture project (real metadata) instead of the image's bare generated one./base/extension-overrides.docker restart— the extension host reloads holding the new VSIX, org auth re-runs, and the workbench comes back opening the seeded fixture.npm run test:container— Playwright drives a browser against the running container.