import { describe, it, expect } from 'vitest' import { 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`/`patch` shell out // to git, and the extension lane runs on node:24-bookworm-slim which may not // ship it. Those two are covered where git is guaranteed — ci.yml and build.yml // 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).toContain(':(exclude)extension/scripts/**') expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false) expect(pathspec.some((e) => e.includes('helpers'))).toBe(false) }) 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;/) } }) const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.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. Asserted across all three rather than // against one named consumer, so it keeps holding as consumers come and go. 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', () => { const majorMinor = (v) => v.split('.').slice(0, 2).join('.') it('keeps the hand-set MAJOR.MINOR in lockstep across both files', () => { // Narrowed from full-string equality at milestone 271 step 5. Since step 4 // the patch component is derived from commit time and stamped into both // files at build time, so the committed patch numbers are inert — nothing // reads them and they are not what ships. Asserting on them would fail for // a difference that changes nothing. // // MAJOR.MINOR is the opposite: still hand-set, still shipped, and // packaging.sh reads it from manifest.json ALONE. Let the two diverge and // the extension ships a version package.json disagrees with, with no other // signal. expect(majorMinor(read('manifest.json').version)) .toBe(majorMinor(read('package.json').version)) }) it('uses a plain dotted numeric version AMO will accept', () => { // The committed value seeds MAJOR.MINOR, so it still has to parse even // though its patch component never ships. ci.yml asserts the same shape on // the DERIVED value, which is the one AMO actually sees. expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/) }) 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')) }) })