diff --git a/packages/core/src/job/job.test.ts b/packages/core/src/job/job.test.ts index c0ec02b..52804cf 100644 --- a/packages/core/src/job/job.test.ts +++ b/packages/core/src/job/job.test.ts @@ -1,5 +1,7 @@ import { CompletedResult, RetryResult, SnoozeResult } from "@sidequest/core"; -import { Job } from "./job"; +import path from "path"; +import { pathToFileURL } from "url"; +import { Job, resolveScriptPath } from "./job"; export class DummyJob extends Job { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -113,3 +115,32 @@ describe("job.ts", () => { }); }); }); + +describe("resolveScriptPath", () => { + it("should return the input if it is already a file URL", () => { + const fileUrl = "file:///some/path/to/file.js"; + expect(resolveScriptPath(fileUrl)).toBe(fileUrl); + }); + + it("should convert an absolute path to a file URL", () => { + const absPath = path.resolve("/tmp/test.js"); + const expected = pathToFileURL(absPath).href; + expect(resolveScriptPath(absPath)).toBe(expected); + }); + + it("should resolve a relative path to a file URL based on import.meta.dirname", () => { + const relativePath = "test/fixtures/job-script.js"; + const baseDir = import.meta?.dirname ? import.meta.dirname : __dirname; + const absPath = path.resolve(baseDir, relativePath); + const expected = pathToFileURL(absPath).href; + expect(resolveScriptPath(relativePath)).toBe(expected); + }); + + it("should handle relative paths with ./ and ../", () => { + const relativePath = "../test/fixtures/job-script.js"; + const baseDir = import.meta?.dirname ? import.meta.dirname : __dirname; + const absPath = path.resolve(baseDir, relativePath); + const expected = pathToFileURL(absPath).href; + expect(resolveScriptPath(relativePath)).toBe(expected); + }); +}); diff --git a/packages/core/src/job/job.ts b/packages/core/src/job/job.ts index 5190d1e..d979ef9 100644 --- a/packages/core/src/job/job.ts +++ b/packages/core/src/job/job.ts @@ -1,4 +1,5 @@ import { access } from "fs/promises"; +import path from "path"; import { pathToFileURL } from "url"; import { logger } from "../logger"; import { ErrorData, JobData, JobState } from "../schema"; @@ -237,12 +238,12 @@ export abstract class Job implements JobData { * Attempts to determine the file path where a given class is exported by analyzing the current call stack. * * This function inspects the stack trace of a newly created error to extract file paths, - * then checks each file to see if it exports the specified class. If found, returns the file path - * as a `file://` URI. If not found, returns the first file path in the stack as a fallback. + * then checks each file to see if it exports the specified class. If found, returns the relative path + * from the current working directory. If not found, returns the first file path in the stack as a fallback. * Throws an error if no file paths can be determined. * * @param className - The name of the class to search for in the stack trace files. - * @returns A promise that resolves to the `file://` URI of the file exporting the class, or the first file in the stack. + * @returns A promise that resolves to the relative path of the file exporting the class, or the first file in the stack. * @throws If no file paths can be determined from the stack trace. */ async function buildPath(className: string) { @@ -263,19 +264,54 @@ async function buildPath(className: string) { for (const filePath of filePaths) { const hasExported = await hasClassExported(filePath!, className); if (hasExported) { - logger("Job").debug(`${filePath} exports class ${className}`); - return `file://${filePath}`; + const relativePath = path.relative(import.meta.dirname, filePath!); + logger("Job").debug(`${filePath} exports class ${className}, relative path: ${relativePath}`); + return relativePath.replaceAll("\\", "/"); } } if (filePaths.length > 0) { - logger("Job").debug(`No class ${className} found in stack, returning first file path: ${filePaths[0]}`); - return `file://${filePaths[0]}`; + const relativePath = path.relative(import.meta.dirname, filePaths[0]!); + logger("Job").debug(`No class ${className} found in stack, returning first file path: ${relativePath}`); + return relativePath.replaceAll("\\", "/"); } throw new Error("Could not determine the task path"); } +/** + * Resolves a relative script path (as stored in job.script) to an absolute file URL + * that can be used for dynamic imports. + * + * This function takes a relative path that was generated by buildPath() and converts + * it back to an absolute file URL by resolving it relative to this file's directory. + * It also handles edge cases where the path might already be absolute or a file URL. + * + * @param relativePath - The relative path stored in job.script + * @returns The absolute file URL that can be used for dynamic import() + * + * @example + * ```typescript + * const scriptUrl = resolveScriptPath("../../../examples/hello-job.js"); + * const module = await import(scriptUrl); + * ``` + */ +export function resolveScriptPath(relativePath: string): string { + // If it's already a file URL, return as-is + if (relativePath.startsWith("file://")) { + return relativePath; + } + + // If it's already an absolute path, convert to file URL + if (path.isAbsolute(relativePath)) { + return pathToFileURL(relativePath).href; + } + + // Otherwise, resolve relative to this file's directory + const absolutePath = path.resolve(import.meta.dirname, relativePath); + return pathToFileURL(absolutePath).href; +} + /** * Checks if a given file exports a class with the specified name. * diff --git a/packages/engine/src/shared-runner/runner.test.ts b/packages/engine/src/shared-runner/runner.test.ts index ace420e..bac6c85 100644 --- a/packages/engine/src/shared-runner/runner.test.ts +++ b/packages/engine/src/shared-runner/runner.test.ts @@ -36,7 +36,7 @@ describe("runner.ts", () => { jobData.script = "invalid!"; const result = (await run({ jobData, config })) as FailedResult; expect(result.type).toEqual("failed"); - expect(result.error.message).toMatch(/Cannot find package 'invalid!'/); + expect(result.error.message).toMatch(/Cannot find module/); }); sidequestTest("fails with invalid class", async ({ config }) => { diff --git a/packages/engine/src/shared-runner/runner.ts b/packages/engine/src/shared-runner/runner.ts index 1f6969b..1581ed9 100644 --- a/packages/engine/src/shared-runner/runner.ts +++ b/packages/engine/src/shared-runner/runner.ts @@ -1,4 +1,4 @@ -import { Job, JobClassType, JobData, JobResult, logger, toErrorData } from "@sidequest/core"; +import { Job, JobClassType, JobData, JobResult, logger, resolveScriptPath, toErrorData } from "@sidequest/core"; import { EngineConfig } from "../engine"; import { importSidequest } from "../utils"; @@ -14,7 +14,11 @@ export default async function run({ jobData, config }: { jobData: JobData; confi let script: Record = {}; try { logger("Runner").debug(`Importing job script "${jobData.script}"`); - script = (await import(jobData.script)) as Record; + + // Convert relative path to absolute file URL for dynamic import + const scriptUrl = resolveScriptPath(jobData.script); + + script = (await import(scriptUrl)) as Record; logger("Runner").debug(`Successfully imported job script "${jobData.script}"`); } catch (error) { const errorMessage = `Failed to import job script "${jobData.script}": ${error instanceof Error ? error.message : String(error)}`;