DEV Community

Cover image for I Shipped a Broken Chrome Extension to the Web Store. Here's the Test Suite That Prevents It Now.
Husain
Husain

Posted on

I Shipped a Broken Chrome Extension to the Web Store. Here's the Test Suite That Prevents It Now.

Last month I launched my first Chrome extension, PowerSearch — a tab search tool for people who drown in browser tabs. I built it because I was cycling through 20+ JIRA and Confluence tabs every day at work, losing my mind trying to find the one I needed 5 minutes ago.

The launch went like this:

  1. Built the extension ✅
  2. Zipped it up ✅
  3. Uploaded to Chrome Web Store ✅
  4. First real user tried to buy Pro... ❌

The entire license system was dead. Clicking "Buy Pro" did nothing.

What Went Wrong

My build script was zipping the project directory instead of just the dist/ output. The result: two manifest.json files in the package — one at the root level (the source), one nested inside dist/.

my-extension.zip
├── manifest.json          ← source manifest (wrong)
├── package.json           ← shouldn't be here
├── dist/
│   ├── manifest.json      ← actual built manifest
│   ├── service-worker.js
│   └── ...
Enter fullscreen mode Exit fullscreen mode

Chrome loaded the extension just fine — it picked up the root manifest.json. But the built JavaScript referenced paths relative to dist/, so the LemonSqueezy checkout URL pointed to a path that didn't exist. Silent failure. No error in the console. Just... nothing happens when you click the button.

The Fix: Test Your Zip, Not Your Code

I had unit tests. I had integration tests. I even had end-to-end tests for the license flow. All green. But none of them tested the actual packaged artifact — the zip file that gets uploaded to Chrome Web Store.

The lesson: your test suite is incomplete if it doesn't validate what you ship.

After fixing the build script, I wrote a Vitest test suite that runs against the production zip file. Here's the approach:

1. Extract and inspect the zip in beforeAll

import { describe, it, expect, beforeAll } from 'vitest'
import { execSync } from 'child_process'

let zipFiles: string[]
let manifest: any

beforeAll(() => {
  const zipPath = 'my-extension-v1.0.0.zip'

  // List all files in the zip
  const listing = execSync(`unzip -l "${zipPath}"`, { encoding: 'utf-8' })
  zipFiles = listing
    .split('\n')
    .filter((line) => line.match(/^\s+\d+/))
    .map((line) => line.replace(/^\s+\d+\s+\S+\s+\S+\s+/, '').trim())
    .filter((f) => f && !f.endsWith('/'))

  // Extract and parse manifest.json from inside the zip
  manifest = JSON.parse(
    execSync(`unzip -p "${zipPath}" manifest.json`, { encoding: 'utf-8' })
  )
})
Enter fullscreen mode Exit fullscreen mode

No temporary directories. No filesystem cleanup. unzip -l lists contents, unzip -p extracts to stdout. Fast and clean.

2. Verify required files exist

describe('zip — Required Files', () => {
  it('contains manifest.json', () => {
    expect(zipFiles).toContain('manifest.json')
  })

  it('contains popup HTML', () => {
    expect(zipFiles).toContain('src/popup/index.html')
  })

  it('contains all required icon PNGs', () => {
    const requiredIcons = [
      'icons/icon-16.png',
      'icons/icon-32.png',
      'icons/icon-48.png',
      'icons/icon-128.png',
    ]
    for (const icon of requiredIcons) {
      expect(zipFiles).toContain(icon)
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

3. Cross-reference manifest against zip contents

This is the test that would have caught my bug. Every file referenced in manifest.json must actually exist in the zip:

describe('zip — Manifest Validation', () => {
  it('all icons referenced in manifest exist in the zip', () => {
    for (const size of Object.values(manifest.icons) as string[]) {
      expect(zipFiles).toContain(size)
    }
  })

  it('popup HTML referenced in manifest exists in the zip', () => {
    expect(zipFiles).toContain(manifest.action.default_popup)
  })

  it('content script JS referenced in manifest exists in the zip', () => {
    for (const cs of manifest.content_scripts) {
      for (const jsFile of cs.js) {
        expect(zipFiles).toContain(jsFile)
      }
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

4. Catch stale build artifacts

If you don't clean your dist/ before building, you can end up with old hashed files from previous builds. This test ensures only ONE of each bundle type exists:

describe('zip — No Stale Build Artifacts', () => {
  it('only ONE service worker JS file exists (no old builds)', () => {
    const swFiles = zipFiles.filter(
      (f) =>
        f.includes('service-worker') &&
        f.endsWith('.js') &&
        !f.endsWith('.js.map') &&
        f !== 'service-worker-loader.js'
    )
    expect(swFiles).toHaveLength(1)
  })

  it('only ONE popup JS bundle exists (no old builds)', () => {
    const popupFiles = zipFiles.filter(
      (f) => f.match(/assets\/popup-.*\.js$/) && !f.endsWith('.js.map')
    )
    expect(popupFiles).toHaveLength(1)
  })
})
Enter fullscreen mode Exit fullscreen mode

5. Block dev files from leaking

No source .ts files, no node_modules, no .env — nothing that shouldn't be in a production extension:

describe('zip — No Development Files', () => {
  it('no .ts source files (only compiled .js)', () => {
    const tsFiles = zipFiles.filter(
      (f) => f.endsWith('.ts') && !f.endsWith('.d.ts')
    )
    expect(tsFiles).toHaveLength(0)
  })

  it('no node_modules', () => {
    const nodeModules = zipFiles.filter((f) => f.includes('node_modules'))
    expect(nodeModules).toHaveLength(0)
  })

  it('no .env files', () => {
    const envFiles = zipFiles.filter((f) => f.includes('.env'))
    expect(envFiles).toHaveLength(0)
  })

  it('no package.json', () => {
    expect(zipFiles).not.toContain('package.json')
  })
})
Enter fullscreen mode Exit fullscreen mode

The Full Picture

My test suite now has 43 tests that validate the zip package. They run in under a second. The complete test covers:

  • ✅ Zip file integrity (valid archive, reasonable size)
  • ✅ All required files present (manifest, popup, icons, service worker)
  • ✅ Manifest references match actual zip contents
  • ✅ Service worker loader → worker chain is valid
  • ✅ LemonSqueezy config contains correct production values
  • ✅ No stale builds (exactly one of each bundle)
  • ✅ No dev files leaked (no .ts, node_modules, .env, tests)

The package script is simple — build, then zip only the dist/ folder:

{
  "scripts": {
    "build": "vite build",
    "package": "rm -rf dist && npm run build && cd dist && zip -r ../extension.zip ."
  }
}
Enter fullscreen mode Exit fullscreen mode

After running npm run package, I run npm test which includes the zip validation. If any test fails, I don't upload.

What I'd Do Differently

  1. Test the artifact, not just the code. Unit tests and integration tests are necessary but not sufficient. If you ship a zip/bundle/container, test that too.
  2. Automate the "stupid" stuff. I thought I'd never mess up a zip file. I was wrong. Computers don't forget to check things.
  3. Ship to yourself first. Install your own packaged extension from the zip before uploading to the store. If I'd done this, I would have caught the bug in 30 seconds.

The extension is PowerSearch if you're curious — it's a tab search tool that lets you find any open tab or page in your history with Cmd+Shift+F. But honestly, the test suite is probably more useful to you than the extension. Steal it, adapt it, and don't ship broken builds like I did.


What's your build validation story? Have you ever shipped something broken that tests should have caught? Drop a comment — I'd love to hear I'm not the only one.

Top comments (0)