diff --git a/packages/cli/src/commands/baseCommand.ts b/packages/cli/src/commands/baseCommand.ts index 9346b14b2..c6d60ede0 100644 --- a/packages/cli/src/commands/baseCommand.ts +++ b/packages/cli/src/commands/baseCommand.ts @@ -3,8 +3,8 @@ import prompts from 'prompts' import { Command } from '@oclif/core' import { api } from '../rest/api' import { CommandStyle } from '../helpers/command-style' -import { PackageFilesResolver } from '../services/check-parser/package-files/resolver' import { PackageJsonFile } from '../services/check-parser/package-files/package-json-file' +import { detectNearestPackageJson } from '../services/check-parser/package-files/package-manager' export type BaseCommandClass = typeof Command & { coreCommand: boolean @@ -18,12 +18,15 @@ export abstract class BaseCommand extends Command { #packageJsonLoader?: Promise async loadPackageJsonOfSelf (): Promise { - if (!this.#packageJsonLoader) { - const resolver = new PackageFilesResolver() - this.#packageJsonLoader = resolver.loadPackageJsonFile(__filename) - } + try { + if (!this.#packageJsonLoader) { + this.#packageJsonLoader = detectNearestPackageJson(__dirname) + } - return await this.#packageJsonLoader + return await this.#packageJsonLoader + } catch { + return + } } async checkEngineCompatibility (): Promise { diff --git a/packages/cli/src/commands/import/plan.ts b/packages/cli/src/commands/import/plan.ts index a81304f0a..d9902347a 100644 --- a/packages/cli/src/commands/import/plan.ts +++ b/packages/cli/src/commands/import/plan.ts @@ -26,9 +26,8 @@ import { ExitError } from '@oclif/core/errors' import { confirmCommit, performCommitAction } from './commit' import { confirmApply, performApplyAction } from './apply' import { generateChecklyConfig } from '../../services/checkly-config-codegen' -import { PackageFilesResolver } from '../../services/check-parser/package-files/resolver' import { PackageJsonFile } from '../../services/check-parser/package-files/package-json-file' -import { detectPackageManager, knownPackageManagers, PackageManager } from '../../services/check-parser/package-files/package-manager' +import { detectNearestPackageJson, detectPackageManager, knownPackageManagers, PackageManager } from '../../services/check-parser/package-files/package-manager' import { parseProject } from '../../services/project-parser' import { Runtime } from '../../rest/runtimes' import { ConstructExport, Project, Session } from '../../constructs/project' @@ -704,10 +703,11 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co } async #loadPackageJson (): Promise { - const resolver = new PackageFilesResolver() - return await resolver.loadPackageJsonFile(process.cwd(), { - isDir: true, - }) + try { + return await detectNearestPackageJson(process.cwd()) + } catch { + return + } } #createPackageJson (logicalId: string): PackageJsonFile { diff --git a/packages/cli/src/constructs/browser-check.ts b/packages/cli/src/constructs/browser-check.ts index a06c654b7..9e1f2cca1 100644 --- a/packages/cli/src/constructs/browser-check.ts +++ b/packages/cli/src/constructs/browser-check.ts @@ -1,9 +1,7 @@ import fs from 'node:fs/promises' -import path from 'node:path' import { CheckProps, RuntimeCheck, RuntimeCheckProps } from './check' import { Session, SharedFileRef } from './project' -import { pathToPosix } from '../services/util' import { Content, Entrypoint, isContent, isEntrypoint } from './construct' import { detectSnapshots } from '../services/snapshot-service' import { PlaywrightConfig } from './playwright-config' @@ -168,7 +166,7 @@ export class BrowserCheck extends RuntimeCheck { const deps: SharedFileRef[] = [] for (const { filePath, content } of parsed.dependencies) { deps.push(Session.registerSharedFile({ - path: pathToPosix(path.relative(Session.basePath!, filePath)), + path: Session.relativePosixPath(filePath), content, })) } diff --git a/packages/cli/src/constructs/check-group-v1.ts b/packages/cli/src/constructs/check-group-v1.ts index 213c5d62b..31b539b1d 100644 --- a/packages/cli/src/constructs/check-group-v1.ts +++ b/packages/cli/src/constructs/check-group-v1.ts @@ -17,7 +17,6 @@ import { Diagnostics } from './diagnostics' import { DeprecatedConstructDiagnostic, DeprecatedPropertyDiagnostic, InvalidPropertyValueDiagnostic } from './construct-diagnostics' import CheckTypes from '../constants' import { CheckConfigDefaults } from '../services/checkly-config-loader' -import { pathToPosix } from '../services/util' import { AlertChannelSubscription } from './alert-channel-subscription' import { BrowserCheck } from './browser-check' import { CheckGroupRef } from './check-group-ref' @@ -438,7 +437,7 @@ export class CheckGroupV1 extends Construct { }, // the browserChecks props inherited from the group are applied in BrowserCheck.constructor() } - const checkLogicalId = pathToPosix(path.relative(Session.basePath!, filepath)) + const checkLogicalId = Session.relativePosixPath(filepath) if (checkType === CheckTypes.BROWSER) { new BrowserCheck(checkLogicalId, props) } else { diff --git a/packages/cli/src/constructs/internal/codegen/snippet.ts b/packages/cli/src/constructs/internal/codegen/snippet.ts index 1d87c81fd..a875a9840 100644 --- a/packages/cli/src/constructs/internal/codegen/snippet.ts +++ b/packages/cli/src/constructs/internal/codegen/snippet.ts @@ -54,6 +54,6 @@ export function parseSnippetDependencies (content: string): string[] { } return dependencies - .filter(value => value.startsWith(SNIPPET_PATH_PREFIX)) - .map(value => value.slice(SNIPPET_PATH_PREFIX.length)) + .filter(({ importPath }) => importPath.startsWith(SNIPPET_PATH_PREFIX)) + .map(({ importPath }) => importPath.slice(SNIPPET_PATH_PREFIX.length)) } diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 6fd5f56a2..8031d7afa 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -24,6 +24,7 @@ import { Diagnostics } from './diagnostics' import { ConstructDiagnostics, InvalidPropertyValueDiagnostic } from './construct-diagnostics' import { ProjectBundle, ProjectDataBundle } from './project-bundle' import { pathToPosix } from '../services/util' +import { Workspace } from '../services/check-parser/package-files/workspace' export interface ProjectProps { /** @@ -243,6 +244,7 @@ export class Session { static privateLocations: PrivateLocationApi[] static parsers = new Map() static constructExports: ConstructExport[] = [] + static workspace?: Workspace static async loadFile (filePath: string): Promise { const loader = this.loader @@ -337,6 +339,7 @@ export class Session { const parser = new Parser({ supportedNpmModules: Object.keys(runtime.dependencies), checkUnsupportedModules: Session.verifyRuntimeDependencies, + workspace: Session.workspace, }) Session.parsers.set(runtime.name, parser) diff --git a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts index cf88fd8d6..2f1146754 100644 --- a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts +++ b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { describe, it, expect } from 'vitest' -import { loadChecklyConfig } from '../checkly-config-loader' +import { loadChecklyConfig, defaultFilenames } from '../checkly-config-loader' import { splitConfigFilePath } from '../util' describe('loadChecklyConfig()', () => { @@ -25,8 +25,10 @@ describe('loadChecklyConfig()', () => { try { await loadChecklyConfig(configDir) } catch (e: any) { - expect(e.message).toContain(`Unable to locate a config at ${configDir} with ${ - ['checkly.config.ts', 'checkly.config.mts', 'checkly.config.cts', 'checkly.config.js', 'checkly.config.mjs', 'checkly.config.cjs'].join(', ')}.`) + expect(e.message).toContain(`Unable to detect a Checkly configuration file`) + for (const filename of defaultFilenames) { + expect(e.message).toContain(filename) + } } }) it('config TS file should export an object', async () => { diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package-lock.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package-lock.json new file mode 100644 index 000000000..ed7a7f891 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "workspace-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workspace-example", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ] + }, + "node_modules/bar": { + "resolved": "packages/bar", + "link": true + }, + "node_modules/baz": { + "resolved": "packages/baz", + "link": true + }, + "node_modules/foo": { + "resolved": "packages/foo", + "link": true + }, + "packages/bar": { + "version": "1.0.0", + "dependencies": { + "baz": "*", + "foo": "*" + } + }, + "packages/baz": { + "version": "1.0.0" + }, + "packages/foo": { + "version": "1.0.0" + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package.json new file mode 100644 index 000000000..6f6c36ca4 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/package.json @@ -0,0 +1,7 @@ +{ + "name": "workspace-example", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/checkly.config.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/checkly.config.ts new file mode 100644 index 000000000..fffb104f3 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/checkly.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Workspace Example Project', + logicalId: '56fbaf4d-fc2c-418c-868a-3f461809ed37', + checks: { + checkMatch: '**/__checks__/**/*.check.ts', + tags: [ + 'mac', + ], + }, +}) + +export default config diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/package.json new file mode 100644 index 000000000..f4d29576a --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/package.json @@ -0,0 +1,12 @@ +{ + "name": "bar", + "version": "1.0.0", + "main": "dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "dependencies": { + "foo": "*", + "baz": "*" + } +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/api.check.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/api.check.ts new file mode 100644 index 000000000..23491303b --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/api.check.ts @@ -0,0 +1,12 @@ +import { ApiCheck } from 'checkly/constructs' + +new ApiCheck('api-check-1', { + name: 'API Check #1', + request: { + url: 'https://api.checklyhq.com', + method: 'GET', + }, + setupScript: { + entrypoint: './setup.ts' + } +}) diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/setup.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/setup.ts new file mode 100644 index 000000000..9e9a34d15 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/__checks__/api-check-1/setup.ts @@ -0,0 +1,3 @@ +import { value } from '@lib/helper' + +console.log(value) diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/index.ts new file mode 100644 index 000000000..5ff6d2ff1 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/index.ts @@ -0,0 +1 @@ +export { value } from '@lib/helper' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/lib/helper.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/lib/helper.ts new file mode 100644 index 000000000..3e02cd133 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/src/lib/helper.ts @@ -0,0 +1,2 @@ +export { value } from 'foo' +export { value as value2 } from 'baz/lib/dep2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/tsconfig.json new file mode 100644 index 000000000..6d5cf4dfe --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/bar/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "nodenext", + "esModuleInterop": true, + "outDir": "dist", + "rootDirs": [ + "src", + ], + "baseUrl": "./src", + "strict": true, + "target": "esnext", + "sourceMap": true, + "paths": { + "@lib/*": [ + "./lib/*" + ] + } + }, + "include": [ + "src/**/*", + ], +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/package.json new file mode 100644 index 000000000..cb030520a --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/package.json @@ -0,0 +1,9 @@ +{ + "name": "baz", + "version": "1.0.0", + "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./lib/dep2": "./dist/lib/dep2.js" + } +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/index.ts new file mode 100644 index 000000000..435db7ccb --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/index.ts @@ -0,0 +1 @@ +export { value } from './lib/dep1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep1.ts new file mode 100644 index 000000000..1d772ee2c --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep1.ts @@ -0,0 +1 @@ +export const value = 1 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep2.ts new file mode 100644 index 000000000..3aee44a1e --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/src/lib/dep2.ts @@ -0,0 +1 @@ +export const value = 2 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/tsconfig.json new file mode 100644 index 000000000..8c751e160 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/baz/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "nodenext", + "esModuleInterop": true, + "outDir": "dist", + "rootDirs": [ + "src" + ], + "strict": true, + "target": "esnext", + "sourceMap": true + }, + "include": [ + "src/**/*" + ], +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/package.json new file mode 100644 index 000000000..81e70e9bb --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "version": "1.0.0", + "main": "dist/index.js", + "exports": { + ".": "./dist/index.js" + } +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/index.ts new file mode 100644 index 000000000..435db7ccb --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/index.ts @@ -0,0 +1 @@ +export { value } from './lib/dep1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/lib/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/lib/dep1.ts new file mode 100644 index 000000000..1d772ee2c --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/src/lib/dep1.ts @@ -0,0 +1 @@ +export const value = 1 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/tsconfig.json new file mode 100644 index 000000000..8c751e160 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/packages/foo/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "nodenext", + "esModuleInterop": true, + "outDir": "dist", + "rootDirs": [ + "src" + ], + "strict": true, + "target": "esnext", + "sourceMap": true + }, + "include": [ + "src/**/*" + ], +} \ No newline at end of file diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/workspace-example/tsconfig.json new file mode 100644 index 000000000..e69de29bb diff --git a/packages/cli/src/services/check-parser/package-files/json-source-file.ts b/packages/cli/src/services/check-parser/package-files/json-source-file.ts index 521da653d..0b274c586 100644 --- a/packages/cli/src/services/check-parser/package-files/json-source-file.ts +++ b/packages/cli/src/services/check-parser/package-files/json-source-file.ts @@ -26,4 +26,13 @@ export class JsonSourceFile { // Ignore. } } + + static async loadFromFilePath (filePath: string): Promise | undefined> { + const sourceFile = await SourceFile.loadFromFilePath(filePath) + if (!sourceFile) { + return + } + + return JsonSourceFile.loadFromSourceFile(sourceFile) + } } diff --git a/packages/cli/src/services/check-parser/package-files/json-text-source-file-parser.ts b/packages/cli/src/services/check-parser/package-files/json-text-source-file-parser.ts index 08c0858f3..3d357c84b 100644 --- a/packages/cli/src/services/check-parser/package-files/json-text-source-file-parser.ts +++ b/packages/cli/src/services/check-parser/package-files/json-text-source-file-parser.ts @@ -6,7 +6,8 @@ class UninitializedJsonTextSourceFileParserState extends SourceFileParser { async #parser (): Promise { try { - const { parseJsonText, convertToObject } = await import('typescript') + const typescriptExports = await import('typescript') + const { parseJsonText, convertToObject } = typescriptExports.default const parser = new SourceFileParserFuncState((sourceFile: SourceFile) => { const errors: any[] = [] diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts index e2fd55e3f..92c052244 100644 --- a/packages/cli/src/services/check-parser/package-files/package-json-file.ts +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -4,9 +4,24 @@ import semver from 'semver' import { JsonSourceFile } from './json-source-file' import { FileMeta, SourceFile } from './source-file' +import { PathResolver, ResolveResult } from './paths' -type ExportCondition = - 'node-addons' | 'node' | 'import' | 'require' | 'module-sync' | 'default' +type ConditionKey = + | 'node-addons' + | 'node' + | 'import' + | 'require' + | 'module-sync' + | 'default' + // Allow any string value, but keep auto complete for known values. + | (string & Record) + +type Exports = + | string + | Record> + +type Imports = + | Record> type Schema = { name?: string @@ -14,10 +29,12 @@ type Schema = { license?: string main?: string engines?: Record - exports?: string | string[] | Record | Record> + exports?: Exports + imports?: Imports dependencies?: Record devDependencies?: Record private?: boolean + workspaces?: string[] } export interface EngineSupportResult { @@ -47,10 +64,66 @@ export class PackageJsonFile { : [fallbackMainPath] } + hasExports (): boolean { + return !!this.jsonFile.data.exports + } + + resolveExportPath (exportPath: string, conditions: ConditionKey[]): ResolveResult { + const resolver = PathResolver.createFromPaths( + this.basePath, + this.#resolveExports(this.jsonFile.data.exports ?? {}, conditions), + ) + + // Exports must always start with "./" - make sure that the path we're + // matching against also starts with that prefix. + if (!exportPath.startsWith('./')) { + exportPath = `./${exportPath}` + } + + return resolver.resolve(exportPath) + } + + #resolveExports (exports: Exports, conditions: ConditionKey[]): Record { + if (typeof exports === 'string') { + return { + '.': [exports], + } + } + + const resolved: Record = {} + + Resolve: + for (const [from, rules] of Object.entries(exports)) { + if (typeof rules === 'string') { + resolved[from] = [rules] + continue Resolve + } + + for (const [condition, to] of Object.entries(rules)) { + if (conditions.includes(condition)) { + resolved[from] = [to] + continue Resolve + } + } + + const fallback = rules['default'] + if (fallback) { + resolved[from] = [fallback] + continue Resolve + } + } + + return resolved + } + public get meta () { return this.jsonFile.meta } + public get name () { + return this.jsonFile.data.name + } + public get version () { return this.jsonFile.data.version } @@ -67,6 +140,10 @@ export class PackageJsonFile { return this.jsonFile.data.engines } + public get workspaces () { + return this.jsonFile.data.workspaces + } + supportsEngine (engine: string, version: string): EngineSupportResult { const requirements = this.engines?.[engine] if (requirements === undefined) { @@ -105,6 +182,24 @@ export class PackageJsonFile { return new PackageJsonFile(jsonFile) } + static async loadFromSourceFile (sourceFile: SourceFile): Promise { + const jsonSourceFile = await JsonSourceFile.loadFromSourceFile(sourceFile) + if (!jsonSourceFile) { + return + } + + return PackageJsonFile.loadFromJsonSourceFile(jsonSourceFile) + } + + static async loadFromFilePath (filePath: string): Promise { + const sourceFile = await SourceFile.loadFromFilePath(filePath) + if (!sourceFile) { + return + } + + return PackageJsonFile.loadFromSourceFile(sourceFile) + } + static filePath (dirPath: string) { return path.join(dirPath, PackageJsonFile.FILENAME) } diff --git a/packages/cli/src/services/check-parser/package-files/package-manager.ts b/packages/cli/src/services/check-parser/package-files/package-manager.ts index 89a697191..34e4f9116 100644 --- a/packages/cli/src/services/check-parser/package-files/package-manager.ts +++ b/packages/cli/src/services/check-parser/package-files/package-manager.ts @@ -2,6 +2,9 @@ import fs from 'node:fs/promises' import path from 'node:path' import { lineage } from './walk' +import { PackageJsonFile } from './package-json-file' +import { JsonSourceFile } from './json-source-file' +import { lookupNearestPackageJsonWorkspace, Package, Workspace } from './workspace' export class Runnable { executable: string @@ -38,6 +41,7 @@ export interface PackageManager { get name (): string installCommand (): Runnable execCommand (args: string[]): Runnable + lookupWorkspace (dir: string): Promise } class NotDetectedError extends Error {} @@ -47,10 +51,13 @@ export abstract class PackageManagerDetector { abstract detectUserAgent (userAgent: string): boolean abstract detectRuntime (): boolean abstract get representativeLockfile (): string | undefined + abstract get representativeConfigFile (): string | undefined abstract detectLockfile (dir: string): Promise + abstract detectConfigFile (dir: string): Promise abstract detectExecutable (lookup: PathLookup): Promise abstract installCommand (): Runnable abstract execCommand (args: string[]): Runnable + abstract lookupWorkspace (dir: string): Promise } export class NpmDetector extends PackageManagerDetector implements PackageManager { @@ -70,10 +77,19 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage return 'package-lock.json' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('npm') } @@ -85,6 +101,10 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage execCommand (args: string[]): Runnable { return new Runnable('npx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class CNpmDetector extends PackageManagerDetector implements PackageManager { @@ -104,11 +124,20 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag return } + get representativeConfigFile (): undefined { + return + } + // eslint-disable-next-line require-await async detectLockfile (): Promise { throw new NotDetectedError() } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('cnpm') } @@ -120,6 +149,10 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('npx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class PNpmDetector extends PackageManagerDetector implements PackageManager { @@ -139,10 +172,18 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag return 'pnpm-lock.yaml' } + get representativeConfigFile (): string { + return 'pnpm-workspace.yaml' + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + async detectConfigFile (dir: string): Promise { + return await accessR(path.join(dir, this.representativeConfigFile)) + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('pnpm') } @@ -154,6 +195,67 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('pnpm', args) } + + async lookupWorkspace (dir: string): Promise { + // To avoid having to bring in a yaml parser, just call pnpm directly. + // However, to avoid calling pnpm if it's likely not installed, detect + // the presence of the workspace file first. + for (const searchPath of lineage(dir)) { + try { + await this.detectConfigFile(searchPath) + } catch { + continue + } + + const { execa } = await import('execa') + + const pnpmArgs = [ + 'list', + '--json', + '--only-projects', + '--workspace-root', + ] + + const result = await execa('pnpm', pnpmArgs, { + cwd: searchPath, + }) + + type ListOnlyProjectsOutput = { + name: string + path: string + dependencies: Record + }[] + + const output: ListOnlyProjectsOutput = JSON.parse(result.stdout) + if (!Array.isArray(output)) { + throw new Error(`The output of 'pnpm list' was not an array (stdout=${result.stdout}, stderr=${result.stderr})`) + } + + if (output.length !== 1) { + return + } + + const project = output[0] + + const rootPackage = new Package({ + name: project.name, + path: project.path, + workspaces: Object.values(project.dependencies).map(dep => dep.path), + }) + + const workspacePackages = Object.entries(project.dependencies).map(([name, { path }]) => { + return new Package({ + name, + path, + }) + }) + + return new Workspace( + rootPackage, + workspacePackages, + ) + } + } } export class YarnDetector extends PackageManagerDetector implements PackageManager { @@ -173,10 +275,19 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag return 'yarn.lock' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('yarn') } @@ -188,6 +299,10 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('yarn', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class DenoDetector extends PackageManagerDetector implements PackageManager { @@ -207,10 +322,18 @@ export class DenoDetector extends PackageManagerDetector implements PackageManag return 'deno.lock' } + get representativeConfigFile (): string { + return 'deno.json' + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + async detectConfigFile (dir: string): Promise { + return await accessR(path.join(dir, this.representativeConfigFile)) + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('deno') } @@ -222,6 +345,51 @@ export class DenoDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('deno', ['run', '-A', `npm:${args[0]}`, ...args.slice(1)]) } + + async lookupWorkspace (dir: string): Promise { + for (const searchPath of lineage(dir)) { + try { + const configFile = await this.detectConfigFile(searchPath) + + type Schema = { + workspace?: string[] + } + + const jsonFile = await JsonSourceFile.loadFromFilePath(configFile) + if (!jsonFile) { + continue + } + + const rootPackage = await Package.loadFromDirPath(searchPath) + if (rootPackage === undefined) { + continue + } + + const workspaces = jsonFile.data.workspace?.map(packagePath => { + return path.resolve(searchPath, packagePath) + }) + + if (!workspaces) { + continue + } + + const packages: Package[] = [] + + for (const workspace of workspaces) { + const workspacePackage = await Package.loadFromDirPath(workspace) + if (workspacePackage === undefined) { + continue + } + + packages.push(workspacePackage) + } + + return new Workspace(rootPackage, packages) + } catch { + continue + } + } + } } export class BunDetector extends PackageManagerDetector implements PackageManager { @@ -241,10 +409,19 @@ export class BunDetector extends PackageManagerDetector implements PackageManage return 'bun.lockb' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('bun') } @@ -256,6 +433,10 @@ export class BunDetector extends PackageManagerDetector implements PackageManage execCommand (args: string[]): Runnable { return new Runnable('bunx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } async function accessR (filePath: string): Promise { @@ -402,6 +583,18 @@ export async function detectPackageManager ( // Nothing detected. } + // Next, try to find a config file. + try { + const { packageManager } = await detectNearestConfigFile(dir, { + detectors, + root: options?.root, + }) + + return packageManager + } catch { + // Nothing detected. + } + // Finally, try to find a relevant executable. // // This can generate a whole bunch of path lookups. Try one by one despite @@ -477,3 +670,98 @@ export async function detectNearestLockfile ( throw new NoLockfileFoundError(searchPaths, lockfiles) } + +export interface NearestConfigFile { + packageManager: PackageManager + configFile: string +} + +export class NoConfigFileFoundError extends Error { + searchPaths: string[] + configFiles: string[] + + constructor (searchPaths: string[], configFiles: string[], options?: ErrorOptions) { + const message = `Unable to detect a config file in any of the following paths:` + + `\n\n` + + `${searchPaths.map(searchPath => ` ${searchPath}`).join('\n')}` + + `\n\n` + + `Config files we looked for:` + + `\n\n` + + `${configFiles.map(lockfile => ` ${lockfile}`).join('\n')}` + super(message, options) + this.name = 'NoConfigFileFoundError' + this.searchPaths = searchPaths + this.configFiles = configFiles + } +} + +export async function detectNearestConfigFile ( + dir: string, + options?: DetectOptions, +): Promise { + const detectors = options?.detectors ?? knownPackageManagers + + const searchPaths: string[] = [] + + for (const searchPath of lineage(dir, { root: options?.root })) { + try { + searchPaths.push(searchPath) + + // Assume that only a single kind of config file exists, which means + // the resolve order does not matter. + return await Promise.any(detectors.map(async detector => { + const configFile = await detector.detectConfigFile(searchPath) + return { + packageManager: detector, + configFile, + } + })) + } catch { + // Nothing detected. + } + } + + const configFiles = detectors.reduce((acc, detector) => { + return acc.concat(detector.representativeConfigFile ?? []) + }, []) + + throw new NoConfigFileFoundError(searchPaths, configFiles) +} + +export class NoPackageJsonFoundError extends Error { + searchPaths: string[] + + constructor (searchPaths: string[], options?: ErrorOptions) { + const message = `Unable to detect a package.json in any of the following paths:` + + `\n\n` + + `${searchPaths.map(searchPath => ` ${searchPath}`).join('\n')}` + super(message, options) + this.name = 'NoPackageJsonFoundError' + this.searchPaths = searchPaths + } +} + +export interface DetectNearestPackageJsonOptions { + root?: string +} + +export async function detectNearestPackageJson ( + dir: string, + options?: DetectNearestPackageJsonOptions, +): Promise { + const searchPaths: string[] = [] + + for (const searchPath of lineage(dir, { root: options?.root })) { + searchPaths.push(searchPath) + + const packageJson = await PackageJsonFile.loadFromFilePath( + PackageJsonFile.filePath(searchPath), + ) + + if (packageJson) { + return packageJson + } + } + + throw new NoPackageJsonFoundError(searchPaths) +} diff --git a/packages/cli/src/services/check-parser/package-files/paths.ts b/packages/cli/src/services/check-parser/package-files/paths.ts index 818d60451..5f9bc6f6f 100644 --- a/packages/cli/src/services/check-parser/package-files/paths.ts +++ b/packages/cli/src/services/check-parser/package-files/paths.ts @@ -275,3 +275,33 @@ export function isBuiltinPath (importPath: string) { return false } + +export function isImportsPath (importPath: string) { + if (importPath.startsWith('#')) { + return true + } + + return false +} + +export interface ExternalPath { + name: string + path: string +} + +export function splitExternalPath (importPath: string): ExternalPath { + if (importPath.startsWith('@')) { + const [namespace, pkg, ...rest] = importPath.split('/') + return { + name: pkg ? `${namespace}/${pkg}` : namespace, + path: rest.join('/'), + } + } + + const [pkg, ...rest] = importPath.split('/') + + return { + name: pkg, + path: rest.join('/'), + } +} diff --git a/packages/cli/src/services/check-parser/package-files/resolver.ts b/packages/cli/src/services/check-parser/package-files/resolver.ts index 899e04373..8271c2bc7 100644 --- a/packages/cli/src/services/check-parser/package-files/resolver.ts +++ b/packages/cli/src/services/check-parser/package-files/resolver.ts @@ -4,12 +4,13 @@ import { SourceFile } from './source-file' import { PackageJsonFile } from './package-json-file' import { TSConfigFile } from './tsconfig-json-file' import { JSConfigFile } from './jsconfig-json-file' -import { isBuiltinPath, isLocalPath, PathResult } from './paths' +import { isBuiltinPath, isImportsPath, isLocalPath, PathResult, splitExternalPath } from './paths' import { FileLoader, LoadFile } from './loader' import { JsonSourceFile } from './json-source-file' import { JsonTextSourceFile } from './json-text-source-file' import { LookupContext } from './lookup' -import { walkUp, WalkUpOptions } from './walk' +import { lineage, LineageOptions } from './walk' +import { Workspace } from './workspace' class PackageFilesCache { #sourceFileCache = new FileLoader(SourceFile.loadFromFilePath) @@ -112,6 +113,13 @@ class PackageFiles { } } +export type RawDependencySource = 'require' | 'import' + +export type RawDependency = { + importPath: string + source: RawDependencySource +} + type TSConfigFileLocalDependency = { kind: 'tsconfig-file' importPath: string @@ -140,11 +148,18 @@ type RelativePathLocalDependency = { sourceFile: SourceFile } +type WorkspacePackageLocalDependency = { + kind: 'workspace-package-path' + importPath: string + sourceFile: SourceFile +} + type LocalDependency = TSConfigFileLocalDependency | TSConfigResolvedPathLocalDependency | TSConfigBaseUrlRelativePathLocalDependency | RelativePathLocalDependency + | WorkspacePackageLocalDependency type MissingDependency = { importPath: string @@ -161,32 +176,37 @@ export type Dependencies = { local: LocalDependency[] } +interface ResolveSourceFileOptions { + exportPath?: string + source?: RawDependencySource +} + export class PackageFilesResolver { cache = new PackageFilesCache() + workspace?: Workspace - async loadPackageJsonFile (filePath: string, options?: WalkUpOptions): Promise { - let packageJson: PackageJsonFile | undefined - - await walkUp(filePath, async dirPath => { - packageJson = await this.cache.packageJson(PackageJsonFile.filePath(dirPath)) - return packageJson !== undefined - }, options) - - return packageJson + constructor (workspace?: Workspace) { + this.workspace = workspace } - async loadPackageFiles (filePath: string, options?: WalkUpOptions): Promise { + async loadPackageFiles (filePath: string, options?: LineageOptions): Promise { const files = new PackageFiles() - await walkUp(filePath, async dirPath => { - const found = await files.satisfyFromDirPath(dirPath, this.cache) - return found - }, options) + for (const searchPath of lineage(path.dirname(filePath), options)) { + const found = await files.satisfyFromDirPath(searchPath, this.cache) + if (found) { + break + } + } return files } - private async resolveSourceFile (sourceFile: SourceFile, context: LookupContext): Promise { + private async resolveSourceFile ( + sourceFile: SourceFile, + context: LookupContext, + options?: ResolveSourceFileOptions, + ): Promise { if (sourceFile.meta.basename === PackageJsonFile.FILENAME) { const packageJson = await this.cache.packageJson(sourceFile.meta.filePath) if (packageJson === undefined) { @@ -195,11 +215,35 @@ export class PackageFilesResolver { return [sourceFile] } + const { + exportPath = '', + source = 'import', + } = options ?? {} + + const searchPaths: string[] = [] + + if (packageJson.hasExports()) { + const { root, paths } = packageJson.resolveExportPath(exportPath, [ + source, + 'node', + 'module-sync', + 'default', + ]) + + for (const { target: targetPath } of paths) { + searchPaths.push(path.resolve(root, targetPath.path)) + } + } + + if (searchPaths.length === 0 && exportPath === '') { + searchPaths.push(...packageJson.mainPaths) + } + // Go through each main path. A fallback path is included. If we can // find a tsconfig for the main file, look it up and attempt to find // the original TypeScript sources roughly the same way tsc does it. - for (const mainPath of packageJson.mainPaths) { - const { tsconfigJson, jsconfigJson } = await this.loadPackageFiles(mainPath, { + for (const searchPath of searchPaths) { + const { tsconfigJson, jsconfigJson } = await this.loadPackageFiles(searchPath, { root: packageJson.basePath, }) @@ -209,7 +253,7 @@ export class PackageFilesResolver { continue } - const candidatePaths = configJson.collectLookupPaths(mainPath).flatMap(filePath => { + const candidatePaths = configJson.collectLookupPaths(searchPath).flatMap(filePath => { return context.collectLookupPaths(filePath) }) for (const candidatePath of candidatePaths) { @@ -224,7 +268,7 @@ export class PackageFilesResolver { } } - const mainSourceFile = await this.cache.sourceFile(mainPath, context) + const mainSourceFile = await this.cache.sourceFile(searchPath, context) if (mainSourceFile === undefined) { continue } @@ -241,7 +285,7 @@ export class PackageFilesResolver { async resolveDependenciesForFilePath ( filePath: string, - dependencies: string[], + dependencies: RawDependency[], ): Promise { const resolved: Dependencies = { external: [], @@ -256,7 +300,7 @@ export class PackageFilesResolver { const context = LookupContext.forFilePath(filePath) resolve: - for (const importPath of dependencies) { + for (const { importPath, source } of dependencies) { if (isBuiltinPath(importPath)) { resolved.external.push({ importPath, @@ -363,6 +407,37 @@ export class PackageFilesResolver { } } + if (isImportsPath(importPath)) { + // TODO + continue resolve + } + + if (this.workspace) { + const { name, path: exportPath } = splitExternalPath(importPath) + const pkg = this.workspace.memberByName(name) + if (pkg) { + const sourceFile = await this.cache.sourceFile(pkg.path, context) + if (sourceFile !== undefined) { + const resolvedFiles = await this.resolveSourceFile(sourceFile, context, { + exportPath, + source, + }) + let found = false + for (const resolvedFile of resolvedFiles) { + resolved.local.push({ + kind: 'workspace-package-path', + importPath, + sourceFile: resolvedFile, + }) + found = true + } + if (found) { + continue resolve + } + } + } + } + resolved.external.push({ importPath, }) diff --git a/packages/cli/src/services/check-parser/package-files/workspace.ts b/packages/cli/src/services/check-parser/package-files/workspace.ts new file mode 100644 index 000000000..f4554102c --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/workspace.ts @@ -0,0 +1,148 @@ +import path from 'node:path' + +import { glob } from 'glob' + +import { PackageJsonFile } from './package-json-file' +import { lineage } from './walk' + +export interface PackageOptions { + /** + * The name of the package. + */ + name: string + + /** + * An absolute path to the package directory. + */ + path: string + + /** + * Whether the package is a workspace. + */ + workspaces?: string[] +} + +export class Package { + name: string + path: string + workspaces?: string[] + + constructor ({ name, path, workspaces }: PackageOptions) { + this.name = name + this.path = path + this.workspaces = workspaces + } + + // eslint-disable-next-line require-await + static async loadFromPackageJsonFile (packageJson: PackageJsonFile): Promise { + const { name, workspaces } = packageJson + if (name === undefined) { + return + } + + return new Package({ + name, + path: packageJson.meta.dirname, + workspaces, + }) + } + + static async loadFromDirPath (dirPath: string): Promise { + const packageJson = await PackageJsonFile.loadFromFilePath(PackageJsonFile.filePath(dirPath)) + if (!packageJson) { + return + } + + return await Package.loadFromPackageJsonFile(packageJson) + } +} + +export class Workspace { + /** + * The workspace root package. + */ + root: Package + + /** + * Packages that are a part of the workspace, excluding the root package. + */ + packages: Package[] + + #membersByName = new Map() + #membersByPath = new Map() + + constructor (root: Package, packages: Package[]) { + this.root = root + this.packages = packages + this.#membersByName = [root, ...packages].reduce( + (map, pkg) => map.set(pkg.name, pkg), + new Map(), + ) + this.#membersByPath = [root, ...packages].reduce( + (map, pkg) => map.set(pkg.path, pkg), + new Map(), + ) + } + + memberByName (name: string): Package | undefined { + return this.#membersByName.get(name) + } + + memberByPath (path: string): Package | undefined { + return this.#membersByPath.get(path) + } + + /** + * @param root An absolute path to the workspace root. + * @param patterns Relative workspace patterns. + * @returns Absolute paths to every package in the workspace. + */ + static async resolvePatterns ( + root: string, + patterns: string[], + ): Promise { + const lookup = patterns.map(pattern => + path.join(pattern, PackageJsonFile.FILENAME), + ) + + const results = await glob(lookup, { + cwd: root, + absolute: true, + }) + + const packages: Package[] = [] + + for (const result of results) { + const packageJson = await PackageJsonFile.loadFromFilePath(result) + if (packageJson === undefined) { + continue + } + + const workspacePackage = await Package.loadFromPackageJsonFile(packageJson) + if (workspacePackage === undefined) { + continue + } + + packages.push(workspacePackage) + } + + return packages + } +} + +export async function lookupNearestPackageJsonWorkspace (dir: string): Promise { + for (const searchPath of lineage(dir)) { + const rootPackage = await Package.loadFromDirPath(searchPath) + if (!rootPackage) { + continue + } + + if (rootPackage.workspaces === undefined || rootPackage.workspaces.length === 0) { + continue + } + + const packages = await Workspace.resolvePatterns(searchPath, rootPackage.workspaces) + + return new Workspace(rootPackage, packages) + } +} diff --git a/packages/cli/src/services/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index 4c9f86606..90b4c5c07 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -10,9 +10,10 @@ import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-est import { Collector } from './collector' import { DependencyParseError } from './errors' -import { PackageFilesResolver, Dependencies } from './package-files/resolver' +import { PackageFilesResolver, Dependencies, RawDependency, RawDependencySource } from './package-files/resolver' import type { PlaywrightConfig } from '../playwright-config' import { findFilesWithPattern, pathToPosix } from '../util' +import { Workspace } from './package-files/workspace' // Our custom configuration to handle walking errors @@ -20,7 +21,7 @@ import { findFilesWithPattern, pathToPosix } from '../util' const ignore = (_node: any, _st: any, _c: any) => {} type Module = { - dependencies: Array + dependencies: Array } type SupportedFileExtension = '.js' | '.mjs' | '.ts' @@ -93,15 +94,28 @@ function getTsParser (): any { } } +class RawDependencyCollector { + #dependencies: RawDependency[] = [] + + add (dependency: RawDependency) { + this.#dependencies.push(dependency) + } + + state (): RawDependency[] { + return this.#dependencies + } +} + type ParserOptions = { supportedNpmModules?: Array checkUnsupportedModules?: boolean + workspace?: Workspace } export class Parser { supportedModules: Set checkUnsupportedModules: boolean - resolver = new PackageFilesResolver() + resolver: PackageFilesResolver cache = new Map() + const dependencies = new RawDependencyCollector() const extension = path.extname(filePath) try { @@ -368,7 +383,7 @@ export class Parser { } catch (err) { return { module: { - dependencies: Array.from(dependencies), + dependencies: dependencies.state(), }, error: err, } @@ -376,60 +391,60 @@ export class Parser { return { module: { - dependencies: Array.from(dependencies), + dependencies: dependencies.state(), }, } } - static jsNodeVisitor (dependencies: Set): any { + static jsNodeVisitor (dependencies: RawDependencyCollector): any { return { CallExpression (node: Node) { if (!Parser.isRequireExpression(node)) return const requireStringArg = Parser.getRequireStringArg(node) - Parser.registerDependency(requireStringArg, dependencies) + Parser.registerDependency(requireStringArg, 'require', dependencies) }, ImportDeclaration (node: any) { if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, ExportNamedDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, ExportAllDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, } } - static tsNodeVisitor (tsParser: any, dependencies: Set): any { + static tsNodeVisitor (tsParser: any, dependencies: RawDependencyCollector): any { return { // While rare, TypeScript files may also use require. CallExpression (node: Node) { if (!Parser.isRequireExpression(node)) return const requireStringArg = Parser.getRequireStringArg(node) - Parser.registerDependency(requireStringArg, dependencies) + Parser.registerDependency(requireStringArg, 'require', dependencies) }, ImportDeclaration (node: TSESTree.ImportDeclaration) { // For now, we only support literal strings in the import statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, ExportNamedDeclaration (node: TSESTree.ExportNamedDeclaration) { // The statement isn't importing another dependency if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, ExportAllDeclaration (node: TSESTree.ExportAllDeclaration) { if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, dependencies) + Parser.registerDependency(node.source.value, 'import', dependencies) }, } } @@ -471,12 +486,19 @@ export class Parser { } } - static registerDependency (importArg: string | null, dependencies: Set) { - // TODO: We currently don't support import path aliases, f.ex: `import { Something } from '@services/my-service'` - if (!importArg) { - // If there's no importArg, don't register a dependency - } else { - dependencies.add(importArg) + static registerDependency ( + importPath: string | null, + source: RawDependencySource, + dependencies: RawDependencyCollector, + ) { + if (!importPath) { + // If there's no importPath, don't register a dependency. + return } + + dependencies.add({ + importPath, + source, + }) } } diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index 8175ca653..b02816616 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -143,9 +143,40 @@ export async function getChecklyConfigFile (): Promise<{ checklyConfig: string, return config } -export class ConfigNotFoundError extends Error {} +export class ConfigNotFoundError extends Error { + searchPaths: string[] + configFiles: string[] -export async function loadChecklyConfig (dir: string, filenames = ['checkly.config.ts', 'checkly.config.mts', 'checkly.config.cts', 'checkly.config.js', 'checkly.config.mjs', 'checkly.config.cjs'], writeChecklyConfig: boolean = true, playwrightConfigPath?: string): Promise<{ config: ChecklyConfig, constructs: Construct[] }> { + constructor (searchPaths: string[], configFiles: string[], options?: ErrorOptions) { + const message = `Unable to detect a Checkly configuration file in any of the following paths:` + + `\n\n` + + `${searchPaths.map(searchPath => ` ${searchPath}`).join('\n')}` + + `\n\n` + + `Configuration files we looked for:` + + `\n\n` + + `${configFiles.map(lockfile => ` ${lockfile}`).join('\n')}` + super(message, options) + this.name = 'ConfigNotFoundError' + this.searchPaths = searchPaths + this.configFiles = configFiles + } +} + +export const defaultFilenames = [ + 'checkly.config.ts', + 'checkly.config.mts', + 'checkly.config.cts', + 'checkly.config.js', + 'checkly.config.mjs', + 'checkly.config.cjs', +] + +export async function loadChecklyConfig ( + dir: string, + filenames = defaultFilenames, + writeChecklyConfig: boolean = true, + playwrightConfigPath?: string, +): Promise<{ config: ChecklyConfig, constructs: Construct[] }> { let config: ChecklyConfig | undefined Session.loadingChecklyConfigFile = true Session.checklyConfigFileConstructs = [] @@ -188,7 +219,7 @@ async function handleMissingConfig ( } return checklyConfig } - throw new ConfigNotFoundError(`Unable to locate a config at ${dir} with ${filenames.join(', ')}.`) + throw new ConfigNotFoundError([dir], filenames) } function validateConfigFields (config: ChecklyConfig, fields: (keyof ChecklyConfig)[]): void { diff --git a/packages/cli/src/services/project-parser.ts b/packages/cli/src/services/project-parser.ts index 7971419df..ba2574e79 100644 --- a/packages/cli/src/services/project-parser.ts +++ b/packages/cli/src/services/project-parser.ts @@ -13,6 +13,7 @@ import { CheckConfigDefaults, PlaywrightSlimmedProp } from './checkly-config-loa import type { Runtime } from '../rest/runtimes' import { isEntrypoint, type Construct } from '../constructs/construct' import { PlaywrightCheck } from '../constructs/playwright-check' +import { detectNearestPackageJson, detectPackageManager } from './check-parser/package-files/package-manager' type ProjectParseOpts = { directory: string @@ -34,11 +35,32 @@ type ProjectParseOpts = { playwrightConfigPath?: string include?: string | string[] playwrightChecks?: PlaywrightSlimmedProp[] + enableWorkspaces?: boolean } const BASE_CHECK_DEFAULTS = { } +async function findWorkspace (directory: string) { + const packageManager = await detectPackageManager(directory) + const workspace = await packageManager.lookupWorkspace(directory) + const nearestPackageJson = await detectNearestPackageJson(directory, { + root: workspace?.root.path, + }) + + // If the nearest workspace includes the nearest package, then use the + // workspace root as the project root. Otherwise, use the config dir as + // the project root. + const basePath = workspace?.memberByPath(nearestPackageJson.basePath) !== undefined + ? workspace.root.path + : directory + + return { + basePath, + workspace, + } +} + export async function parseProject (opts: ProjectParseOpts): Promise { const { directory, @@ -60,6 +82,7 @@ export async function parseProject (opts: ProjectParseOpts): Promise { playwrightConfigPath, include, playwrightChecks, + enableWorkspaces = true, } = opts const project = new Project(projectLogicalId, { name: projectName, @@ -70,17 +93,22 @@ export async function parseProject (opts: ProjectParseOpts): Promise { project.allowTestOnly(true) } + const { basePath, workspace } = enableWorkspaces + ? await findWorkspace(directory) + : { basePath: directory, workspace: undefined } + checklyConfigConstructs?.forEach( construct => project.addResource(construct.type, construct.logicalId, construct), ) Session.project = project - Session.basePath = directory + Session.basePath = basePath Session.checkDefaults = Object.assign({}, BASE_CHECK_DEFAULTS, checkDefaults) Session.checkFilter = checkFilter Session.browserCheckDefaults = browserCheckDefaults Session.availableRuntimes = availableRuntimes Session.defaultRuntimeId = defaultRuntimeId Session.verifyRuntimeDependencies = verifyRuntimeDependencies ?? true + Session.workspace = workspace // TODO: Do we really need all of the ** globs, or could we just put node_modules? const ignoreDirectories = ['**/node_modules/**', '**/.git/**', ...ignoreDirectoriesMatch] diff --git a/packages/cli/src/services/util.ts b/packages/cli/src/services/util.ts index 9be87e827..02ddbb033 100644 --- a/packages/cli/src/services/util.ts +++ b/packages/cli/src/services/util.ts @@ -234,7 +234,7 @@ export async function bundlePlayWrightProject ( outputFile, browsers: pwConfigParsed.getBrowsers(), playwrightVersion, - relativePlaywrightConfigPath: path.relative(dir, filePath), + relativePlaywrightConfigPath: path.relative(Session.basePath!, filePath), cacheHash, }) }) @@ -283,27 +283,45 @@ export async function loadPlaywrightProjectFiles ( dir: string, pwConfigParsed: PlaywrightConfig, include: string[], archive: Archiver, lockFile: string, ) { - const ignoredFiles = ['**/node_modules/**', '.git/**'] - const parser = new Parser({}) + const parser = new Parser({ + workspace: Session.workspace, + }) const { files, errors } = await parser.getFilesAndDependencies(pwConfigParsed) - const mode = 0o755 // Default mode for files in the archive if (errors.length) { throw new Error(`Error loading playwright project files: ${errors.map((e: string) => e).join(', ')}`) } + const root = Session.basePath! + const prefix = path.relative(root, dir) + const fileOptions = { + mode: 0o755, // Default mode for files in the archive + } for (const file of files) { - const relativePath = path.relative(dir, file) - archive.file(file, { name: relativePath, mode }) + archive.file(file, { + ...fileOptions, + name: path.relative(root, file), + }) } const lockFileDirName = path.dirname(lockFile) - archive.file(lockFile, { name: path.basename(lockFile), mode }) - archive.file(path.join(lockFileDirName, 'package.json'), { name: 'package.json', mode }) - // handle workspaces - archive.glob('**/package.json', { cwd: path.join(dir, '/'), ignore: ignoredFiles }, { mode }) + const packageJsonFile = path.join(lockFileDirName, 'package.json') + archive.file(lockFile, { + ...fileOptions, + name: path.relative(root, lockFile), + }) + archive.file(packageJsonFile, { + ...fileOptions, + name: path.relative(root, packageJsonFile), + }) for (const includePattern of include) { - archive.glob(includePattern, { cwd: path.join(dir, '/') }, { mode }) + archive.glob(includePattern, { cwd: dir }, { + ...fileOptions, + prefix, + }) } for (const filePath of extraFiles) { - archive.file(path.resolve(dir, filePath), { name: filePath, mode }) + archive.file(path.resolve(root, filePath), { + ...fileOptions, + name: path.relative(root, filePath), + }) } }