Skip to content

Run Playwright tests in CI for shell integration - #45

Open
savetheclocktower wants to merge 3 commits into
local-path-detectionfrom
ci-playwright
Open

Run Playwright tests in CI for shell integration#45
savetheclocktower wants to merge 3 commits into
local-path-detectionfrom
ci-playwright

Conversation

@savetheclocktower

@savetheclocktower savetheclocktower commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

I know this is a lot of code to review at once! Whenever I’ve thrown code at folks in the past, I’ve found that the reviewer is much more confident in their approval if the PR comes with a lot of tests.

I mentioned earlier how the shell integration was the one area that existing specs couldn’t get at. I knew this was something I could fix with Playwright in CI, and I knew that I should already have set up CI for this project, so the inertia I felt (because of the enormity of the task) was outweighed by my guilt.

I told Claude Code that I wanted to do this very devops-y thing, and described how I envisioned the Playwright end-to-end tests working. Eventually, it would be good to test all combinations of OS and shell (fish, bash, zsh, pwsh), perhaps with certain holes in the matrix where appropriate. But that, in the short term, we were aiming to get just bash/Ubuntu working properly.

This took forever for a few reasons:

  • The first reason is the dumbest: I had given rough instructions to Claude on how to test a local copy of a package in Pulsar even when it’s a built-in package. But later I didn’t confirm that it followed those instructions! A few CI runs were wasted before we realized the terminal repo was never ppm link’d into place.
  • At that point, we discovered the first major problem: the terminal package defines TerminalElement, then unconditionally registers it as pulsar-terminal via customElements.define. I didn’t realize this would be a problem… because I didn’t know that a core package always has its source code loaded (somehow) even if it’s not used because of package shadowing via ppm link. So Pulsar complained as soon as the linked code also tried to define pulsar-terminal… because it was already defined.
  • The workaround for this is to defer the calling of customElements.define until activation. That’s easy to fix, but it won’t actually fix anything until the built-in version of terminal gets that fix. So for now, I made it so that we fell back to a non-clashing element name if pulsar-element is taken. This also requires that we introduce some other reliable way of targeting this element via CSS selector (for styling, keymaps, menus, etc.), so I’ve got it adding a data-pulsar-terminal attribute in all cases. Once we ship 1.133.0 with this fix, we can go back to assuming pulsar-terminal will be free. (As explained, all PRs in this stack got this fix — not just this one.)
  • After fixing these things — plus a couple of revealed bugs along the way — the CI passed for bash. It didn’t take much more effort to add zsh, fish, and pwsh support in Ubuntu. Haven’t tackled the other platforms yet, but this demonstrates that the core mechanism for listening in on cwd changes is sound for each supported shell.

Once I got this working, I decided it was also worth running the unit tests as well. Adding that as a step was easy; but running the specs in a new environment revealed that they were much flakier in CI than they were on my machine. The process of diagnosing these flaky tests led to the discovery of a couple of subtle timing bugs; test reliability should be like 99% better now.

On the usage of Claude Code

I want to reassure anyone reading that they are not getting a glimpse into some sudden new LLM-maxxed development methodology on my part. This is a temporary mode I've put myself in:

  • hyperlinks, the bottom PR in this stack, was done entirely by hand, except that I eventually used Claude to help me write some of the specs that I had procrastinated on.
  • The next two PRs in the stack were mostly Claude Code–driven because my role would’ve been that of a transcriber even in the best case; I was going to use VS Code’s implementations of shell integration and local path detection (rather than reinvent the wheel) no matter what, so I allowed Claude Code to do most of the transcribing.
  • This last PR is an example of me using Claude Code to do something rigorous that I would never have had the patience to do on my own: end-to-end testing of a package in CI.

So the rationales are different, but they all fall under the same umbrella: I considered all this work to be chores of various forms. I would find it quite disempowering and disheartening for this to be my main method of authoring code! But I also don't find it inherently edifying or character-building to write tests by hand, or to hand-author a GitHub Actions workflow file. I know myself well enough at this point to know that I could use some help with eating my vegetables.

Reads the package name from package.json at runtime (via the same
__dirname-based PACKAGE_ROOT walk-up pattern already used in
shell-integration/index.ts) rather than repeating the literal string
'terminal' across the codebase. Config keys, storage keys, and URIs
that were built from that hardcoded string now derive from PACKAGE_NAME
instead, so they can never drift out of sync with the package's actual
identity.
Three real bugs surfaced while getting a real end-to-end shell-integration
test running reliably:

- TerminalElement's IntersectionObserver disconnected itself only after
  awaiting createTerminal(), not before. Intersection changes can be
  delivered as more than one callback invocation before the first one's
  own continuation resumes, so a second invocation could slip through
  and trigger a redundant, concurrent createTerminal() call before the
  first ever reached its disconnect(). Disconnecting synchronously, on
  the first qualifying entry, closes that window.

- decoration.onRender() (used to anchor hover tooltips) isn't a one-shot
  "first paint" hook - XTerm re-invokes it on every repaint of that
  decoration (scroll, resize, cursor blink). The callback unconditionally
  re-added a tooltip via atom.tooltips.add() on every one of those
  repaints, stacking up duplicate tooltip instances for a single hover.

- createTerminal() and restartPtyProcess() both had "wait for the current
  call, then start a new one anyway" reentrancy handling, which doesn't
  actually serialize concurrent callers: when several calls pile up
  awaiting the same in-flight promise, each one wakes up and
  unconditionally kicks off its own new run, racing the others. Coalescing
  onto the in-flight promise (return instead of just await) fixes this for
  both. Also switched their cleanup from .then() to .finally() - a
  rejected call was otherwise never clearing the field, wedging every
  subsequent call behind a dead promise forever.

Along with the corresponding spec updates: two new regression tests for
the view-provider path, the test helper reworked to rely on the (now
properly guarded) IntersectionObserver instead of racing it with a
redundant explicit createTerminal() call, and findShellIntegrationAddon()
made robust to more than one addon having been loaded.
…ish/pwsh

Real end-to-end coverage: a real installed Pulsar, this checkout loaded
as a dev-mode package, a real login shell, and an actual `cd` typed into
the terminal - verified by reading TerminalModel.cwd back out of the
running app. This proves the one thing the headless jasmine specs can't:
that the shipped shell scripts actually emit correct shell-integration
sequences from a real shell startup, dotfiles and all.

e2e/helpers.js launches Pulsar via Playwright's Electron support, with
this checkout dev-linked in via a symlink under $ATOM_HOME/dev/packages
(replicating what `ppm link --dev` does) so the real, current build is
what activates rather than whatever `terminal` version Pulsar ships
bundled.

The workflow (.github/workflows/e2e.yml) runs the fast headless spec
suite first so a broken spec fails the job before paying for the slower,
more flake-prone E2E run, then runs the E2E suite itself across a
strategy.matrix of bash/zsh/fish/pwsh on ubuntu-latest. Shell selection
needs no test-side wiring at all: getDefaultShell() (src/config.ts)
already reads $SHELL, and openPulsar() already spreads ...process.env
into the launched app, so the workflow just resolves each shell's
absolute path and exports it. zsh needs one extra step: a fresh `apt`
install leaves its completion directories writable, which trips
compinit's security check into an interactive prompt that swallows
whatever's typed next - `compaudit` lists exactly which directories to
fix. pwsh needs no install step at all, since GitHub's hosted
ubuntu-latest runners ship it preinstalled.

OS doesn't affect what this actually proves (the shell side of shell
integration), so it isn't part of the matrix yet; the shell dimension is
factored out on its own so growing the matrix further (fish/pwsh already
here; macOS/Windows later) is just adding entries, not duplicating job
definitions.
@savetheclocktower
savetheclocktower changed the base branch from main to local-path-detection August 23, 2026 03:49
@savetheclocktower
savetheclocktower marked this pull request as ready for review August 23, 2026 06:50
@savetheclocktower savetheclocktower linked an issue Aug 23, 2026 that may be closed by this pull request
@savetheclocktower savetheclocktower changed the title Experiment with running Playwright tests in CI Run Playwright tests in CI for shell integration Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chore: add CI workflows

1 participant