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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/core/src/job/job.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
});
50 changes: 43 additions & 7 deletions packages/core/src/job/job.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/shared-runner/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
8 changes: 6 additions & 2 deletions packages/engine/src/shared-runner/runner.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,7 +14,11 @@ export default async function run({ jobData, config }: { jobData: JobData; confi
let script: Record<string, JobClassType> = {};
try {
logger("Runner").debug(`Importing job script "${jobData.script}"`);
script = (await import(jobData.script)) as Record<string, JobClassType>;

// Convert relative path to absolute file URL for dynamic import
const scriptUrl = resolveScriptPath(jobData.script);

script = (await import(scriptUrl)) as Record<string, JobClassType>;
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)}`;
Expand Down
Loading