diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index f47e53c..681b7ba 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -46,14 +46,23 @@ jobs: env: CYPRESS_INSTALL_BINARY: 0 PUPPETEER_SKIP_DOWNLOAD: true - run: npm i + run: npm ci - name: Lint run: npm run lint + - name: Type check + run: npm run typecheck + - name: Build library run: npm run build + - name: Validate distribution + run: npm run validate:dist + + - name: Test package + run: npm run test:package + - name: Check build changes run: | bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets' && exit 1)" diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 75ce07d..75f78c0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -47,7 +47,7 @@ jobs: env: CYPRESS_INSTALL_BINARY: 0 run: | - npm i + npm ci npm run build - name: Publish to npm diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2df58d..5f76201 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -33,7 +33,7 @@ jobs: - name: Install & Build run: | - npm i + npm ci npm run build:demo - name: Deploy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3164dd..18945f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,7 +46,7 @@ jobs: env: CYPRESS_INSTALL_BINARY: 0 PUPPETEER_SKIP_DOWNLOAD: true - run: npm i + run: npm ci - name: Test run: npm run test diff --git a/package-lock.json b/package-lock.json index 314b506..e05ae0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,12 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@nextcloud/browserslist-config": "^3.1.2", + "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", "@vitejs/plugin-vue": "^6.0.4", "@vitest/eslint-plugin": "^1.6.14", + "@vue/language-core": "^3.3.11", "@vue/test-utils": "^2.4.6", "eslint": "^10.1.0", "eslint-plugin-vue": "^10.8.0", @@ -927,13 +929,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", - "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/whatwg-mimetype": { @@ -1436,6 +1438,22 @@ "@vue/shared": "3.5.42" } }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.42", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", @@ -1555,6 +1573,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", @@ -3642,9 +3667,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -4050,29 +4075,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/vue-tsc/node_modules/@vue/language-core": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", - "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@vue/compiler-dom": "^3.5.0", - "@vue/shared": "^3.5.0", - "alien-signals": "^3.2.1", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1", - "picomatch": "^4.0.4" - } - }, - "node_modules/vue-tsc/node_modules/alien-signals": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", - "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", - "dev": true, - "license": "MIT" - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/package.json b/package.json index e9e83f2..fa7cd0f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "build:report": "vite build --mode report", "build:demo": "vite build --mode demo", "preview:demo": "vite preview --outDir dist-demo", + "typecheck": "vue-tsc --noEmit", "validate:dist": "node scripts/validate-pdfjs-dist.mjs", + "test:package": "node scripts/test-package.mjs", "prepack": "npm run build && npm run validate:dist", "test": "vitest run", "test:watch": "vitest", @@ -54,10 +56,12 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@nextcloud/browserslist-config": "^3.1.2", + "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", "@vitejs/plugin-vue": "^6.0.4", "@vitest/eslint-plugin": "^1.6.14", + "@vue/language-core": "^3.3.11", "@vue/test-utils": "^2.4.6", "eslint-plugin-vue": "^10.8.0", "eslint": "^10.1.0", diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 0000000..47d9552 --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const execFileAsync = promisify(execFile) +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const npmExecPath = process.env.npm_execpath + +if (!npmExecPath) { + throw new Error('Run this check through npm so the active npm executable can be reused.') +} + +async function runNpm(args, cwd) { + return execFileAsync(process.execPath, [npmExecPath, ...args], { + cwd, + maxBuffer: 10 * 1024 * 1024, + }) +} + +function exportTargets(exports) { + if (typeof exports === 'string') { + return [exports] + } + if (!exports || typeof exports !== 'object') { + return [] + } + return Object.values(exports).flatMap(exportTargets) +} + +const tempRoot = await mkdtemp(path.join(tmpdir(), 'pdf-elements-package-')) + +try { + const packDir = path.join(tempRoot, 'pack') + const consumerDir = path.join(tempRoot, 'consumer') + await Promise.all([mkdir(packDir), mkdir(consumerDir)]) + + const { stdout } = await runNpm( + ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], + packageRoot + ) + const [packResult] = JSON.parse(stdout) + assert(packResult?.filename, 'npm pack did not report a tarball filename') + + const packedFiles = new Set(packResult.files.map(({ path: filePath }) => filePath)) + const packageJson = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')) + const requiredFiles = ['COPYING', 'README.md', 'dist/index.css', 'dist/index.mjs', packageJson.types] + + for (const target of exportTargets(packageJson.exports)) { + assert(target.startsWith('./'), `Package export must be relative: ${target}`) + requiredFiles.push(target.slice(2)) + } + + for (const filePath of new Set(requiredFiles)) { + assert(packedFiles.has(filePath), `Packed package is missing ${filePath}`) + } + + await writeFile( + path.join(consumerDir, 'package.json'), + JSON.stringify({ name: 'pdf-elements-smoke-consumer', private: true, type: 'module' }), + 'utf8' + ) + + const tarballPath = path.join(packDir, packResult.filename) + await runNpm( + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock', tarballPath], + consumerDir + ) + + await writeFile( + path.join(consumerDir, 'verify.mjs'), + `import assert from 'node:assert/strict' +import PDFElements, { ensureWorkerReady } from '@libresign/pdf-elements' + +assert(PDFElements, 'The public entry point has no default export') +assert.equal(typeof ensureWorkerReady, 'function') + +for (const specifier of ${JSON.stringify(Object.keys(packageJson.exports))}) { + const publicSpecifier = specifier === '.' + ? '@libresign/pdf-elements' + : \`@libresign/pdf-elements/\${specifier.slice(2)}\` + assert(import.meta.resolve(publicSpecifier), \`Unable to resolve \${publicSpecifier}\`) +} +`, + 'utf8' + ) + + await execFileAsync(process.execPath, ['verify.mjs'], { cwd: consumerDir }) + + await writeFile( + path.join(consumerDir, 'index.ts'), + `import PDFElements, { + ensureWorkerReady, + type PDFDocumentEntry, +} from '@libresign/pdf-elements' + +const component = PDFElements +const prepareWorker: () => Promise = ensureWorkerReady +let document: PDFDocumentEntry | undefined + +void component +void prepareWorker +void document +`, + 'utf8' + ) + await writeFile( + path.join(consumerDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + lib: ['ES2022', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + strict: true, + target: 'ES2022', + }, + include: ['index.ts'], + }), + 'utf8' + ) + + const tscPath = path.join(packageRoot, 'node_modules', 'typescript', 'bin', 'tsc') + await execFileAsync(process.execPath, [tscPath, '--project', 'tsconfig.json'], { + cwd: consumerDir, + }) + + globalThis.console.log(`Validated packed package ${packResult.filename} from a clean consumer.`) +} finally { + await rm(tempRoot, { recursive: true, force: true }) +} diff --git a/tests/components/PDFPage.spec.ts b/tests/components/PDFPage.spec.ts index 2e1fab8..d5a771e 100644 --- a/tests/components/PDFPage.spec.ts +++ b/tests/components/PDFPage.spec.ts @@ -114,7 +114,7 @@ describe('PDFPage business rules', () => { }, }) - wrapper.vm.renderTask = { cancel } + wrapper.vm.renderTask = { cancel, promise: Promise.resolve() } await wrapper.vm.render() @@ -129,7 +129,7 @@ describe('PDFPage business rules', () => { }) const cancel = vi.fn() - wrapper.vm.renderTask = { cancel } + wrapper.vm.renderTask = { cancel, promise: Promise.resolve() } wrapper.unmount() expect(cancel).toHaveBeenCalled() diff --git a/tsconfig.json b/tsconfig.json index 7f3dc82..0cbae68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "types": ["vite/client", "vitest/globals"] + "types": ["node", "vite/client", "vitest/globals"] }, "include": ["env.d.ts", "src", "examples", "tests"] } diff --git a/vite.config.ts b/vite.config.ts index 81dfc3d..59e1d7a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig(async ({ command, mode }) => { insertTypesEntry: true, include: ['src'], exclude: ['examples', 'tests'], + processor: 'vue', }) ) }