diff --git a/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts new file mode 100644 index 00000000..e96a04fc --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts @@ -0,0 +1,113 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { list } from 'tar' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { Bundler } from '../bundler.js' + +describe('Bundler', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'checkly-bundler-')) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('does not archive a symlink when files below it are selected', async () => { + const packagePath = path.join(root, 'packages', 'shared-package') + const linkedPackagePath = path.join( + root, + 'packages', + 'app', + 'node_modules', + '@workspace', + 'shared-package', + ) + const packageJsonPath = path.join(packagePath, 'package.json') + const sourcePath = path.join(packagePath, 'src', 'index.js') + const linkedPackageJsonPath = path.join(linkedPackagePath, 'package.json') + const linkedSourcePath = path.join(linkedPackagePath, 'src', 'index.js') + + await mkdir(path.dirname(linkedPackagePath), { recursive: true }) + await mkdir(path.dirname(sourcePath), { recursive: true }) + await writeFile(packageJsonPath, '{"name":"@workspace/shared-package"}') + await writeFile(sourcePath, 'export const value = true\n') + await symlink('../../../shared-package', linkedPackagePath, 'dir') + + const bundler = await Bundler.create({ + cacheHash: 'cache-hash', + tempDir: root, + stripPrefix: root, + }) + bundler.registerFiles( + { filePath: packageJsonPath, physical: true }, + { filePath: sourcePath, physical: true }, + { filePath: linkedPackagePath, physical: true }, + { filePath: linkedPackageJsonPath, physical: true }, + { filePath: linkedSourcePath, physical: true }, + ) + + const archive = await bundler.finalize() + const archivePaths: string[] = [] + await list({ + file: archive.archiveFile, + onReadEntry: entry => archivePaths.push(entry.path), + }) + + expect(archivePaths.sort()).toEqual([ + 'packages/app/node_modules/@workspace/shared-package/package.json', + 'packages/app/node_modules/@workspace/shared-package/src/index.js', + 'packages/shared-package/package.json', + 'packages/shared-package/src/index.js', + ]) + }) + + it('archives a symlink when no files below it are selected', async () => { + const packagePath = path.join(root, 'packages', 'shared-package') + const linkedPackagePath = path.join( + root, + 'packages', + 'app', + 'node_modules', + '@workspace', + 'shared-package', + ) + + await mkdir(path.dirname(linkedPackagePath), { recursive: true }) + await mkdir(packagePath, { recursive: true }) + await symlink('../../../shared-package', linkedPackagePath, 'dir') + + const bundler = await Bundler.create({ + cacheHash: 'cache-hash', + tempDir: root, + stripPrefix: root, + }) + bundler.registerFiles({ filePath: linkedPackagePath, physical: true }) + + const archive = await bundler.finalize() + const archiveEntries: Array<{ + path: string + type: string + linkpath: string + }> = [] + await list({ + file: archive.archiveFile, + onReadEntry: entry => archiveEntries.push({ + path: entry.path, + type: entry.type, + linkpath: entry.linkpath, + }), + }) + + expect(archiveEntries).toEqual([{ + path: 'packages/app/node_modules/@workspace/shared-package', + type: 'SymbolicLink', + linkpath: '../../../shared-package', + }]) + }) +}) diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index d8c2ccb8..d654e6ae 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -14,6 +14,7 @@ import { File } from './parser.js' import { Workspace } from './package-files/workspace.js' const debug = Debug('checkly:cli:services:check-parser:bundler') +const FILESYSTEM_CONCURRENCY = 32 export interface CreateBundleArchiveOptions { tempDir?: string @@ -307,22 +308,59 @@ export class Bundler { } async finalize (): Promise { + let files = Array.from(this.#files.values()) + files.sort((a, b) => { + return a.filePath.localeCompare(b.filePath) + }) + files = await omitSymlinksWithSelectedDescendants(files) + const archive = await BundleArchive.create({ tempDir: this.#tempDir, stripPrefix: this.#stripPrefix, }) - const files = Array.from(this.#files.values()) - files.sort((a, b) => { - return a.filePath.localeCompare(b.filePath) - }) - await archive.add(...files) return await archive.finalize() } } +async function omitSymlinksWithSelectedDescendants (files: File[]): Promise { + const physicalFiles = files.filter(file => file.physical) + const symlinkPaths = new Set() + for (let offset = 0; offset < physicalFiles.length; offset += FILESYSTEM_CONCURRENCY) { + const batch = physicalFiles.slice(offset, offset + FILESYSTEM_CONCURRENCY) + const symlinks = await Promise.all(batch.map(async file => { + if (!(await fs.lstat(file.filePath)).isSymbolicLink()) { + return undefined + } + + return path.resolve(file.filePath) + })) + for (const symlink of symlinks) { + if (symlink !== undefined) { + symlinkPaths.add(symlink) + } + } + } + + const symlinksWithSelectedDescendants = new Set() + for (const file of files) { + const filePath = path.resolve(file.filePath) + let parentPath = path.dirname(filePath) + while (parentPath !== path.dirname(parentPath)) { + if (symlinkPaths.has(parentPath)) { + symlinksWithSelectedDescendants.add(parentPath) + } + parentPath = path.dirname(parentPath) + } + } + + return files.filter(file => { + return !symlinksWithSelectedDescendants.has(path.resolve(file.filePath)) + }) +} + async function createArchiver (): Promise { // Dynamic import for CommonJs so it doesn't break when using checkly/playwright-reporter archiver // The custom Checkly fork of archiver exports TarArchive class instead of a default function