import { describe, it, expect } from 'vitest' import { readdirSync, readFileSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import path from 'node:path' const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8')) const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8') // Only the git-free subcommands are exercised here: `version` shells out to // git, and the extension lane runs on node:24-bookworm-slim which may not ship // it. That one is covered where git is guaranteed — ci.yml's extension-version // lane and build.yml both run on ci-python. const packaging = (cmd) => execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], { cwd: EXT_DIR, encoding: 'utf8' }) .trim() .split(/\s+/) .filter(Boolean) describe('packaging.sh — the single definition of what ships', () => { it('emits an ignore list and a pathspec that agree on the tracked files', () => { const ignore = packaging('ignore') const pathspec = packaging('pathspec').map((e) => e.replace(':(exclude)extension/', '')) // Every git-excluded path must also be hidden from web-ext. The reverse is // not required: node_modules and web-ext-artifacts are build output git // never tracks, so they appear only in the ignore list. for (const entry of pathspec) { expect(ignore, `pathspec has "${entry}" but --ignore-files does not`).toContain(entry) } expect(pathspec.length).toBeGreaterThan(0) expect(ignore).toContain('node_modules') }) it('emits glob patterns literally, never expanded against the working tree', () => { // The script iterates its lists with deliberate word-splitting, so it must // run with pathname expansion off. Without that, invoking it from a cwd // where test/ exists (exactly how ci.yml and vitest call it) expands // `test/**` into the individual spec files, and the pathspec silently stops // covering anything added later. const pathspec = packaging('pathspec') expect(pathspec).toContain(':(exclude)extension/test/**') expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false) expect(pathspec.some((e) => e.includes('helpers'))).toBe(false) const ignore = packaging('ignore') expect(ignore).toContain('test/**') expect(ignore).toContain('scripts/**') expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false) }) it('lets packaging.sh move the version, though it never ships in the XPI', () => { // The two lists answer different questions and this is the one place they // disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon // code — but packaging.sh DECIDES the version string, and build.yml stamps // that string into the manifest.json that does ship. So changing how the // version is computed changes the shipped bytes. // // Excluding it from the pathspec was invisible while every push rebuilt the // web image. Milestone 313 step 4 made that rebuild conditional on the // derived revision moving, and the omission turned into a silent failure: // a new version means sign-extension misses its ext- cache and // signs, while build-web sees an unmoved revision, reuses the published // image and ships the OLD XPI. An orphaned signature, and an instance // serving code the registry calls current. const pathspec = packaging('pathspec') expect( pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')), 'the pathspec excludes scripts/, so a change to how the version is ' + 'derived would not move the version it derives', ).toBe(false) // ...and it is still kept out of the package itself. Both must hold: the // tempting "fix" for either half is to make the two lists one again. expect(packaging('ignore')).toContain('scripts') }) it('keeps its own scripts and specs out of the XPI', () => { // Both are repo infrastructure. web-ext packages everything not ignored, so // omitting either would ship dev tooling to users -- and `test/**` in // particular only survives because callers `set -f` before substituting it. const ignore = packaging('ignore') expect(ignore).toContain('vitest.config.js') // Both forms per directory. The glob covers the contents; the bare name // covers the directory ENTRY, which web-ext writes separately — with only // the glob, the XPI carries an empty `test/` and `scripts/`. for (const dir of ['test', 'scripts']) { expect(ignore, `${dir} contents`).toContain(`${dir}/**`) expect(ignore, `${dir} directory entry`).toContain(dir) } }) }) describe('consumers delegate rather than keeping their own copy', () => { // These assertions are the actual anti-regression value: it is easy for a // future edit to "simplify" by inlining a literal list again, which silently // reintroduces the drift that issue #2397 was about. it('package.json derives --ignore-files from the script', () => { for (const [name, script] of Object.entries(read('package.json').scripts)) { if (!script.includes('--ignore-files')) continue expect(script, `${name} should call packaging.sh`).toContain('scripts/packaging.sh ignore') expect(script, `${name} must set -f before the substitution`).toMatch(/set -f;/) } }) // Read from disk rather than listed by hand. The point of this assertion is // that it survives consumers coming and going, and a hardcoded list is the // one part of it that cannot — release.yml (milestone 318 step 7) would have // joined the directory without joining the check. const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) => f.endsWith('.yml') ) it('no workflow hardcodes the packaged-file set', () => { // ci.yml used to substitute `packaging.sh pathspec` directly, for the // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the // set today, and nothing should start to: a literal :(exclude)extension/... // in a workflow means someone bypassed the shared definition, which is // exactly the drift #2397 was about. expect( WORKFLOWS.length, 'no workflows found — the glob is not looking where it thinks' ).toBeGreaterThan(2) for (const wf of WORKFLOWS) { const text = readText('..', '.forgejo', 'workflows', wf) expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//) } }) it('build.yml takes the shipped version from the script, not from the repo', () => { // The version is DERIVED from commit time (#3092, milestone 271 step 4). // Going back to reading the committed value is not a style regression, it // is the bug: a hand-set version makes dev and main sign the same number // for different code, and the ext- cache then serves one channel // the other's XPI. const build = readText('..', '.forgejo', 'workflows', 'build.yml') expect(build).toContain('packaging.sh version') expect(build, 'build.yml re-reads the committed version instead of deriving it') .not.toMatch(/grep[^\n]*'"version"'[^\n]*package\.json/) }) }) describe('extension version', () => { // Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's // manifest.json/version page. Each segment is the single digit 0 or starts // 1-9 — so no leading zeros — and there are at most four of them. const AMO = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/ it('keeps a committed version AMO would accept, though it ships nothing', () => { // The committed value is wholly inert since milestone 318 step 8: there is // no hand-set MAJOR.MINOR left for packaging.sh to read, and build.yml // stamps the derived string over both files before web-ext sees them. // // It is still asserted, for one reason: `npm run build` locally packages // whatever is committed, so a value AMO would reject turns a local build // into a confusing failure with no CI signal ahead of it. ci.yml checks // the same grammar against the DERIVED value, which is the one AMO sees. for (const file of ['manifest.json', 'package.json']) { expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO) } }) it('rejects the zero-padded family shape, which is why the extension unpads', () => { // Guards the reason for the exception, not just its result. If this ever // starts passing, someone has loosened the pattern and the next sign burns // an AMO version to find out. (#3138.) expect('2026.08.29.0201').not.toMatch(AMO) expect('2026.8.29.201').toMatch(AMO) // Five segments: AMO allows four. expect('2026.8.29.2.1').not.toMatch(AMO) }) it('declares manifest v3', () => { expect(read('manifest.json').manifest_version).toBe(3) }) it('lists every background script that exists, in dependency order', () => { // url.js must load BEFORE api.js: api.js calls normalizeApiUrl at // init()-time, and these are classic scripts sharing one scope, so a // reordering here is a runtime ReferenceError with no build-time signal. const scripts = read('manifest.json').background.scripts for (const rel of scripts) { expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow() } expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js')) }) })