feat(files): add file management UI for scenarios and runs
- add scenario upload and download UI with typed file API helpers - show run artifacts on run detail pages after polling completes - handle multipart uploads and absolute file paths for downloads
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import * as http from "http";
|
||||
import * as fs from "fs/promises";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { FileStorageService } from "../src/file/file-storage.service";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
|
||||
describe("Exec Context File Methods Integration Tests", () => {
|
||||
let app: INestApplication;
|
||||
let fileStorageService: FileStorageService;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
let fileRepo: Repository<FileEntity>;
|
||||
let scenarioFileRepo: Repository<ScenarioFileEntity>;
|
||||
let scenarioRunFileRepo: Repository<ScenarioRunFileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let stepRepo: Repository<ScenarioStepEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||
let filesDir: string;
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioFileRepo = app.get<Repository<ScenarioFileEntity>>(
|
||||
getRepositoryToken(ScenarioFileEntity),
|
||||
);
|
||||
scenarioRunFileRepo = app.get<Repository<ScenarioRunFileEntity>>(
|
||||
getRepositoryToken(ScenarioRunFileEntity),
|
||||
);
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||
getRepositoryToken(ScenarioStepEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||
getRepositoryToken(ScenarioRunStepEntity),
|
||||
);
|
||||
|
||||
filesDir = fileStorageService.getAbsolutePath("");
|
||||
|
||||
// Start a test HTTP server for downloadFile tests
|
||||
testServer = await createTestServer();
|
||||
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the test server
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
|
||||
// Clean up files directory
|
||||
try {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTestServer(): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/test-file.bin") {
|
||||
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
res.end(Buffer.from("test downloaded file content"));
|
||||
} else if (req.url === "/test.pdf") {
|
||||
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||
res.end(Buffer.from("fake pdf content"));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "localhost", () => {
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createScenario(name = "test-scenario") {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name }),
|
||||
);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async function createStep(
|
||||
scenarioId: string,
|
||||
execCode: string,
|
||||
order = 0,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await stepRepo.save(
|
||||
stepRepo.create({
|
||||
scenarioId,
|
||||
order,
|
||||
execCode,
|
||||
}),
|
||||
);
|
||||
return step;
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function createRunStep(
|
||||
runId: string,
|
||||
scenarioStepId: string,
|
||||
order = 0,
|
||||
) {
|
||||
const runStep = await runStepRepo.save(
|
||||
runStepRepo.create({
|
||||
runId,
|
||||
scenarioStepId,
|
||||
order,
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
return runStep;
|
||||
}
|
||||
|
||||
async function waitForRunCompletion(
|
||||
runId: string,
|
||||
maxAttempts = 50,
|
||||
): Promise<ScenarioRunEntity> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
// Run scheduler pickup once
|
||||
await scheduler.pickUpPendingRuns();
|
||||
|
||||
// Wait a bit for execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const run = await runRepo.findOneBy({ id: runId });
|
||||
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||
}
|
||||
|
||||
// ── context.getScenarioFiles() ─────────────────────────────────────────────
|
||||
|
||||
describe("context.getScenarioFiles()", () => {
|
||||
it("returns scenario files during a run", async () => {
|
||||
const scenario = await createScenario("get-scenario-files-test");
|
||||
|
||||
// Upload a file to the scenario
|
||||
const fileBuffer = Buffer.from("scenario file content");
|
||||
const uploadedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"test-file.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
// Create scenario file mapping
|
||||
await scenarioFileRepo.save(
|
||||
scenarioFileRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
fileId: uploadedFile.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify run completed successfully
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
expect(runStep).toBeDefined();
|
||||
expect(runStep!.output).toBeDefined();
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toBeDefined();
|
||||
expect(Array.isArray(output.items)).toBe(true);
|
||||
expect(output.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find the uploaded file in the output
|
||||
const foundFile = output.items.find((f: any) => f.id === uploadedFile.id);
|
||||
expect(foundFile).toBeDefined();
|
||||
expect(foundFile.name).toBe("test-file.txt");
|
||||
expect(foundFile.mimeType).toBe("text/plain");
|
||||
expect(foundFile.size).toBe(fileBuffer.length);
|
||||
expect(foundFile.sha256).toBeDefined();
|
||||
expect(foundFile.path).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty list when no files are attached to scenario", async () => {
|
||||
const scenario = await createScenario("no-files-test");
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toEqual([]);
|
||||
expect(output.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── context.downloadFile() ────────────────────────────────────────────────
|
||||
|
||||
describe("context.downloadFile()", () => {
|
||||
it("downloads a file and creates a run artifact", async () => {
|
||||
const scenario = await createScenario("download-file-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step that downloads a file
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.id).toBeDefined();
|
||||
expect(output.name).toBeDefined();
|
||||
expect(output.mimeType).toBe("application/pdf");
|
||||
expect(output.size).toBeGreaterThan(0);
|
||||
expect(output.sha256).toBeDefined();
|
||||
expect(output.path).toBeDefined();
|
||||
|
||||
// Verify the file was created as a run artifact
|
||||
const runFileMapping = await scenarioRunFileRepo.findOne({
|
||||
where: { runId: run.id, fileId: output.id },
|
||||
});
|
||||
expect(runFileMapping).toBeDefined();
|
||||
|
||||
// Verify the file exists on disk
|
||||
const file = await fileRepo.findOneBy({ id: output.id });
|
||||
expect(file).toBeDefined();
|
||||
const fullPath = fileStorageService.getAbsolutePath(file!.filePath);
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Verify file content matches what was downloaded
|
||||
const diskContent = await fs.readFile(fullPath);
|
||||
expect(diskContent).toEqual(Buffer.from("fake pdf content"));
|
||||
|
||||
// Verify the file has an expiresAt date set
|
||||
expect(file!.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("downloads file with custom filename", async () => {
|
||||
const scenario = await createScenario("download-custom-filename-test");
|
||||
const downloadUrl = `${testServerUrl}/test-file.bin`;
|
||||
|
||||
// Create a step that downloads a file with a custom filename
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}', { filename: 'custom.dat' }); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output.name).toBe("custom.dat");
|
||||
});
|
||||
|
||||
it("creates ScenarioRunFileEntity mapping after download", async () => {
|
||||
const scenario = await createScenario("download-mapping-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify that a ScenarioRunFileEntity was created
|
||||
const runFileCount = await scenarioRunFileRepo.count({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
expect(runFileCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the mapping links to the correct run
|
||||
const mappings = await scenarioRunFileRepo.find({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
expect(mappings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const mapping of mappings) {
|
||||
expect(mapping.runId).toBe(run.id);
|
||||
expect(mapping.fileId).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("file returned by downloadFile has correct metadata", async () => {
|
||||
const scenario = await createScenario("download-metadata-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}');
|
||||
return {
|
||||
hasId: result.id !== undefined,
|
||||
hasName: result.name !== undefined,
|
||||
hasMimeType: result.mimeType !== undefined,
|
||||
hasSize: result.size > 0,
|
||||
hasSha256: result.sha256 !== undefined,
|
||||
hasPath: result.path !== undefined
|
||||
};`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.hasId).toBe(true);
|
||||
expect(output.hasName).toBe(true);
|
||||
expect(output.hasMimeType).toBe(true);
|
||||
expect(output.hasSize).toBe(true);
|
||||
expect(output.hasSha256).toBe(true);
|
||||
expect(output.hasPath).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user