Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/cli/src/services/check-parser/__tests__/bundler.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
}])
})
})
48 changes: 43 additions & 5 deletions packages/cli/src/services/check-parser/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -307,22 +308,59 @@ export class Bundler {
}

async finalize (): Promise<FinalizedBundleArchive> {
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<File[]> {
const physicalFiles = files.filter(file => file.physical)
const symlinkPaths = new Set<string>()
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<string>()
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<Archiver> {
// 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
Expand Down
Loading