Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  fix(runner-e2e): prepare frozen Daytona plugin dependencies (#12791)
  chore(deps): bump @pierre/diffs from 1.3.5 to 1.3.6 (#12311)
  test(server): make the instance settings route suite deterministic under CPU contention (#12789)
This commit is contained in:
Dotta 2026-09-03 16:31:35 -05:00
commit 77c464dc7a
3 changed files with 84 additions and 39 deletions

View File

@ -52,7 +52,7 @@
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@pierre/diffs": "^1.3.5"
"@pierre/diffs": "^1.3.6"
},
"devDependencies": {
"@types/node": "^24.0.0",

View File

@ -749,8 +749,8 @@ importers:
specifier: workspace:*
version: link:../sdk
'@pierre/diffs':
specifier: ^1.3.5
version: 1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: ^1.3.6
version: 1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
devDependencies:
'@types/node':
specifier: ^24.0.0
@ -3207,8 +3207,8 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@pierre/diffs@1.3.5':
resolution: {integrity: sha512-BhaLEiUvR+BdIyOYdogA4JLQjluWPubuwySmmIqEkcE0FwIWRbgJgHNC/r884dlxEr89fO4hTk/sBln0a7NSOw==}
'@pierre/diffs@1.3.6':
resolution: {integrity: sha512-a3woaW2QHy78JDxPJK0OJzwZUN4xoQLLIS/pceO8X6+L8gA5D682mP7/w3YxxEVRPXOaoe/p/RJ5Oj/3nrEzew==}
peerDependencies:
react: ^19.2.8
react-dom: ^19.2.8
@ -10574,7 +10574,7 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@pierre/diffs@1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@pierre/diffs@1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@pierre/theme': 2.0.0
'@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)

View File

@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
@ -40,41 +41,51 @@ function registerModuleMocks() {
// Identity object the mocked db.transaction hands to writers; tests assert
// both the marker clear and the settings update receive THIS same tx.
const TX_SENTINEL = { __tx: true };
// Runs the callback with a sentinel tx and propagates throws, so a failing
// write inside rejects the whole request exactly like a real transaction
// rollback. This is the default mockDb.transaction implementation; a test
// that installs its own mockImplementation loses this default, so
// beforeEach below reinstalls it before every test.
function defaultTransactionImplementation(fn: (tx: unknown) => Promise<unknown>) {
return fn(TX_SENTINEL);
}
// Module-scoped (not rebuilt per createApp call) so a test can assert how
// many times a request opened a transaction — the task-drain audit writes
// for every company must share ONE transaction, not one each.
const mockDb = {
// Runs the callback with a sentinel tx and propagates throws, so a
// failing write inside rejects the whole request exactly like a real
// transaction rollback.
transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(TX_SENTINEL)),
transaction: vi.fn(defaultTransactionImplementation),
};
async function createApp(actor: any) {
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any));
app.use(errorHandler);
return app;
}
describe("instance settings routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
return { errorHandler, instanceSettingsRoutes };
});
function createApp(actor: any) {
const { errorHandler, instanceSettingsRoutes } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any));
app.use(errorHandler);
return app;
}
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
// vi.clearAllMocks() clears recorded calls only; it does not remove a
// mockImplementation a prior test installed. Reinstall the default here
// so a stateful implementation from one test can never leak into the
// next one.
mockDb.transaction.mockReset();
mockDb.transaction.mockImplementation(defaultTransactionImplementation);
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.getGeneral.mockReset();
mockInstanceSettingsService.getExperimental.mockReset();
@ -1151,10 +1162,19 @@ describe("instance settings routes", () => {
const transactionCalls: string[] = [];
let releasePostTransaction: (() => void) | undefined;
let sawFirstCall = false;
// Resolves the instant the first (blocked) transaction call starts.
// The test then waits for this real event, not a fixed duration.
// Under CPU contention the event loop can take far longer than any
// fixed budget to reach this call, so a timer would flake here.
let notifyFirstTransactionStarted: (() => void) | undefined;
const firstTransactionStarted = new Promise<void>((resolve) => {
notifyFirstTransactionStarted = resolve;
});
mockDb.transaction.mockImplementation((fn: (tx: unknown) => Promise<unknown>) => {
if (!sawFirstCall) {
sawFirstCall = true;
transactionCalls.push("post-start");
notifyFirstTransactionStarted?.();
return new Promise((resolve) => {
releasePostTransaction = () => {
transactionCalls.push("post-commit");
@ -1166,19 +1186,44 @@ describe("instance settings routes", () => {
return fn(TX_SENTINEL);
});
// Each route handler awaits listCompanyIds as its last step before it
// enters the task-drain transition queue, so a second call proves the
// DELETE passed authorization and reached the queue — not merely that
// it has not arrived yet.
let listCompanyIdsCallCount = 0;
let notifySecondListCompanyIdsCall: (() => void) | undefined;
const secondListCompanyIdsCall = new Promise<void>((resolve) => {
notifySecondListCompanyIdsCall = resolve;
});
mockInstanceSettingsService.listCompanyIds.mockImplementation(async () => {
listCompanyIdsCallCount += 1;
if (listCompanyIdsCallCount === 2) notifySecondListCompanyIdsCall?.();
return ["company-1", "company-2"];
});
const app = await createApp(adminActor);
// supertest only sends the request once something calls .then() on
// it, so kick both off eagerly instead of waiting for the final
// Promise.all below to do it — otherwise neither request would even
// reach the (still-pending) POST transaction during the wait.
// supertest only sends a request once something calls .then() on it,
// so force the POST to send now instead of waiting for the final
// Promise.all below to do it.
const postPromise = request(app).post("/api/instance/task-drain").send({});
postPromise.then(() => {}, () => {});
// Wait for the POST's transaction call to start before the test sends
// the DELETE. At that point the POST already called listCompanyIds,
// already entered the task-drain transition queue, and sits blocked
// inside the mocked db.transaction call — the POST holds the queue.
// Only a real event proves this; a fixed wait would not, because the
// event loop can take far longer than any fixed budget under CPU
// contention. Sending the DELETE only after this event fixes the
// request order by the queue, not by which socket the operating
// system happens to service first.
await firstTransactionStarted;
const deletePromise = request(app).delete("/api/instance/task-drain");
deletePromise.then(() => {}, () => {});
// Give both requests time to reach as far as they can go before the
// POST's transaction is released.
await new Promise((resolve) => setTimeout(resolve, 30));
// Wait for the DELETE's own listCompanyIds call. It proves the DELETE
// passed authorization and reached the transition queue behind the
// POST — not merely that it has not shown up yet.
await secondListCompanyIdsCall;
expect(transactionCalls).toEqual(["post-start"]);
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();