Compare commits
9 Commits
c0e06a09a6
...
6ec95c6f41
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec95c6f41 | |||
| d2c614463c | |||
| d6e86c97f4 | |||
| 37e0171bdf | |||
| 9211b4e95d | |||
| b4d902ffb6 | |||
| 83a467a65e | |||
| ea4b78542e | |||
| 673e092414 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"name": "shsf-backend",
|
"name": "shsf-backend",
|
||||||
"description": "Backend for SHSF",
|
"description": "Backend for SHSF",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { validateCronExpression } from "../../lib/Cron";
|
|
||||||
import { processGitPulls } from "../../lib/SystemCrons";
|
|
||||||
|
|
||||||
describe("validateCronExpression", () => {
|
|
||||||
it("returns true for a valid cron expression", async () => {
|
|
||||||
const result = await validateCronExpression("0 0 * * *");
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for an invalid cron expression", async () => {
|
|
||||||
const result = await validateCronExpression("invalid-cron");
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("processGitPulls", () => {
|
|
||||||
it("passes configured git source directories to scheduled pulls", async () => {
|
|
||||||
const performGitPull = vi.fn().mockResolvedValue({ success: true, logs: "" });
|
|
||||||
const prisma = {
|
|
||||||
function: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 910001,
|
|
||||||
name: "source-dir-fn",
|
|
||||||
git_pull_interval: 10,
|
|
||||||
git_source_dir: "functions/api",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dependencies: Parameters<typeof processGitPulls>[0] = {
|
|
||||||
prisma: prisma as unknown as Parameters<typeof processGitPulls>[0]["prisma"],
|
|
||||||
executeFunction: vi.fn() as unknown as Parameters<typeof processGitPulls>[0]["executeFunction"],
|
|
||||||
performGitPull: performGitPull as unknown as Parameters<typeof processGitPulls>[0]["performGitPull"],
|
|
||||||
};
|
|
||||||
|
|
||||||
await processGitPulls(dependencies);
|
|
||||||
|
|
||||||
expect(performGitPull).toHaveBeenCalledWith(910001, "functions/api");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -28,7 +28,7 @@ export const VERSION: {
|
|||||||
} = {
|
} = {
|
||||||
type: "SHSF API",
|
type: "SHSF API",
|
||||||
major: 2,
|
major: 2,
|
||||||
minor: 0,
|
minor: 1,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
toString() {
|
toString() {
|
||||||
return `${this.major}.${this.minor}.${this.patch}`;
|
return `${this.major}.${this.minor}.${this.patch}`;
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export async function getExitCodeFromLog(triggerLog: TriggerLog): Promise<number
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(triggerLog.result);
|
const parsed = JSON.parse(triggerLog.result);
|
||||||
|
// persistFunctionExecutionLog writes "exit_code"; "exitCode" is kept for
|
||||||
|
// older rows written before the key was standardised.
|
||||||
|
if (parsed && typeof parsed === "object" && "exit_code" in parsed) {
|
||||||
|
return parsed.exit_code;
|
||||||
|
}
|
||||||
if (parsed && typeof parsed === "object" && "exitCode" in parsed) {
|
if (parsed && typeof parsed === "object" && "exitCode" in parsed) {
|
||||||
return parsed.exitCode;
|
return parsed.exitCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,27 @@ export async function executeLoadedHttpFunction(
|
|||||||
payloadHash as string,
|
payloadHash as string,
|
||||||
);
|
);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
// Cache hits are still executions from the caller's perspective —
|
||||||
|
// without this, functions with caching enabled only ever log their
|
||||||
|
// failures (errors are never cached), making the log view look like
|
||||||
|
// the function does nothing but fail.
|
||||||
|
await dependencies.persistFunctionExecutionLog({
|
||||||
|
functionId: functionData.id,
|
||||||
|
functionData,
|
||||||
|
logs: "Result served from response cache — function code was not executed.",
|
||||||
|
output: cached.result,
|
||||||
|
payload: buildHttpLogPayload(identityValues, route),
|
||||||
|
exit_code: 0,
|
||||||
|
tooks: [
|
||||||
|
{
|
||||||
|
description: "Served from response cache",
|
||||||
|
value: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ratelimit: loggedRateLimit,
|
||||||
|
});
|
||||||
|
|
||||||
return dependencies.handleFunctionResult(
|
return dependencies.handleFunctionResult(
|
||||||
ctr,
|
ctr,
|
||||||
JSON.parse(cached.result),
|
JSON.parse(cached.result),
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
|
// Includes overdue triggers (nextRun in the past): a missed
|
||||||
|
// tick or server downtime must not permanently kill a cron.
|
||||||
nextRun: {
|
nextRun: {
|
||||||
gte: now,
|
|
||||||
lte: fiveMinutesFromNow,
|
lte: fiveMinutesFromNow,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -152,9 +153,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = interval.next();
|
// Fire based on the stored schedule so overdue triggers run instead
|
||||||
|
// of waiting for (or missing) the next parsed boundary.
|
||||||
if (next.getTime() <= now.getTime() + 1000) {
|
if (cron.nextRun.getTime() <= now.getTime() + 1000) {
|
||||||
const followingRun = interval.next().toDate();
|
const followingRun = interval.next().toDate();
|
||||||
|
|
||||||
await prisma.functionTrigger.update({
|
await prisma.functionTrigger.update({
|
||||||
@@ -225,7 +226,7 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
|||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
const secondsUntilNextRun = Math.round(
|
const secondsUntilNextRun = Math.round(
|
||||||
(next.getTime() - now.getTime()) / 1000,
|
(cron.nextRun.getTime() - now.getTime()) / 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (secondsUntilNextRun <= 5) {
|
if (secondsUntilNextRun <= 5) {
|
||||||
@@ -285,6 +286,22 @@ export async function processStorageCleanup({ prisma }: SystemCronDependencies)
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
storageLog.error({ err: error }, "Error during storage cleanup");
|
storageLog.error({ err: error }, "Error during storage cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Expired cache rows are only ever filtered out on read; without this
|
||||||
|
// they accumulate in the table indefinitely.
|
||||||
|
const expiredCache = await prisma.functionCache.deleteMany({
|
||||||
|
where: {
|
||||||
|
expiresAt: { lt: now },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expiredCache.count > 0) {
|
||||||
|
storageLog.info({ count: expiredCache.count }, "Expired function cache entries cleaned up");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
storageLog.error({ err: error }, "Error during function cache cleanup");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLog = createLogger("AUTO_UPDATE");
|
const updateLog = createLogger("AUTO_UPDATE");
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export = new fileRouter.Path("/")
|
|||||||
await print(
|
await print(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "end",
|
type: "end",
|
||||||
exitCode: 0,
|
exitCode: result?.exit_code ?? 0,
|
||||||
output: output,
|
output: output,
|
||||||
result: result?.result,
|
result: result?.result,
|
||||||
took: result?.tooks,
|
took: result?.tooks,
|
||||||
@@ -229,7 +229,7 @@ export = new fileRouter.Path("/")
|
|||||||
{ mode: "dev_execute" },
|
{ mode: "dev_execute" },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (functionData.cache_enabled && result?.result) {
|
if (functionData.cache_enabled && result?.exit_code === 0 && result?.result) {
|
||||||
await setFunctionCache(
|
await setFunctionCache(
|
||||||
functionData.id,
|
functionData.id,
|
||||||
payloadHash,
|
payloadHash,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileRouter, INSTANCE_SECRET, prisma } from "../../..";
|
import { API_KEY_HEADER, COOKIE, fileRouter, INSTANCE_SECRET, prisma } from "../../..";
|
||||||
|
import { checkAuthentication } from "../../../lib/Authentication";
|
||||||
import { getLinkLock, getLinkStatus, getUUID, setLinkStatus } from "../../../lib/DataManager";
|
import { getLinkLock, getLinkStatus, getUUID, setLinkStatus } from "../../../lib/DataManager";
|
||||||
import { OpenAPITags } from "../../../lib/openapi";
|
import { OpenAPITags } from "../../../lib/openapi";
|
||||||
|
|
||||||
@@ -173,16 +174,27 @@ export = new fileRouter.Path("/")
|
|||||||
/**
|
/**
|
||||||
* POST /api/global/unlink
|
* POST /api/global/unlink
|
||||||
* Removes the link between the external user and this instance.
|
* Removes the link between the external user and this instance.
|
||||||
* Requires the email that was used to link and the instance UUID.
|
* Requires the email that was used to link and the instance UUID,
|
||||||
|
* plus either an authenticated local Admin or the instance secret.
|
||||||
*/
|
*/
|
||||||
.http("POST", "/api/global/unlink", (http) =>
|
.http("POST", "/api/global/unlink", (http) =>
|
||||||
http
|
http
|
||||||
.ratelimit((limit) => limit.hits(5).window(10000).penalty(500))
|
.ratelimit((limit) => limit.hits(5).window(10000).penalty(500))
|
||||||
.document({
|
.document({
|
||||||
description:
|
description:
|
||||||
"Unlinks the currently linked remote user from this instance. Requires the linked email and the instance UUID.",
|
"Unlinks the currently linked remote user from this instance. Requires the linked email and the instance UUID, plus either an authenticated local Admin session/API key or the instance secret via the x-shsf-insect header.",
|
||||||
tags: ["Global"] as OpenAPITags[],
|
tags: ["Global"] as OpenAPITags[],
|
||||||
operationId: "unlinkInstance",
|
operationId: "unlinkInstance",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "x-shsf-insect",
|
||||||
|
in: "header",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"The instance secret. Alternative to session/API key authentication.",
|
||||||
|
schema: { type: "string" },
|
||||||
|
},
|
||||||
|
],
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -232,6 +244,28 @@ export = new fileRouter.Path("/")
|
|||||||
if (!data)
|
if (!data)
|
||||||
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
|
||||||
|
|
||||||
|
// 0. The linked email and instance UUID are visible to local admins,
|
||||||
|
// so they are not secrets. Require a real credential: either the
|
||||||
|
// instance secret (used by shsf.dev) or a local Admin session/API key.
|
||||||
|
const secretHeader = ctr.headers.get("x-shsf-insect");
|
||||||
|
const secretOk =
|
||||||
|
typeof secretHeader === "string" && secretHeader === INSTANCE_SECRET;
|
||||||
|
|
||||||
|
if (!secretOk) {
|
||||||
|
const authCheck = await checkAuthentication(
|
||||||
|
ctr.cookies.get(COOKIE),
|
||||||
|
ctr.headers.get(API_KEY_HEADER),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authCheck.success || authCheck.user.role !== "Admin") {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({
|
||||||
|
status: "FAILED",
|
||||||
|
message:
|
||||||
|
"Unlinking requires the instance secret or an authenticated local Admin.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Verify current link status
|
// 1. Verify current link status
|
||||||
const linkStatus = await getLinkStatus();
|
const linkStatus = await getLinkStatus();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileRouter } from "../../..";
|
import { API_KEY_HEADER, COOKIE, fileRouter, INSTANCE_SECRET } from "../../..";
|
||||||
|
import { checkAuthentication } from "../../../lib/Authentication";
|
||||||
import { getLinkStatus } from "../../../lib/DataManager";
|
import { getLinkStatus } from "../../../lib/DataManager";
|
||||||
import { OpenAPITags } from "../../../lib/openapi";
|
import { OpenAPITags } from "../../../lib/openapi";
|
||||||
|
|
||||||
@@ -9,9 +10,20 @@ export = new fileRouter.Path("/").http(
|
|||||||
http
|
http
|
||||||
.ratelimit((limit) => limit.hits(10).window(5000).penalty(50))
|
.ratelimit((limit) => limit.hits(10).window(5000).penalty(50))
|
||||||
.document({
|
.document({
|
||||||
description: "Returns the link status of this instance.",
|
description:
|
||||||
|
"Returns the link status of this instance. Requires an authenticated Admin session/API key, or the instance secret via the x-shsf-insect header.",
|
||||||
tags: ["Global"] as OpenAPITags[],
|
tags: ["Global"] as OpenAPITags[],
|
||||||
operationId: "getLinkStatus",
|
operationId: "getLinkStatus",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "x-shsf-insect",
|
||||||
|
in: "header",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"The instance secret. Alternative to session/API key authentication.",
|
||||||
|
schema: { type: "string" },
|
||||||
|
},
|
||||||
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Returns the link status of this instance.",
|
description: "Returns the link status of this instance.",
|
||||||
@@ -36,9 +48,36 @@ export = new fileRouter.Path("/").http(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
401: { description: "Authentication failed." },
|
||||||
|
403: { description: "Authenticated user is not an Admin." },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.onRequest(async (ctr) => {
|
.onRequest(async (ctr) => {
|
||||||
|
const secretHeader = ctr.headers.get("x-shsf-insect");
|
||||||
|
const secretOk =
|
||||||
|
typeof secretHeader === "string" && secretHeader === INSTANCE_SECRET;
|
||||||
|
|
||||||
|
if (!secretOk) {
|
||||||
|
const authCheck = await checkAuthentication(
|
||||||
|
ctr.cookies.get(COOKIE),
|
||||||
|
ctr.headers.get(API_KEY_HEADER),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authCheck.success) {
|
||||||
|
return ctr.status(ctr.$status.UNAUTHORIZED).print({
|
||||||
|
status: 401,
|
||||||
|
message: authCheck.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCheck.user.role !== "Admin") {
|
||||||
|
return ctr.status(ctr.$status.FORBIDDEN).print({
|
||||||
|
status: 403,
|
||||||
|
message: "Admins only.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const linkStatus = await getLinkStatus();
|
const linkStatus = await getLinkStatus();
|
||||||
return ctr.print({
|
return ctr.print({
|
||||||
status: "OK",
|
status: "OK",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export = new fileRouter.Path("/").http(
|
|||||||
"/api/global/showSecret",
|
"/api/global/showSecret",
|
||||||
(http) =>
|
(http) =>
|
||||||
http
|
http
|
||||||
|
.ratelimit((limit) => limit.hits(3).window(10000).penalty(5000))
|
||||||
.onRequest(async (ctr) => {
|
.onRequest(async (ctr) => {
|
||||||
const authCheck = await checkAuthentication(
|
const authCheck = await checkAuthentication(
|
||||||
ctr.cookies.get(COOKIE),
|
ctr.cookies.get(COOKIE),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
getSanitizedPayload,
|
getSanitizedPayload,
|
||||||
isSHSFBinaryEnvelope,
|
isSHSFBinaryEnvelope,
|
||||||
SHSF_BINARY_TRANSPORT,
|
SHSF_BINARY_TRANSPORT,
|
||||||
} from "../../lib/Caching";
|
} from "../lib/Caching";
|
||||||
|
|
||||||
describe("getSanitizedPayload", () => {
|
describe("getSanitizedPayload", () => {
|
||||||
it("returns non-object primitives as-is", async () => {
|
it("returns non-object primitives as-is", async () => {
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { validateCronExpression } from "../lib/Cron";
|
||||||
|
import { processCrons, processGitPulls } from "../lib/SystemCrons";
|
||||||
|
|
||||||
|
describe("validateCronExpression", () => {
|
||||||
|
it("returns true for a valid cron expression", async () => {
|
||||||
|
const result = await validateCronExpression("0 0 * * *");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an invalid cron expression", async () => {
|
||||||
|
const result = await validateCronExpression("invalid-cron");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processCrons", () => {
|
||||||
|
const buildDependencies = (trigger: Record<string, unknown>) => {
|
||||||
|
const update = vi.fn().mockResolvedValue({});
|
||||||
|
const executeFunction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ exit_code: 0, logs: "", result: null, tooks: [] });
|
||||||
|
const prisma = {
|
||||||
|
functionTrigger: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([trigger]),
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
functionFile: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dependencies: Parameters<typeof processCrons>[0] = {
|
||||||
|
prisma: prisma as unknown as Parameters<typeof processCrons>[0]["prisma"],
|
||||||
|
executeFunction:
|
||||||
|
executeFunction as unknown as Parameters<typeof processCrons>[0]["executeFunction"],
|
||||||
|
performGitPull: vi.fn() as unknown as Parameters<
|
||||||
|
typeof processCrons
|
||||||
|
>[0]["performGitPull"],
|
||||||
|
};
|
||||||
|
return { dependencies, update, executeFunction };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fires overdue triggers instead of dropping them", async () => {
|
||||||
|
const overdue = {
|
||||||
|
id: 42,
|
||||||
|
functionId: 7,
|
||||||
|
name: "overdue-cron",
|
||||||
|
cron: "*/5 * * * *",
|
||||||
|
enabled: true,
|
||||||
|
nextRun: new Date(Date.now() - 60 * 60 * 1000), // missed an hour ago
|
||||||
|
data: null,
|
||||||
|
function: { id: 7 },
|
||||||
|
};
|
||||||
|
const { dependencies, update, executeFunction } = buildDependencies(overdue);
|
||||||
|
|
||||||
|
await processCrons(dependencies);
|
||||||
|
// the execution itself runs detached; wait for the microtask queue
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(executeFunction).toHaveBeenCalledTimes(1);
|
||||||
|
const rescheduleCall = update.mock.calls.find(
|
||||||
|
(call) => call[0]?.data?.nextRun instanceof Date,
|
||||||
|
);
|
||||||
|
expect(rescheduleCall).toBeDefined();
|
||||||
|
expect(rescheduleCall![0].data.nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes nextRun without executing when it is null", async () => {
|
||||||
|
const fresh = {
|
||||||
|
id: 43,
|
||||||
|
functionId: 7,
|
||||||
|
name: "fresh-cron",
|
||||||
|
cron: "*/5 * * * *",
|
||||||
|
enabled: true,
|
||||||
|
nextRun: null,
|
||||||
|
data: null,
|
||||||
|
function: { id: 7 },
|
||||||
|
};
|
||||||
|
const { dependencies, update, executeFunction } = buildDependencies(fresh);
|
||||||
|
|
||||||
|
await processCrons(dependencies);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(executeFunction).not.toHaveBeenCalled();
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 43 },
|
||||||
|
data: { nextRun: expect.any(Date) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processGitPulls", () => {
|
||||||
|
it("passes configured git source directories to scheduled pulls", async () => {
|
||||||
|
const performGitPull = vi.fn().mockResolvedValue({ success: true, logs: "" });
|
||||||
|
const prisma = {
|
||||||
|
function: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 910001,
|
||||||
|
name: "source-dir-fn",
|
||||||
|
git_pull_interval: 10,
|
||||||
|
git_source_dir: "functions/api",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dependencies: Parameters<typeof processGitPulls>[0] = {
|
||||||
|
prisma: prisma as unknown as Parameters<typeof processGitPulls>[0]["prisma"],
|
||||||
|
executeFunction: vi.fn() as unknown as Parameters<typeof processGitPulls>[0]["executeFunction"],
|
||||||
|
performGitPull: performGitPull as unknown as Parameters<typeof processGitPulls>[0]["performGitPull"],
|
||||||
|
};
|
||||||
|
|
||||||
|
await processGitPulls(dependencies);
|
||||||
|
|
||||||
|
expect(performGitPull).toHaveBeenCalledWith(910001, "functions/api");
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
parseStoredEnvironmentVariables,
|
parseStoredEnvironmentVariables,
|
||||||
serializeEnvironmentVariables,
|
serializeEnvironmentVariables,
|
||||||
toDockerEnvironment,
|
toDockerEnvironment,
|
||||||
} from "../../lib/EnvironmentVariables";
|
} from "../lib/EnvironmentVariables";
|
||||||
|
|
||||||
describe("EnvironmentVariables", () => {
|
describe("EnvironmentVariables", () => {
|
||||||
it("parses stored environment variables and ignores invalid entries", () => {
|
it("parses stored environment variables and ignores invalid entries", () => {
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
getAnalyticsRangeStart,
|
getAnalyticsRangeStart,
|
||||||
normalizeAnalyticsRange,
|
normalizeAnalyticsRange,
|
||||||
parseExecutionAnalyticsLog,
|
parseExecutionAnalyticsLog,
|
||||||
} from "../../lib/FunctionAnalytics";
|
} from "../lib/FunctionAnalytics";
|
||||||
|
|
||||||
describe("FunctionAnalytics helpers", () => {
|
describe("FunctionAnalytics helpers", () => {
|
||||||
it("normalizes unknown ranges to 7d", () => {
|
it("normalizes unknown ranges to 7d", () => {
|
||||||
+18
-7
@@ -1,18 +1,29 @@
|
|||||||
import { TriggerLog } from '@prisma/client';
|
import { TriggerLog } from '@prisma/client';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { getExitCodeFromLog, stripHeadersFromPayload } from '../../lib/FunctionLogging';
|
import { getExitCodeFromLog, stripHeadersFromPayload } from '../lib/FunctionLogging';
|
||||||
|
|
||||||
describe('getExitCodeFromLog', () => {
|
describe('getExitCodeFromLog', () => {
|
||||||
it('should return the correct exit code',async () => {
|
const makeLog = (result: string) =>
|
||||||
const expectedExitCode = 0; // Replace with the expected exit code for your test case
|
({
|
||||||
const test = {
|
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
functionId: 1,
|
functionId: 1,
|
||||||
id: 1,
|
id: 1,
|
||||||
result: JSON.stringify({ exitCode: expectedExitCode }),
|
result,
|
||||||
} as TriggerLog;
|
}) as TriggerLog;
|
||||||
|
|
||||||
expect(await getExitCodeFromLog(test)).toBe(expectedExitCode);
|
it('reads the exit_code key written by persistFunctionExecutionLog', async () => {
|
||||||
|
const log = makeLog(JSON.stringify({ exit_code: 137, tooks: [], output: '' }));
|
||||||
|
expect(await getExitCodeFromLog(log)).toBe(137);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy exitCode key', async () => {
|
||||||
|
const log = makeLog(JSON.stringify({ exitCode: 0 }));
|
||||||
|
expect(await getExitCodeFromLog(log)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no exit code is present', async () => {
|
||||||
|
const log = makeLog(JSON.stringify({ output: 'hi' }));
|
||||||
|
expect(await getExitCodeFromLog(log)).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
hasConfiguredRateLimitBuckets,
|
hasConfiguredRateLimitBuckets,
|
||||||
normalizeFunctionRateLimitConfig,
|
normalizeFunctionRateLimitConfig,
|
||||||
resetFunctionRateLimitState,
|
resetFunctionRateLimitState,
|
||||||
} from "../../lib/FunctionRateLimit";
|
} from "../lib/FunctionRateLimit";
|
||||||
|
|
||||||
const fallbackWindowMs = parseInt(env.RATELIMIT || "0", 10) || 0;
|
const fallbackWindowMs = parseInt(env.RATELIMIT || "0", 10) || 0;
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { getGitEditBlock } from "../../lib/GitEditGuards";
|
import { getGitEditBlock } from "../lib/GitEditGuards";
|
||||||
|
|
||||||
describe("getGitEditBlock", () => {
|
describe("getGitEditBlock", () => {
|
||||||
it("blocks edits when git_url is configured", async () => {
|
it("blocks edits when git_url is configured", async () => {
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
listGitAppFiles,
|
listGitAppFiles,
|
||||||
removeGitMetadata,
|
removeGitMetadata,
|
||||||
stripCredentialsFromUrl,
|
stripCredentialsFromUrl,
|
||||||
} from "../../lib/GitOps";
|
} from "../lib/GitOps";
|
||||||
import { getFunctionBaseDir, getFunctionAppDir } from "../../lib/StoragePaths";
|
import { getFunctionBaseDir, getFunctionAppDir } from "../lib/StoragePaths";
|
||||||
|
|
||||||
const testFunctionIds = new Set<number>();
|
const testFunctionIds = new Set<number>();
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { executeLoadedHttpFunction } from "../../lib/HttpExecution";
|
import { executeLoadedHttpFunction } from "../lib/HttpExecution";
|
||||||
|
|
||||||
const baseFunctionData = {
|
const baseFunctionData = {
|
||||||
id: 10,
|
id: 10,
|
||||||
+6
-6
@@ -8,28 +8,28 @@ import {
|
|||||||
prepareRunnerTransport,
|
prepareRunnerTransport,
|
||||||
readRunnerResult,
|
readRunnerResult,
|
||||||
revokeLegacyFunctionDbTokens,
|
revokeLegacyFunctionDbTokens,
|
||||||
} from "../../lib/RunnerTransport";
|
} from "../lib/RunnerTransport";
|
||||||
import {
|
import {
|
||||||
FunctionStorageService,
|
FunctionStorageService,
|
||||||
StorageServiceError,
|
StorageServiceError,
|
||||||
} from "../../lib/FunctionStorageService";
|
} from "../lib/FunctionStorageService";
|
||||||
import {
|
import {
|
||||||
DbComScriptCS,
|
DbComScriptCS,
|
||||||
DbComScriptGO,
|
DbComScriptGO,
|
||||||
DbComScriptPY,
|
DbComScriptPY,
|
||||||
ShsfRuntimeScriptCS,
|
ShsfRuntimeScriptCS,
|
||||||
} from "../../lib/RunnerScripts";
|
} from "../lib/RunnerScripts";
|
||||||
import {
|
import {
|
||||||
generateDotnetRunnerScript,
|
generateDotnetRunnerScript,
|
||||||
generateGoRunnerWrapperCode,
|
generateGoRunnerWrapperCode,
|
||||||
generatePythonRunnerScript,
|
generatePythonRunnerScript,
|
||||||
} from "../../lib/RunnerRuntimeScripts";
|
} from "../lib/RunnerRuntimeScripts";
|
||||||
|
|
||||||
type TestStorageDb = NonNullable<
|
type TestStorageDb = NonNullable<
|
||||||
ConstructorParameters<typeof FunctionStorageService>[0]
|
ConstructorParameters<typeof FunctionStorageService>[0]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
vi.mock("../../index.js", () => ({
|
vi.mock("../index.js", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
accessToken: {
|
accessToken: {
|
||||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
@@ -209,7 +209,7 @@ describe("generated transport scripts", () => {
|
|||||||
|
|
||||||
describe("legacy function DB token cleanup", () => {
|
describe("legacy function DB token cleanup", () => {
|
||||||
it("deletes hidden legacy function DB tokens", async () => {
|
it("deletes hidden legacy function DB tokens", async () => {
|
||||||
const { prisma } = await import("../../index.js");
|
const { prisma } = await import("../index.js");
|
||||||
await revokeLegacyFunctionDbTokens();
|
await revokeLegacyFunctionDbTokens();
|
||||||
expect(prisma.accessToken.deleteMany).toHaveBeenCalledWith({
|
expect(prisma.accessToken.deleteMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { FunctionFile } from "@prisma/client";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
appendLogOutput,
|
||||||
|
findFileByNameIgnoreCase,
|
||||||
|
getRuntimeType,
|
||||||
|
isDotnetImage,
|
||||||
|
isHtmlStartupFile,
|
||||||
|
parseExecutionPayloadRoute,
|
||||||
|
resolveServeOnlyHtmlFileName,
|
||||||
|
truncateDbField,
|
||||||
|
} from "../lib/RunnerUtils";
|
||||||
|
import { DB_FIELD_LIMIT } from "../lib/RunnerTypes";
|
||||||
|
|
||||||
|
describe("truncateDbField", () => {
|
||||||
|
it("returns short values unchanged", () => {
|
||||||
|
expect(truncateDbField("hello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates values over the DB field limit and appends a marker", () => {
|
||||||
|
const long = "x".repeat(DB_FIELD_LIMIT + 100);
|
||||||
|
const result = truncateDbField(long);
|
||||||
|
expect(result.length).toBe(DB_FIELD_LIMIT + "...[truncated for DB]".length);
|
||||||
|
expect(result.endsWith("...[truncated for DB]")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appendLogOutput", () => {
|
||||||
|
it("ignores whitespace-only additions", () => {
|
||||||
|
expect(appendLogOutput("existing", " \n ")).toBe("existing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns trimmed next when existing is empty", () => {
|
||||||
|
expect(appendLogOutput("", " new line \n")).toBe("new line");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins existing and next with a single newline", () => {
|
||||||
|
expect(appendLogOutput("first\n", " second ")).toBe("first\nsecond");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDotnetImage / getRuntimeType", () => {
|
||||||
|
it("detects dotnet sdk images", () => {
|
||||||
|
expect(isDotnetImage("mcr.microsoft.com/dotnet/sdk:8.0")).toBe(true);
|
||||||
|
expect(isDotnetImage("python:3.12")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps images to runtime types", () => {
|
||||||
|
expect(getRuntimeType("mcr.microsoft.com/dotnet/sdk:8.0")).toBe("dotnet");
|
||||||
|
expect(getRuntimeType("python:3.12")).toBe("python");
|
||||||
|
expect(getRuntimeType("golang:1.22")).toBe("golang");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isHtmlStartupFile", () => {
|
||||||
|
it("matches .html files case-insensitively", () => {
|
||||||
|
expect(isHtmlStartupFile("index.html")).toBe(true);
|
||||||
|
expect(isHtmlStartupFile("INDEX.HTML")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-html and empty startup files", () => {
|
||||||
|
expect(isHtmlStartupFile("main.py")).toBe(false);
|
||||||
|
expect(isHtmlStartupFile(null)).toBe(false);
|
||||||
|
expect(isHtmlStartupFile(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseExecutionPayloadRoute", () => {
|
||||||
|
it("extracts a string route from a JSON payload", () => {
|
||||||
|
expect(parseExecutionPayloadRoute('{"route":"/about"}')).toBe("/about");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-string routes or invalid JSON", () => {
|
||||||
|
expect(parseExecutionPayloadRoute('{"route":42}')).toBe(null);
|
||||||
|
expect(parseExecutionPayloadRoute("not json")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveServeOnlyHtmlFileName", () => {
|
||||||
|
it("falls back to the startup file for empty/default/root routes", () => {
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", null)).toBe("index.html");
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "default")).toBe("index.html");
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "/")).toBe("index.html");
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", " ")).toBe("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips query strings, fragments and leading slashes", () => {
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "/about?x=1#top")).toBe(
|
||||||
|
"about.html",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends .html when the route has no extension", () => {
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "docs/intro")).toBe(
|
||||||
|
"docs/intro.html",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects path traversal attempts", () => {
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "../secret")).toBe(null);
|
||||||
|
expect(resolveServeOnlyHtmlFileName("index.html", "a\\b")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findFileByNameIgnoreCase", () => {
|
||||||
|
const file = (name: string) => ({ name }) as FunctionFile;
|
||||||
|
|
||||||
|
it("prefers an exact-case match over a case-insensitive one", () => {
|
||||||
|
const files = [file("INDEX.HTML"), file("index.html")];
|
||||||
|
expect(findFileByNameIgnoreCase(files, "index.html")?.name).toBe("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a case-insensitive match", () => {
|
||||||
|
const files = [file("Index.Html")];
|
||||||
|
expect(findFileByNameIgnoreCase(files, "index.html")?.name).toBe("Index.Html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when nothing matches", () => {
|
||||||
|
expect(findFileByNameIgnoreCase([file("main.py")], "index.html")).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
getFunctionExecutionsDir,
|
getFunctionExecutionsDir,
|
||||||
getGitRepoDir,
|
getGitRepoDir,
|
||||||
getShsfDataRoot,
|
getShsfDataRoot,
|
||||||
} from "../../lib/StoragePaths";
|
} from "../lib/StoragePaths";
|
||||||
|
|
||||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { readRawRequestBodyFromMiddleware } from "../../lib/middlewares/executionBody";
|
import { readRawRequestBodyFromMiddleware } from "../lib/middlewares/executionBody";
|
||||||
|
|
||||||
function createCtr(method: string, chunks: Array<Buffer | string>) {
|
function createCtr(method: string, chunks: Array<Buffer | string>) {
|
||||||
const bodyState = {
|
const bodyState = {
|
||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/__tests__/**/*.test.ts', 'src/__tests__/**/*.ts'],
|
include: ['src/tests/**/*.test.ts'],
|
||||||
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', "./src/routes/**"],
|
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', "./src/routes/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# TODO.md
|
# TODO.md
|
||||||
|
|
||||||
## P0 - Priority 0 (Critical)
|
## P0 - Priority 0 (Critical)
|
||||||
- [ ] Add UI unit testing / Improve it
|
- [x] Add UI unit testing / Improve it
|
||||||
- [ ] Add Backend unit testing / Improve it (eg. rename files, move into seperate testing folder etc etc /src/tests)
|
- [x] Add Backend unit testing / Improve it (eg. rename files, move into seperate testing folder etc etc /src/tests)
|
||||||
- [x] Migrate env checking to a seperate module using zod
|
- [x] Migrate env checking to a seperate module using zod
|
||||||
- [x] Rewrite Runner.ts & split
|
- [x] Rewrite Runner.ts & split
|
||||||
- [x] Docker image & Propper compose
|
- [x] Docker image & Propper compose
|
||||||
- [x] Remove the entire pip DOWNLOAD cache for shared functions as its a security risk
|
- [x] Remove the entire pip DOWNLOAD cache for shared functions as its a security risk
|
||||||
- [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future)
|
- [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future)
|
||||||
- [ ] Fix SHSF Global & Redo it
|
- [ ] Fix SHSF Global & Redo it (security fixed: link-status/unlink now require admin or instance secret; full redo still open)
|
||||||
- [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf
|
- [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf
|
||||||
- [x] Add cron and more mcp tools
|
- [x] Add cron and more mcp tools
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
- [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI
|
- [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI
|
||||||
- [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.”
|
- [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.”
|
||||||
- [ ] Function Logs Update
|
- [ ] Function Logs Update
|
||||||
- Investigate (Shows only Errors)
|
- [x] Investigate (Shows only Errors) — cache hits were never logged, dev runs always reported exit 0, exit-code parsing used the wrong key
|
||||||
- Hide Specifics (regex blur)
|
- Hide Specifics (regex blur)
|
||||||
- Toggle to only log Generic Headers
|
- Toggle to only log Generic Headers
|
||||||
- [ ] any Modal(???) / function update: Scroll to top on error or move errors to toast (preferred)
|
- [x] any Modal(???) / function update: Scroll to top on error or move errors to toast (preferred)
|
||||||
- [x] Remove "Error fetching files: File edits are disabled while git is configured for this function. Remove git configuration to edit files."
|
- [x] Remove "Error fetching files: File edits are disabled while git is configured for this function. Remove git configuration to edit files."
|
||||||
|
|
||||||
## P4 - Priority 4 (Trivial)
|
## P4 - Priority 4 (Trivial)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shsf",
|
"name": "shsf",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import Modal, { ModalError } from "./Modal";
|
||||||
|
|
||||||
|
describe("Modal", () => {
|
||||||
|
it("renders title and children when open", () => {
|
||||||
|
render(
|
||||||
|
<Modal isOpen={true} onClose={jest.fn()} title="Test Modal">
|
||||||
|
<p>modal body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("heading", { name: "Test Modal" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("modal body")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
render(
|
||||||
|
<Modal isOpen={false} onClose={jest.fn()} title="Hidden">
|
||||||
|
<p>hidden body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("hidden body")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape", () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
render(
|
||||||
|
<Modal isOpen={true} onClose={onClose} title="Esc">
|
||||||
|
<p>body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not close on Escape while loading", () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
render(
|
||||||
|
<Modal isOpen={true} onClose={onClose} title="Busy" isLoading={true}>
|
||||||
|
<p>body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModalError", () => {
|
||||||
|
it("renders the error message", () => {
|
||||||
|
render(<ModalError message="Something failed" />);
|
||||||
|
expect(screen.getByText("Something failed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing without a message", () => {
|
||||||
|
const { container } = render(<ModalError message={null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
|
|
||||||
export const inputClass =
|
export const inputClass =
|
||||||
@@ -45,6 +46,12 @@ export function ModalFooter({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModalError({ message }: { message?: string | null }) {
|
export function ModalError({ message }: { message?: string | null }) {
|
||||||
|
// Modals can be taller than the viewport with the error box rendered at the
|
||||||
|
// top, so a toast makes the failure visible regardless of scroll position.
|
||||||
|
useEffect(() => {
|
||||||
|
if (message) toast.error(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2.5 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
<div className="px-3 py-2.5 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
|
|||||||
"bg-background/40 border border-white/[0.07] rounded-lg p-3 overflow-auto";
|
"bg-background/40 border border-white/[0.07] rounded-lg p-3 overflow-auto";
|
||||||
const sectionHeaderCls = "text-xs font-medium text-muted uppercase tracking-wider mb-2";
|
const sectionHeaderCls = "text-xs font-medium text-muted uppercase tracking-wider mb-2";
|
||||||
|
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(log.result ?? "");
|
||||||
|
if (typeof parsed?.exit_code === "number") exitCode = parsed.exit_code;
|
||||||
|
else if (typeof parsed?.exitCode === "number") exitCode = parsed.exitCode;
|
||||||
|
} catch {
|
||||||
|
// leave exitCode null when the result is not parseable
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-lg overflow-hidden hover:border-white/[0.12] transition-colors">
|
<div className="bg-surface border border-white/[0.07] rounded-lg overflow-hidden hover:border-white/[0.12] transition-colors">
|
||||||
<div
|
<div
|
||||||
@@ -36,9 +45,22 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-medium text-text">
|
<p className="text-sm font-medium text-text">
|
||||||
{new Date(log.createdAt).toLocaleString()}
|
{new Date(log.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
{exitCode !== null && (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
exitCode === 0
|
||||||
|
? "bg-green-500/10 text-green-400 border border-green-500/20"
|
||||||
|
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{exitCode === 0 ? "Success" : `Exit ${exitCode}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted">Execution #{log.id}</p>
|
<p className="text-xs text-muted">Execution #{log.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -128,7 +150,7 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
|
|||||||
className="flex items-center justify-between py-1 border-b border-white/[0.04] last:border-0"
|
className="flex items-center justify-between py-1 border-b border-white/[0.04] last:border-0"
|
||||||
>
|
>
|
||||||
<span className="text-muted text-xs">{took.description}</span>
|
<span className="text-muted text-xs">{took.description}</span>
|
||||||
<span className="text-text text-xs font-mono">{took.value} ms</span>
|
<span className="text-text text-xs font-mono">{Math.round(took.value * 1000)} ms</span>
|
||||||
</div>
|
</div>
|
||||||
)) || (
|
)) || (
|
||||||
<p className="text-muted text-xs">No timing details available</p>
|
<p className="text-muted text-xs">No timing details available</p>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { fireEvent, renderHook } from "@testing-library/react";
|
||||||
|
import { useShiftEnterSubmit } from "./useShiftEnterSubmit";
|
||||||
|
|
||||||
|
describe("useShiftEnterSubmit", () => {
|
||||||
|
it("fires the callback on Ctrl+Enter", () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
renderHook(() => useShiftEnterSubmit(onSubmit));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires the callback on Cmd+Enter (macOS)", () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
renderHook(() => useShiftEnterSubmit(onSubmit));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Enter", metaKey: true });
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire on plain Enter or Ctrl with other keys", () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
renderHook(() => useShiftEnterSubmit(onSubmit));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(document, { key: "s", ctrlKey: true });
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire when disabled", () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
renderHook(() => useShiftEnterSubmit(onSubmit, false));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the listener on unmount", () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
const { unmount } = renderHook(() => useShiftEnterSubmit(onSubmit));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -16,9 +16,9 @@ export const VERSION: {
|
|||||||
patch: number;
|
patch: number;
|
||||||
toString: () => string;
|
toString: () => string;
|
||||||
} = {
|
} = {
|
||||||
type: "SHSF API",
|
type: "SHSF UI",
|
||||||
major: 2,
|
major: 2,
|
||||||
minor: 0,
|
minor: 1,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
toString() {
|
toString() {
|
||||||
return `${this.major}.${this.minor}.${this.patch}`;
|
return `${this.major}.${this.minor}.${this.patch}`;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
Reference in New Issue
Block a user