mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
78301695ea
The workflow-node-module-error plugin records violations in onResolve, which runs before esbuild's tree-shaker. A shared module with both a workflow-safe export and a step-only export that imports node:* would be flagged even when the workflow never reached the step-only export. Mark node:/bun: imports as sideEffects: false so esbuild can tree-shake them when unused in surviving code, enable metafile, and filter recorded violations in onEnd against outputs[].imports so only builtins that actually survive in the emitted bundle are reported. Fixes #1817
765 lines
25 KiB
TypeScript
765 lines
25 KiB
TypeScript
import {
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
// @ts-expect-error - Intentionally unused import for testing getViolationLocation
|
|
// with an import that exists but is never referenced in code
|
|
import http from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, relative, resolve } from 'node:path';
|
|
|
|
// Resolve symlinks in tmpdir to avoid macOS /var -> /private/var issues
|
|
const realTmpdir = realpathSync(tmpdir());
|
|
|
|
import * as esbuild from 'esbuild';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
createNodeModuleErrorPlugin,
|
|
escapeRegExp,
|
|
getImportedIdentifier,
|
|
getPackageName,
|
|
getViolationLocation,
|
|
} from './node-module-esbuild-plugin.js';
|
|
|
|
async function buildWorkflowWithViolation(
|
|
source: string,
|
|
overrides: Partial<esbuild.BuildOptions> = {}
|
|
) {
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
writeFileSync(entryFile, source);
|
|
const relativeEntry = relative(process.cwd(), entryFile);
|
|
|
|
try {
|
|
await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin(), ...(overrides.plugins ?? [])],
|
|
...overrides,
|
|
});
|
|
throw new Error('Expected build to fail');
|
|
} catch (error: any) {
|
|
if (error && typeof error === 'object' && 'errors' in error) {
|
|
return {
|
|
failure: error as esbuild.BuildFailure,
|
|
relativeEntry,
|
|
};
|
|
}
|
|
throw error;
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe('workflow-node-module-error plugin', () => {
|
|
it('should error on fs import', async () => {
|
|
const testCode = `
|
|
import { readFile } from "fs";
|
|
export function workflow() {
|
|
return readFile("test.txt");
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain(
|
|
'You are attempting to use "fs" which is a Node.js module'
|
|
);
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
suggestion: 'Move this function into a step function.',
|
|
});
|
|
expect(violation.location?.line).toBeGreaterThan(0);
|
|
expect(violation.location?.column).toBeGreaterThanOrEqual(0);
|
|
expect(violation.location?.lineText).toContain('readFile');
|
|
});
|
|
|
|
it('should error on node: prefixed imports', async () => {
|
|
const testCode = `
|
|
import { readFile } from "node:fs";
|
|
export function workflow() {
|
|
return readFile;
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } = await buildWorkflowWithViolation(
|
|
testCode,
|
|
{ format: 'cjs' }
|
|
);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain(
|
|
'You are attempting to use "node:fs" which is a Node.js module'
|
|
);
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
suggestion: 'Move this function into a step function.',
|
|
});
|
|
expect(violation.location?.lineText).toContain('readFile');
|
|
});
|
|
|
|
it('should error on multiple Node.js imports', async () => {
|
|
const testCode = `
|
|
import { readFile } from "fs";
|
|
import { join } from "path";
|
|
export function workflow() {
|
|
return readFile(join("a", "b"));
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } = await buildWorkflowWithViolation(
|
|
testCode,
|
|
{ format: 'cjs' }
|
|
);
|
|
|
|
expect(failure.errors).toHaveLength(2);
|
|
|
|
const packages = failure.errors.map((error) => ({
|
|
text: error.text,
|
|
location: error.location,
|
|
}));
|
|
|
|
const fsViolation = packages.find((pkg) => pkg.text.includes('"fs"'));
|
|
const pathViolation = packages.find((pkg) => pkg.text.includes('"path"'));
|
|
|
|
expect(fsViolation?.text).toContain('which is a Node.js module');
|
|
expect(pathViolation?.text).toContain('which is a Node.js module');
|
|
|
|
expect(fsViolation?.location).toMatchObject({
|
|
file: relativeEntry,
|
|
suggestion: 'Move this function into a step function.',
|
|
});
|
|
expect(fsViolation?.location?.lineText).toContain('readFile');
|
|
expect(pathViolation?.location).toMatchObject({
|
|
file: relativeEntry,
|
|
suggestion: 'Move this function into a step function.',
|
|
});
|
|
expect(pathViolation?.location?.lineText).toContain('join');
|
|
});
|
|
|
|
it('should show top-level package name for nested Node.js imports', async () => {
|
|
// This test validates that when a package in node_modules internally uses
|
|
// Node.js built-in modules, the error message shows the top-level package name
|
|
// (e.g., "fake-package") instead of the internal built-in (e.g., "stream").
|
|
//
|
|
// We create a real node_modules directory structure to simulate this scenario.
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const nodeModulesDir = join(tempDir, 'node_modules', 'fake-package');
|
|
const { mkdirSync } = await import('node:fs');
|
|
mkdirSync(nodeModulesDir, { recursive: true });
|
|
|
|
const fakePackageCode = `
|
|
import { Stream } from "stream";
|
|
export function fakePackage() {
|
|
return new Stream();
|
|
}
|
|
`;
|
|
writeFileSync(join(nodeModulesDir, 'index.js'), fakePackageCode);
|
|
writeFileSync(
|
|
join(nodeModulesDir, 'package.json'),
|
|
JSON.stringify({ name: 'fake-package', main: 'index.js' })
|
|
);
|
|
|
|
const testCode = `
|
|
import { fakePackage } from "fake-package";
|
|
export function workflow() {
|
|
return fakePackage();
|
|
}
|
|
`;
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
writeFileSync(entryFile, testCode);
|
|
const relativeEntry = relative(process.cwd(), entryFile);
|
|
|
|
try {
|
|
await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin()],
|
|
});
|
|
throw new Error('Expected build to fail');
|
|
} catch (error: any) {
|
|
if (error && typeof error === 'object' && 'errors' in error) {
|
|
const failure = error as esbuild.BuildFailure;
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
// Should mention the top-level package "fake-package", not the internal "stream" module
|
|
expect(violation.text).toContain('"fake-package"');
|
|
expect(violation.text).toContain('which depends on Node.js modules');
|
|
expect(violation.text).not.toContain('"stream"');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should detect packages when esbuild and resolver choose different entry fields', async () => {
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const nodeModulesDir = join(tempDir, 'node_modules', 'dual-entry-package');
|
|
const esmDir = join(nodeModulesDir, 'esm');
|
|
const cjsDir = join(nodeModulesDir, 'cjs');
|
|
const { mkdirSync } = await import('node:fs');
|
|
mkdirSync(esmDir, { recursive: true });
|
|
mkdirSync(cjsDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(esmDir, 'index.js'),
|
|
`
|
|
import os from "os";
|
|
export function getPlatform() {
|
|
return os.platform();
|
|
}
|
|
`
|
|
);
|
|
writeFileSync(
|
|
join(cjsDir, 'index.cjs'),
|
|
`
|
|
module.exports = {
|
|
getPlatform() {
|
|
return "cjs";
|
|
}
|
|
};
|
|
`
|
|
);
|
|
writeFileSync(
|
|
join(nodeModulesDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'dual-entry-package',
|
|
main: 'cjs/index.cjs',
|
|
module: 'esm/index.js',
|
|
})
|
|
);
|
|
|
|
const testCode = `
|
|
import { getPlatform } from "dual-entry-package";
|
|
export function workflow() {
|
|
return getPlatform();
|
|
}
|
|
`;
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
writeFileSync(entryFile, testCode);
|
|
const relativeEntry = relative(process.cwd(), entryFile);
|
|
|
|
try {
|
|
await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
format: 'cjs',
|
|
mainFields: ['module', 'main'],
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin()],
|
|
});
|
|
throw new Error('Expected build to fail');
|
|
} catch (error: any) {
|
|
if (error && typeof error === 'object' && 'errors' in error) {
|
|
const failure = error as esbuild.BuildFailure;
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"dual-entry-package"');
|
|
expect(violation.text).toContain('depends on Node.js modules');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should find usage of namespace imports', async () => {
|
|
const testCode = `
|
|
import * as fs from "fs";
|
|
export function workflow() {
|
|
return fs.readFile("test.txt");
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"fs" which is a Node.js module');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
expect(violation.location?.lineText).toContain('fs.readFile');
|
|
});
|
|
|
|
it('should find usage of default imports', async () => {
|
|
const testCode = `
|
|
import fs from "fs";
|
|
export function workflow() {
|
|
return fs.readFile("test.txt");
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"fs" which is a Node.js module');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
expect(violation.location?.lineText).toContain('fs.readFile');
|
|
});
|
|
|
|
it('should find usage of aliased imports', async () => {
|
|
const testCode = `
|
|
import { readFile as read } from "fs";
|
|
export function workflow() {
|
|
return read("test.txt");
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"fs" which is a Node.js module');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
// Should point to the aliased identifier "read", not "readFile"
|
|
expect(violation.location?.lineText).toContain('read(');
|
|
});
|
|
|
|
it('should not error when import is unused (tree-shaken)', async () => {
|
|
// Note: esbuild tree-shakes unused imports, so they never trigger resolution.
|
|
// This is expected behavior - if the import isn't used, the module won't be bundled.
|
|
const testCode = `
|
|
import { readFile } from "fs";
|
|
export function workflow() {
|
|
return "no fs usage";
|
|
}
|
|
`;
|
|
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
writeFileSync(entryFile, testCode);
|
|
|
|
try {
|
|
// Build should succeed because unused imports are tree-shaken
|
|
const result = await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin()],
|
|
});
|
|
expect(result.errors).toHaveLength(0);
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should not error when a shared module exposes a workflow-safe export alongside an unused Node.js-using export', async () => {
|
|
// Repro for https://github.com/vercel/workflow/issues/1817
|
|
//
|
|
// When a shared module has both a workflow-safe export and a Node.js-using
|
|
// export, and the workflow bundle only uses the workflow-safe one, esbuild
|
|
// should be able to tree-shake the Node.js-using export (and its imports)
|
|
// and the plugin should not emit a false-positive violation.
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const sharedFile = join(tempDir, 'shared.ts');
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
|
|
writeFileSync(
|
|
sharedFile,
|
|
`
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export const isSupportedValue = (value) => value === "ok";
|
|
|
|
export async function readFixtureFile() {
|
|
return readFile(path.join(process.cwd(), "fixture.txt"), "utf8");
|
|
}
|
|
`
|
|
);
|
|
writeFileSync(
|
|
entryFile,
|
|
`
|
|
import { isSupportedValue } from "./shared.js";
|
|
export function workflow() {
|
|
return isSupportedValue("ok");
|
|
}
|
|
`
|
|
);
|
|
|
|
try {
|
|
const result = await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin()],
|
|
});
|
|
expect(result.errors).toHaveLength(0);
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("should still error when a shared module's Node.js-using export is actually referenced", async () => {
|
|
// Counterpart to the previous test: when the workflow really does pull in
|
|
// the Node.js-using export (directly or transitively), the plugin must
|
|
// still emit the violation.
|
|
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
|
|
const sharedFile = join(tempDir, 'shared.ts');
|
|
const entryFile = join(tempDir, 'workflow.ts');
|
|
const relativeShared = relative(process.cwd(), sharedFile);
|
|
|
|
writeFileSync(
|
|
sharedFile,
|
|
`
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export const isSupportedValue = (value) => value === "ok";
|
|
|
|
export async function readFixtureFile() {
|
|
return readFile(path.join(process.cwd(), "fixture.txt"), "utf8");
|
|
}
|
|
`
|
|
);
|
|
writeFileSync(
|
|
entryFile,
|
|
`
|
|
import { isSupportedValue, readFixtureFile } from "./shared.js";
|
|
export async function workflow() {
|
|
if (!isSupportedValue("ok")) throw new Error("nope");
|
|
return readFixtureFile();
|
|
}
|
|
`
|
|
);
|
|
|
|
try {
|
|
await esbuild.build({
|
|
entryPoints: [entryFile],
|
|
bundle: true,
|
|
write: false,
|
|
platform: 'neutral',
|
|
logLevel: 'silent',
|
|
plugins: [createNodeModuleErrorPlugin()],
|
|
});
|
|
throw new Error('Expected build to fail');
|
|
} catch (error: any) {
|
|
if (error && typeof error === 'object' && 'errors' in error) {
|
|
const failure = error as esbuild.BuildFailure;
|
|
const paths = failure.errors.map((e) => e.text);
|
|
expect(paths.some((t) => t.includes('"node:fs/promises"'))).toBe(true);
|
|
expect(paths.some((t) => t.includes('"node:path"'))).toBe(true);
|
|
// Violations should be attributed to the shared module, not the entry.
|
|
for (const err of failure.errors) {
|
|
expect(err.location?.file).toBe(relativeShared);
|
|
}
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should not point to JSDoc comments when finding usage', async () => {
|
|
const testCode = `
|
|
import { Writable } from "stream";
|
|
/**
|
|
* Convert a Web WritableStream<string> into a Node.js Writable stream
|
|
*/
|
|
export function workflow() {
|
|
return new Writable();
|
|
}
|
|
`;
|
|
|
|
const { failure } = await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"stream" which is a Node.js module');
|
|
// Should point to the actual code usage, not the JSDoc comment
|
|
expect(violation.location?.lineText).toContain('new Writable()');
|
|
expect(violation.location?.lineText).not.toContain('*');
|
|
});
|
|
|
|
it('should not point to single-line block comments when finding usage', async () => {
|
|
const testCode = `
|
|
import { Writable } from "stream";
|
|
/* Writable is used below */
|
|
export function workflow() {
|
|
return new Writable();
|
|
}
|
|
`;
|
|
|
|
const { failure } = await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
// Should point to the actual code usage, not the comment
|
|
expect(violation.location?.lineText).toContain('new Writable()');
|
|
});
|
|
|
|
it('should not treat comment delimiters inside strings as comments', async () => {
|
|
const testCode = `
|
|
import { Writable } from "stream";
|
|
const pattern = "/* Writable */";
|
|
export function workflow() {
|
|
return new Writable();
|
|
}
|
|
`;
|
|
|
|
const { failure } = await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"stream" which is a Node.js module');
|
|
// Should point to actual code, not the string containing "Writable"
|
|
expect(violation.location?.lineText).toContain('new Writable()');
|
|
});
|
|
|
|
it('should error on Bun module imports', async () => {
|
|
const testCode = `
|
|
import { serve } from "bun";
|
|
export function workflow() {
|
|
return serve({ port: 3000 });
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"bun" which is a Bun module');
|
|
expect(violation.text).toContain('Bun modules are not available');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
});
|
|
|
|
it('should error on Bun subpath imports', async () => {
|
|
const testCode = `
|
|
import { Database } from "bun:sqlite";
|
|
export function workflow() {
|
|
return new Database("test.db");
|
|
}
|
|
`;
|
|
|
|
const { failure, relativeEntry } =
|
|
await buildWorkflowWithViolation(testCode);
|
|
|
|
expect(failure.errors).toHaveLength(1);
|
|
const violation = failure.errors[0];
|
|
expect(violation.text).toContain('"bun:sqlite" which is a Bun module');
|
|
expect(violation.text).toContain('Bun modules are not available');
|
|
expect(violation.location).toMatchObject({
|
|
file: relativeEntry,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('workflow-node-module-error helper functions', () => {
|
|
describe('getPackageName', () => {
|
|
it('should get the package name from simple node_modules path', () => {
|
|
const packageName = getPackageName(
|
|
'/Users/adrianlam/GitHub/workflow/node_modules/node-fetch/src/index.js'
|
|
);
|
|
expect(packageName).toBe('node-fetch');
|
|
});
|
|
|
|
it('should get the package name from pnpm nested path', () => {
|
|
const packageName = getPackageName(
|
|
'/Users/adrianlam/GitHub/workflow/node_modules/.pnpm/node-fetch@3.3.2/node_modules/node-fetch/src/index.js'
|
|
);
|
|
expect(packageName).toBe('node-fetch');
|
|
});
|
|
|
|
it('should get scoped package name', () => {
|
|
const packageName = getPackageName(
|
|
'/project/node_modules/@supabase/supabase-js/dist/index.js'
|
|
);
|
|
expect(packageName).toBe('@supabase/supabase-js');
|
|
});
|
|
|
|
it('should return null for paths without node_modules', () => {
|
|
const packageName = getPackageName(
|
|
'/Users/adrianlam/GitHub/workflow/src/index.js'
|
|
);
|
|
expect(packageName).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('escapeRegExp', () => {
|
|
it('should escape regex special characters', () => {
|
|
expect(escapeRegExp('test.file')).toBe('test\\.file');
|
|
expect(escapeRegExp('test*file')).toBe('test\\*file');
|
|
expect(escapeRegExp('test+file')).toBe('test\\+file');
|
|
expect(escapeRegExp('test?file')).toBe('test\\?file');
|
|
expect(escapeRegExp('test^file')).toBe('test\\^file');
|
|
expect(escapeRegExp('test$file')).toBe('test\\$file');
|
|
});
|
|
|
|
it('should escape brackets and braces', () => {
|
|
expect(escapeRegExp('test{file}')).toBe('test\\{file\\}');
|
|
expect(escapeRegExp('test[file]')).toBe('test\\[file\\]');
|
|
expect(escapeRegExp('test(file)')).toBe('test\\(file\\)');
|
|
});
|
|
|
|
it('should escape pipes and backslashes', () => {
|
|
expect(escapeRegExp('test|file')).toBe('test\\|file');
|
|
expect(escapeRegExp('test\\file')).toBe('test\\\\file');
|
|
});
|
|
|
|
it('should handle strings without special characters', () => {
|
|
expect(escapeRegExp('testfile')).toBe('testfile');
|
|
expect(escapeRegExp('test-file')).toBe('test-file');
|
|
});
|
|
|
|
it('should handle package names with special characters', () => {
|
|
expect(escapeRegExp('@supabase/supabase-js')).toBe(
|
|
'@supabase/supabase-js'
|
|
);
|
|
expect(escapeRegExp('package.name')).toBe('package\\.name');
|
|
});
|
|
});
|
|
|
|
describe('getImportedIdentifier', () => {
|
|
it('should extract namespace import identifier', () => {
|
|
expect(getImportedIdentifier('* as fs')).toBe('fs');
|
|
expect(getImportedIdentifier('* as path')).toBe('path');
|
|
});
|
|
|
|
it('should extract first named import', () => {
|
|
expect(getImportedIdentifier('{ readFile }')).toBe('readFile');
|
|
expect(getImportedIdentifier('{ readFile, writeFile }')).toBe('readFile');
|
|
});
|
|
|
|
it('should extract aliased named import', () => {
|
|
expect(getImportedIdentifier('{ readFile as read }')).toBe('read');
|
|
expect(getImportedIdentifier('{ readFile as read, writeFile }')).toBe(
|
|
'read'
|
|
);
|
|
});
|
|
|
|
it('should extract default import', () => {
|
|
expect(getImportedIdentifier('fs')).toBe('fs');
|
|
expect(getImportedIdentifier('myDefault')).toBe('myDefault');
|
|
});
|
|
|
|
it('should extract first identifier from mixed imports', () => {
|
|
// The function checks for braces first, so it extracts from named imports
|
|
expect(getImportedIdentifier('fs, { readFile }')).toBe('readFile');
|
|
expect(getImportedIdentifier('defaultExport, { named }')).toBe('named');
|
|
});
|
|
|
|
it('should handle whitespace variations', () => {
|
|
expect(getImportedIdentifier(' { readFile } ')).toBe('readFile');
|
|
expect(getImportedIdentifier('{readFile}')).toBe('readFile');
|
|
expect(getImportedIdentifier('{ readFile , writeFile }')).toBe(
|
|
'readFile'
|
|
);
|
|
});
|
|
|
|
it('should handle complex named imports', () => {
|
|
expect(getImportedIdentifier('type { ReadStream }')).toBe('ReadStream');
|
|
expect(getImportedIdentifier('{ default as fs }')).toBe('fs');
|
|
});
|
|
|
|
it('should return undefined for edge cases', () => {
|
|
expect(getImportedIdentifier('*')).toBeUndefined();
|
|
expect(getImportedIdentifier('')).toBeUndefined();
|
|
// Empty braces should return undefined since there's no valid identifier
|
|
expect(getImportedIdentifier('{}')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getViolationLocation', () => {
|
|
it('should find violation location for package name that appears in file', async () => {
|
|
// Use the actual monorepo root as cwd
|
|
const cwd = process.cwd();
|
|
const testFile = 'src/node-module-esbuild-plugin.test.ts';
|
|
|
|
// Test with 'vitest' which is actually imported in this file
|
|
const location = await getViolationLocation(cwd, testFile, 'vitest');
|
|
|
|
// The function should find 'vitest' in the import statement
|
|
expect(location).toBeDefined();
|
|
expect(location?.file).toBe(testFile);
|
|
|
|
const contents = readFileSync(resolve(cwd, testFile), 'utf8');
|
|
const lines = contents.split(/\r?\n/);
|
|
const expectedLine =
|
|
lines.findIndex((line) => line.includes(`describe(`)) + 1;
|
|
|
|
expect(location?.line).toBe(expectedLine);
|
|
expect(location?.column).toBe(0);
|
|
expect(location?.lineText).toContain(`describe(`);
|
|
expect(location?.length).toBe(8);
|
|
});
|
|
|
|
it('should return undefined for non-existent files', async () => {
|
|
const cwd = process.cwd();
|
|
const location = await getViolationLocation(
|
|
cwd,
|
|
'non-existent-file.ts',
|
|
'some-package'
|
|
);
|
|
|
|
expect(location).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for files without the package import', async () => {
|
|
const cwd = process.cwd();
|
|
const testFile = 'src/node-module-esbuild-plugin.test.ts';
|
|
|
|
// This package is not imported in the test file
|
|
const location = await getViolationLocation(
|
|
cwd,
|
|
testFile,
|
|
'non-existent-package'
|
|
);
|
|
|
|
expect(location).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined when import is unused even if it can be parsed', async () => {
|
|
const cwd = process.cwd();
|
|
const testFile = 'src/node-module-esbuild-plugin.test.ts';
|
|
|
|
// Test with 'node:http' which is imported in this file but never used
|
|
const location = await getViolationLocation(cwd, testFile, 'node:http');
|
|
|
|
// Since the identifier is never referenced (only imported), we should
|
|
// not produce a location preview.
|
|
expect(location).toBeUndefined();
|
|
});
|
|
});
|
|
});
|