How We Got @everystate/vue to Green CI

Table of Contents
  1. The Starting Point
  2. What Are Tests and Why Do They Matter?
  3. The Two Layers of Testing in This Package
  4. Adding Edge Case Tests
  5. Making Test Output Beautiful
  6. What Is CI and Why Do We Need It?
  7. Creating the GitHub Actions Workflow
  8. CI Failure #1: Missing Dependencies
  9. CI Failure #2: Peer Dependency Resolution
  10. CI Failure #3: Stale Lockfile
  11. The Final Fix
  12. Publishing the Updated Test Runner
  13. Version History
  14. Key Lessons Learned

1. The Starting Point

@everystate/vue is a Vue 3 adapter for the EveryState.js state management library. It provides composables like usePath, useIntent, useWildcard, and useAsync that let Vue components subscribe to EveryState's path-based state.

The package had two test files:

A Vue developer reviewed the package and gave this feedback (translated from Croatian): "It would be great to have more test cases. In tests you see use cases as the developer intended the library to be used, and maybe some edge cases. That's what separates a stable package from an alpha/beta one."

He also said: "When he sees green numbers like that in the console, or even better in a GitHub pipeline" - meaning he wanted to see a CI badge on the repo.

2. What Are Tests and Why Do They Matter?

What is a test?

A test is a small piece of code that checks whether your code works correctly. It's like a recipe's "taste test" - you follow the recipe (run your code), then check if the result is what you expected.

Here's the simplest possible test:

// The code we're testing
function add(a, b) { return a + b; }

// The test
const result = add(2, 3);
if (result !== 5) {
  throw new Error('add(2, 3) should be 5, got ' + result);
}

If the code works, nothing happens. If it's broken, the test throws an error.

Why do tests matter for an open-source package?

When a developer evaluates whether to use your package, they look for signals of quality:

  1. Tests show intent - A test named 'usePath: unsubscribe stops notifications' tells the reader exactly what the library is supposed to do in that scenario. Tests are living documentation.
  2. Tests show edge cases - A test named 'edge: set null is a valid value' tells the reader "yes, the author thought about this case, and it works." Without that test, the reader has to guess.
  3. Tests prevent regressions - When you change code, tests catch if something that used to work is now broken.
  4. Tests signal maturity - A package with 5 tests feels like alpha software. A package with 22 tests covering happy paths AND edge cases feels stable.

Types of tests

3. The Two Layers of Testing in This Package

Layer 1: Self-test (self-test.js)

The self-test is a standalone script that:

Why does it exist? Because Vue composables are thin wrappers around store operations. If you prove that store.subscribe() + store.get() works correctly, you've proven that usePath() (which is just subscribe + ref + computed) will work too.

The self-test has 25 assertions across 8 sections:

1. usePath pattern: subscribe + get
2. useIntent pattern: stable setter
3. useWildcard pattern: wildcard subscribe
4. useAsync pattern: setAsync lifecycle
5. useAsync error pattern
6. Provider pattern: store as external dependency
7. batch pattern (Vue nextTick compat)
8. Unsubscribe pattern (onBeforeUnmount simulation)

Layer 2: Integration tests (tests/vue.test.js)

The integration tests use @everystate/test, a testing library that provides:

The integration tests are more expressive because @everystate/test provides richer assertions. They also generate type information that can be used for TypeScript type generation.

4. Adding Edge Case Tests

We added 8 new edge case tests to tests/vue.test.js, bringing the total from 14 to 22. Here's each one and why it matters:

4.1. Idempotent set (same value re-fires)

'edge: setting same value still notifies subscribers': () => {
  const store = createEveryState({ count: 1 });
  let fires = 0;
  store.subscribe('count', () => { fires++; });
  store.set('count', 1);  // Same value!
  store.set('count', 1);  // Same value again!
  if (fires !== 2) throw new Error(`Expected 2 fires, got ${fires}`);
  store.destroy();
},

Why it matters: Some state libraries (like React's useState) skip re-renders when the value hasn't changed. EveryState.js intentionally does NOT skip - every set() fires subscribers regardless. This test documents that design decision.

4.2. Deep path auto-creation

'edge: deep path auto-creates intermediate objects': () => {
  const store = createEveryState({});
  store.set('a.b.c.d', 'deep');
  if (store.get('a.b.c.d') !== 'deep') throw new Error('Deep path not created');
  if (typeof store.get('a.b.c') !== 'object') throw new Error('...');
  if (typeof store.get('a.b') !== 'object') throw new Error('...');
  store.destroy();
},

Why it matters: When you do store.set('a.b.c.d', value) on an empty store, the library needs to create objects for a, a.b, and a.b.c automatically. This is a common pattern in Vue apps where you might set deeply nested state before the parent objects exist.

4.3. Null as a valid value

'edge: set null is a valid value': () => {
  const store = createEveryState({ data: { items: [1, 2, 3] } });
  store.set('data.items', null);
  if (store.get('data.items') !== null) throw new Error('Expected null');
  store.destroy();
},

Why it matters: In Vue apps, you often reset state to null (e.g., clearing a selected item, resetting form data). This test proves null is a first-class value, not treated as "missing."

4.4. Undefined as a valid value

'edge: set undefined is a valid value': () => {
  const store = createEveryState({ flag: true });
  store.set('flag', undefined);
  if (store.get('flag') !== undefined) throw new Error('Expected undefined');
  store.destroy();
},

Why it matters: Similar to null, but trickier. Many libraries treat undefined as "delete this key." EveryState.js treats it as a valid value.

4.5. Multiple subscribers on the same path

'edge: multiple subscribers on same path fire independently': () => {
  const store = createEveryState({ x: 0 });
  let firesA = 0;
  let firesB = 0;
  const unsubA = store.subscribe('x', () => { firesA++; });
  const unsubB = store.subscribe('x', () => { firesB++; });

  store.set('x', 1);
  if (firesA !== 1 || firesB !== 1) throw new Error('Both should fire once');

  // Unsubscribing one does not affect the other
  unsubA();
  store.set('x', 2);
  if (firesA !== 1) throw new Error('A should stay at 1 after unsub');
  if (firesB !== 2) throw new Error('B should fire again');

  unsubB();
  store.destroy();
},

Why it matters: In a Vue app, multiple components might subscribe to the same path (e.g., a navbar and a sidebar both showing the user's name). When one component unmounts and unsubscribes, the other must keep receiving updates.

4.6. Post-destroy safety (get/set)

'edge: get/set after destroy throws': () => {
  const store = createEveryState({ x: 1 });
  store.destroy();

  let getThrew = false;
  try { store.get('x'); } catch { getThrew = true; }
  if (!getThrew) throw new Error('get() should throw after destroy');

  let setThrew = false;
  try { store.set('x', 2); } catch { setThrew = true; }
  if (!setThrew) throw new Error('set() should throw after destroy');
},

Why it matters: In Vue, onBeforeUnmount timing can be tricky. If a store is destroyed but a component still tries to read/write, it should throw an error immediately rather than silently writing to a dead store. This prevents hard-to-debug state corruption.

4.7. Post-destroy safety (subscribe)

'edge: subscribe after destroy throws': () => {
  const store = createEveryState({ x: 1 });
  store.destroy();

  let threw = false;
  try { store.subscribe('x', () => {}); } catch { threw = true; }
  if (!threw) throw new Error('subscribe() should throw after destroy');
},

Why it matters: Same reasoning as above, but for subscriptions. A component that tries to subscribe to a destroyed store should get an immediate error.

4.8. Realistic Vue app state shape

'edge: realistic Vue app state shape': () => {
  const t = createEventTest({
    auth: { user: null, token: null, isAuthenticated: false },
    router: { view: 'home', path: '/', params: {} },
    entities: { posts: {}, comments: {} },
    ui: { theme: 'dark', sidebarOpen: false, modal: null, loading: false },
  });

  // Simulate login flow
  t.trigger('auth.user', { id: 1, name: 'Alice', role: 'admin' });
  t.trigger('auth.token', 'jwt-abc-123');
  t.trigger('auth.isAuthenticated', true);
  t.assertPath('auth.isAuthenticated', true);
  t.assertShape('auth.user', { id: 'number', name: 'string', role: 'string' });

  // Simulate route change
  t.trigger('router.view', 'dashboard');
  t.trigger('router.path', '/dashboard');
  t.assertPath('router.view', 'dashboard');

  // Simulate entity loading
  t.trigger('entities.posts', { p1: { title: 'Hello', body: 'World' } });
  t.assertShape('entities.posts.p1', { title: 'string', body: 'string' });

  // Simulate UI toggles
  t.trigger('ui.sidebarOpen', true);
  t.trigger('ui.modal', 'confirm-delete');
  t.assertPath('ui.modal', 'confirm-delete');
  t.assertType('ui.loading', 'boolean');
},

Why it matters: This test simulates a real Vue application with authentication, routing, entity management, and UI state. It shows reviewers that the library handles realistic, complex state shapes - not just toy examples.

5. Making Test Output Beautiful

The problem

The original test output was functional but didn't look professional. Modern test runners like Jest and Vitest show colored output with green PASS badges, section grouping, and styled summary bars.

The solution: ANSI escape codes

We updated @everystate/test/eventTest.js to use ANSI escape codes for terminal colors:

// ANSI helpers
const g = '\x1b[32m';   // green text
const r = '\x1b[31m';   // red text
const d = '\x1b[2m';    // dim
const R = '\x1b[0m';    // reset
const passTag = '\x1b[42m\x1b[30m PASS \x1b[0m';  // green background, black text
const failTag = '\x1b[41m\x1b[37m FAIL \x1b[0m';  // red background, white text

What are ANSI escape codes?

ANSI escape codes are special character sequences that terminals interpret as formatting instructions. They start with \x1b[ (the escape character followed by [) and end with a letter code:

You can combine them: \x1b[42m\x1b[30m PASS \x1b[0m means "green background, black text, print ' PASS ', then reset."

Auto-grouping by section

The test runner auto-detects sections from test name prefixes:

const section = name.split(':')[0] || '';
if (section !== currentSection) {
  currentSection = section;
  console.log(`\n  ${d}-- ${section} --${R}`);
}

So tests named 'usePath: subscribe to exact path' and 'usePath: nested path subscription' are automatically grouped under a -- usePath -- header.

The result

  @everystate/test (22 tests)

  -- usePath --
   PASS  usePath: subscribe to exact path
   PASS  usePath: nested path subscription
   PASS  usePath: unsubscribe stops notifications (onBeforeUnmount)

  -- edge --
   PASS  edge: setting same value still notifies subscribers
   PASS  edge: deep path auto-creates intermediate objects
   ...

    22 passed   (22 tests)

In the terminal, the PASS tags render as green badges and the summary bar is a green block - just like Jest or Vitest.

6. What Is CI and Why Do We Need It?

CI = Continuous Integration

CI is a practice where every code change is automatically tested. When you push code to GitHub, a server somewhere runs your tests. If they pass, you get a green checkmark. If they fail, you get a red X.

Why does this matter for open-source?

  1. Credibility - A green CI badge on your README tells potential users "this code is tested and working." It's the #1 signal of a maintained project.
  2. Catches environment issues - Your code works on your machine, but does it work on a clean Ubuntu server with a different Node.js version? CI tests this.
  3. Prevents broken releases - If you accidentally break something, CI catches it before users install the broken version.

What is GitHub Actions?

GitHub Actions is GitHub's built-in CI service. It's free for public repos. You define a workflow in a YAML file, and GitHub runs it on every push.

A workflow has:

7. Creating the GitHub Actions Workflow

We created .github/workflows/tests.yml:

name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20, 22, 24]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install dependencies
        run: npm ci --legacy-peer-deps

      - name: Self-test
        run: node self-test.js

      - name: Integration tests
        run: npm run test:integration

Line-by-line explanation

name: Tests - The name that appears in the GitHub Actions UI.

on: push: branches: [main] - Run this workflow on every push to the main branch. Also runs on pull requests targeting main.

runs-on: ubuntu-latest - Use a fresh Ubuntu virtual machine. GitHub provides these for free. Every run starts from a clean machine with no pre-installed project dependencies.

strategy: matrix: node-version: [20, 22, 24] - Run the tests three times: once on Node.js 20, once on 22, once on 24. This catches compatibility issues. We chose these versions because Node 18 is end-of-life (EOL).

actions/checkout@v4 - Checks out your repository code onto the virtual machine. Without this, the VM is empty.

actions/setup-node@v4 - Installs the specified Node.js version.

npm ci --legacy-peer-deps - Installs dependencies. More on this in the failure sections below.

node self-test.js - Runs the self-test (25 assertions).

npm run test:integration - Runs the integration tests (22 tests).

The README badge

We added a badge to the README that dynamically shows the CI status:

[![Tests](https://github.com/ImsirovicAjdin/everystate-vue/actions/workflows/tests.yml/badge.svg)](https://github.com/ImsirovicAjdin/everystate-vue/actions/workflows/tests.yml)

When tests pass, it shows a green "passing" badge. When they fail, it shows a red "failing" badge. It updates automatically on every push.

8. CI Failure #1: Missing Dependencies

What happened (v1.0.3)

The first CI run failed immediately:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@everystate/core'
imported from /home/runner/work/everystate-vue/everystate-vue/self-test.js

Why it failed

The self-test.js file imports @everystate/core, but that package was only listed as a peerDependency with a local file path:

"peerDependencies": {
  "@everystate/core": "file:../everystate-core",
  "vue": ">=3.0.0"
}

Two problems:

  1. file:../everystate-core - This is a local file path that only works on the developer's machine. In CI, there is no ../everystate-core folder. The GitHub Actions VM only has the everystate-vue repository checked out.
  2. Peer dependencies are not automatically installed - In npm, peer dependencies are packages that the user of your library is expected to install. npm does not guarantee they'll be available in dev/test environments.

What are dependencies, devDependencies, and peerDependencies?

The fix (v1.0.4)

We changed the peerDependencies to use an npm version range and added @everystate/core to devDependencies:

"peerDependencies": {
  "@everystate/core": ">=1.0.0",
  "vue": ">=3.0.0"
},
"devDependencies": {
  "@everystate/core": "^1.0.0",
  "@everystate/test": "^1.0.0"
}

We also moved npm install BEFORE the self-test step in the workflow. The original workflow ran the self-test first (before installing anything), which obviously couldn't work.

9. CI Failure #2: Peer Dependency Resolution

What happened (v1.0.4)

Even after adding @everystate/core to devDependencies and reordering the workflow steps, CI still failed with the same error.

Why it failed

The npm install step was completing in ~0 seconds - suspiciously fast. The install was failing silently because of vue.

Here's what happened:

  1. package.json has "peerDependencies": { "vue": ">=3.0.0" }
  2. npm v7+ auto-installs peer dependencies by default
  3. vue is NOT in devDependencies, so npm tries to resolve it from the registry
  4. The resolution conflicts, and the entire install fails
  5. @everystate/core never gets installed

The fix (v1.0.5)

We added --legacy-peer-deps to the npm install command:

- name: Install dependencies
  run: npm install --legacy-peer-deps

What does --legacy-peer-deps do? It tells npm to use the old (npm v6) behavior: don't try to automatically install peer dependencies. Just install what's in dependencies and devDependencies. Since our tests don't need Vue (they only test the store layer), this is the correct approach.

10. CI Failure #3: Stale Lockfile

What happened (v1.0.5)

The CI still failed. Even with --legacy-peer-deps, @everystate/core wasn't being installed.

What is a lockfile?

When you run npm install, npm creates a package-lock.json file. This file records the exact versions of every package that was installed, along with their download URLs and integrity hashes. It serves two purposes:

  1. Deterministic installs - Every developer (and every CI run) installs the exact same versions.
  2. Speed - npm can skip the version resolution step and go straight to downloading.

Why it failed

The package-lock.json that was committed to GitHub was stale. Here's the timeline:

  1. We edited package.json to add @everystate/core to devDependencies
  2. The commit was pushed immediately
  3. But npm install hadn't been run yet to regenerate the lockfile
  4. The lockfile on GitHub still had the OLD dependency list (without core)
  5. CI used this old lockfile and installed... nothing useful

How we discovered it

Running git status after a local npm install showed:

Changes not staged for commit:
    modified:   package-lock.json

This confirmed the lockfile had been regenerated locally but not committed. The lockfile on GitHub was out of date.

11. The Final Fix

Three changes together fixed CI for good:

  1. Delete node_modules and package-lock.json, run npm install --legacy-peer-deps to regenerate a clean lockfile
  2. Commit the updated lockfile alongside the code changes
  3. Switch from npm install to npm ci in the workflow

What's the difference between npm install and npm ci?

The final package.json at v1.0.6:

{
  "peerDependencies": {
    "@everystate/core": ">=1.0.0",
    "vue": ">=3.0.0"
  },
  "devDependencies": {
    "@everystate/core": "^1.0.0",
    "@everystate/test": "^1.0.0"
  }
}

12. Publishing the Updated Test Runner

Because the test output formatting lives in @everystate/test (a separate npm package), updating the output required a multi-step process:

  1. Edit everystate-test/eventTest.js with the ANSI formatting code
  2. Bump everystate-test/package.json version from 1.0.5 to 1.0.6
  3. Commit, tag, and push the test package
  4. Publish to npm: npm publish --access public
  5. Back in everystate-vue: delete node_modules and package-lock.json
  6. Run npm install --legacy-peer-deps to pull the new @everystate/test@1.0.6
  7. Commit the updated lockfile in everystate-vue

This is the nature of a multi-package ecosystem. The dependency chain is:

@everystate/vue (your app adapter)
  -> @everystate/test (test runner, devDependency)
    -> @everystate/core (state library, peerDependency of test)
  -> @everystate/core (state library, devDependency + peerDependency)

Any change to @everystate/test requires a publish before @everystate/vue can use it.

13. Version History

VersionWhat changedCI status
v1.0.1Initial release with 14 integration testsNo CI
v1.0.2Added 8 edge case tests (22 total)No CI
v1.0.3Added GitHub Actions workflow + README badgesFAILED: missing @everystate/core
v1.0.4Added @everystate/core to devDeps, fixed peerDepsFAILED: vue peer dep blocks install
v1.0.5Added --legacy-peer-deps, ASCII-only charsFAILED: stale lockfile
v1.0.6Committed lockfile, switched to npm ci, green outputPASSED

14. Key Lessons Learned

1. Always commit your lockfile

If you change package.json, run npm install and commit the updated package-lock.json in the same commit. A stale lockfile is a silent CI killer.

2. Use npm ci in CI, not npm install

npm ci is faster, stricter, and deterministic. It fails loudly if the lockfile is out of sync, which helps you catch problems early.

3. Peer dependencies need special handling in CI

If your package has peer dependencies that aren't needed for testing (like Vue), use --legacy-peer-deps to prevent them from blocking the install.

4. Don't use file: paths in published packages

file:../everystate-core only works on your local machine. Published packages must use npm version ranges like >=1.0.0 or ^1.0.0.

5. Test output matters

Pretty test output isn't vanity - it's communication. When a reviewer sees green PASS badges grouped by section with a summary bar, they immediately understand the test coverage without reading the code.

6. Edge case tests signal maturity

Happy-path tests show your library works. Edge case tests show you've thought about the boundaries. Together, they're what separates a stable package from alpha/beta software.

7. CI is a badge of trust

A green CI badge on your README is the single most important signal for open-source credibility. It tells potential users: "This code is tested on every push, on multiple Node.js versions, on a clean machine."

8. Non-ASCII characters can cause confusion

We replaced all Unicode characters (em-dashes, arrows, checkmarks, copyright symbols) with ASCII equivalents. This ensures the source files are readable and editable on any system, and avoids encoding issues in CI environments.

9. Publish dependencies before dependents

In a multi-package ecosystem, if you change a shared dependency (@everystate/test), you must publish it first, then update and re-lock the dependent package (@everystate/vue). The lockfile pins the exact version, so CI will use whatever version was locked at commit time.

← Back to all posts