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:
- Built the extension ✅
- Zipped it up ✅
- Uploaded to Chrome Web Store ✅
- 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
│ └── ...
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' })
)
})
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)
}
})
})
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)
}
}
})
})
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)
})
})
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')
})
})
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 ."
}
}
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
- 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.
- Automate the "stupid" stuff. I thought I'd never mess up a zip file. I was wrong. Computers don't forget to check things.
- 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.












