Files
pleno-vue/tests/unit/request-queue-progress.spec.js

1141 lines
41 KiB
JavaScript

// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import axios from "axios";
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { API_URL } from "@/config.js";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
enqueueRequest,
reportComponentMissingPermission,
requestQueueState,
} from "@/services/requestQueue.js";
enableAutoUnmount(afterEach);
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
};
const flushManyMicrotasks = async (rounds = 10) => {
for (let index = 0; index < rounds; index += 1) {
await Promise.resolve();
}
};
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const triggerShiftTriplePress = async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
await flushMicrotasks();
};
describe("RequestQueueProgress", () => {
const resetSessionUserState = () => {
SessionUser.isSubuser.value = false;
SessionUser.user.display_name.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
};
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
__resetReleaseTimelineForTests();
__resetRequestQueueForTests();
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, status: 200 }))
);
resetSessionUserState();
});
afterEach(() => {
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
resetSessionUserState();
localStorage.clear();
window.history.pushState({}, "", "/");
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("is hidden by default and becomes visible + expanded on shift 3 times", async () => {
const wrapper = mount(RequestQueueProgress);
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false);
const first = createDeferred();
const second = createDeferred();
const requestOne = enqueueRequest(() => first.promise);
const requestTwo = enqueueRequest(() => second.promise);
await flushMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false);
await triggerShiftTriplePress();
expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("1 active, 1 queued");
expect(wrapper.find("[data-testid='request-queue-progress-details']").exists()).toBe(true);
first.resolve({ status: 200 });
await flushManyMicrotasks();
expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("Request queue:");
second.resolve({ status: 200 });
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("Request queue complete: 2/2");
vi.advanceTimersByTime(2600);
await flushMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true);
});
it("stays hidden until shift 3 times even for small batches", async () => {
const wrapper = mount(RequestQueueProgress);
const onlyRequest = createDeferred();
const request = enqueueRequest(() => onlyRequest.promise);
await flushManyMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false);
await triggerShiftTriplePress();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true);
onlyRequest.resolve({ status: 200 });
await request;
await flushManyMicrotasks();
});
it("closes when escape is pressed while open", async () => {
const wrapper = mount(RequestQueueProgress);
const first = createDeferred();
const second = createDeferred();
const requestOne = enqueueRequest(() => first.promise, { method: "GET", url: "/esc-close-1" });
const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/esc-close-2" });
await flushManyMicrotasks();
await triggerShiftTriplePress();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await flushManyMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false);
first.resolve({ status: 200 });
second.resolve({ status: 200 });
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
});
it("dispatches system-search close event on shift 3x for superuser", async () => {
vi.spyOn(SessionUser, "canAccessSuperUser").mockReturnValue(true);
const closeEventListener = vi.fn();
window.addEventListener("system-search:close", closeEventListener);
mount(RequestQueueProgress);
await triggerShiftTriplePress();
expect(closeEventListener).toHaveBeenCalled();
expect(closeEventListener.mock.calls.some(([event]) => event?.type === "system-search:close")).toBe(true);
window.removeEventListener("system-search:close", closeEventListener);
});
it("expands upward with active endpoint details, method icon and timer", async () => {
const wrapper = mount(RequestQueueProgress);
const first = createDeferred();
const second = createDeferred();
const requestOne = enqueueRequest(() => first.promise, { method: "POST", url: "/orders" });
const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/order/items" });
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
expect(wrapper.find("[data-testid='request-queue-errors-box']").exists()).toBe(true);
expect(wrapper.find("[data-testid='request-queue-missing-permissions-box']").exists()).toBe(true);
expect(wrapper.find("[data-testid='request-queue-runtime-box']").exists()).toBe(true);
expect(wrapper.find("[data-testid='request-queue-user-box']").exists()).toBe(true);
const details = wrapper.get("[data-testid='request-queue-progress-details']");
expect(details.text()).toContain("Active requests");
expect(details.text()).toContain("POST");
expect(details.text()).toContain("/orders");
expect(details.text()).toMatch(/ms|s/);
expect(details.find(".fa-plus-circle").exists()).toBe(true);
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("API URL");
expect(runtimeBox.text()).toContain("Current host");
expect(runtimeBox.text()).toContain("Environment");
expect(runtimeBox.text()).toContain("Commit");
expect(runtimeBox.text()).toContain("Version time");
expect(runtimeBox.text()).toContain("Outgoing requests");
expect(runtimeBox.text()).toContain("Ingoing responses");
expect(runtimeBox.text()).toContain("Outgoing bandwidth");
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
const userBox = wrapper.get("[data-testid='request-queue-user-box']");
expect(userBox.text()).toContain("User type");
expect(userBox.text()).toContain("User");
first.resolve({ status: 200 });
second.resolve({ status: 200 });
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
});
it("tracks runtime ingoing/outgoing request counts and bandwidth totals", async () => {
const wrapper = mount(RequestQueueProgress);
const first = createDeferred();
const second = createDeferred();
const requestOne = enqueueRequest(() => first.promise, {
method: "POST",
url: "/runtime-metrics-a",
requestData: {
headers: { "X-Trace": "a" },
data: { payload: "first" },
},
});
const requestTwo = enqueueRequest(() => second.promise, {
method: "GET",
url: "/runtime-metrics-b",
requestData: {
params: { page: 1, limit: 10 },
},
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
first.resolve({ status: 200, data: { ok: true, item: "a" } });
second.resolve({ status: 200, data: { ok: true, item: "b" } });
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
expect(requestQueueState.networkTotals.outgoingRequests).toBe(2);
expect(requestQueueState.networkTotals.ingoingResponses).toBe(2);
expect(requestQueueState.networkTotals.outgoingBytes).toBeGreaterThan(0);
expect(requestQueueState.networkTotals.ingoingBytes).toBeGreaterThan(0);
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Outgoing requests");
expect(runtimeBox.text()).toContain("Ingoing responses");
expect(runtimeBox.text()).toContain("Outgoing bandwidth");
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
});
it("shows the active session release bundle, app URLs, and connected services to release operators", async () => {
localStorage.setItem("superuser_token", "su-token");
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-request-panel",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Session release");
expect(runtimeBox.text()).toContain("Canary");
expect(runtimeBox.text()).toContain("trace-request-panel");
expect(runtimeBox.text()).toContain("#31 canary-bundle");
expect(runtimeBox.text()).toContain("Canary isolated stack");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain("frontend-canary");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain(
"https://api-v2.truckwash.io/canary/frontend"
);
expect(wrapper.get("[data-testid='request-queue-release-app-api']").text()).toContain("api-canary");
expect(wrapper.get("[data-testid='request-queue-release-service-database']").text()).toContain("canary-db #54");
expect(wrapper.get("[data-testid='request-queue-release-service-redis']").text()).toContain("canary-redis #55");
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
});
it("hides release infrastructure metadata from low-privileged subusers", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: ["order_read"] }];
SessionUser.subuser.selectedGrantCustomerNumber.value = 12345;
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-low-privilege",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: { version_label: "api-canary", status: "deployed" },
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
coolify_service_uuid: "database-service-uuid",
health_url: "https://coolify.internal/health/db",
},
},
},
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Runtime details");
expect(runtimeBox.text()).not.toContain("Session release");
expect(runtimeBox.text()).not.toContain("trace-low-privilege");
expect(runtimeBox.text()).not.toContain("Canary isolated stack");
expect(runtimeBox.text()).not.toContain("canary-db");
expect(runtimeBox.text()).not.toContain("database-resource-uuid");
expect(runtimeBox.text()).not.toContain("coolify.internal");
expect(runtimeBox.text()).not.toContain("api-v2.truckwash.io");
expect(runtimeBox.text()).toContain("Restricted to release operators");
expect(wrapper.find("[data-testid='request-queue-release-service-database']").exists()).toBe(false);
});
it("shows subuser details in the user box", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.name.value = "Sub User";
SessionUser.subuser.id.value = 77;
SessionUser.subuser.email.value = "subuser@example.com";
SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: [] }];
SessionUser.subuser.selectedGrantCustomerNumber.value = 12345;
const wrapper = mount(RequestQueueProgress);
const first = createDeferred();
const second = createDeferred();
const requestOne = enqueueRequest(() => first.promise, { method: "GET", url: "/subuser-check-1" });
const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/subuser-check-2" });
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const userBox = wrapper.get("[data-testid='request-queue-user-box']");
expect(userBox.text()).toContain("User type");
expect(userBox.text()).toContain("Subuser");
expect(userBox.text()).toContain("Sub User");
expect(userBox.text()).toContain("subuser@example.com");
expect(userBox.text()).toContain("12345");
first.resolve({ status: 200 });
second.resolve({ status: 200 });
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
});
it("shows ping and bookings insights in the bottom of the middle panel", async () => {
const wrapper = mount(RequestQueueProgress);
const pingRequest = createDeferred();
const bookingsRequest = createDeferred();
const scannerRequest = createDeferred();
const requestOne = enqueueRequest(() => pingRequest.promise, { method: "GET", url: "/ping" });
const requestTwo = enqueueRequest(() => bookingsRequest.promise, { method: "GET", url: "/order-bookings" });
const requestThree = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const scannerServerTiming =
"lpr_client_capture;dur=12.345, lpr_client_preflight;dur=1.500, lpr_client_visual_fingerprint;dur=0.750, lpr_client_draw;dur=2.250, lpr_client_encode;dur=8.500, lpr_client_frame_width;dur=300.000, lpr_client_frame_height;dur=225.000, lpr_client_frame_bytes;dur=12345.000, lpr_cache_miss;dur=1.000, lpr_cache;dur=0.750, lpr_local;dur=2.500, lpr_upstream_processing;dur=30.250, lpr_total;dur=42.500, lpr_upstream_total;dur=40.000";
pingRequest.resolve({ status: 200 });
bookingsRequest.resolve({ status: 200 });
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await Promise.all([requestOne, requestTwo, requestThree]);
await flushManyMicrotasks();
const insights = wrapper.get("[data-testid='request-queue-bottom-request-insights']");
expect(insights.text()).toContain("Ping");
expect(insights.text()).toContain("Bookings");
expect(insights.text()).toContain("Scanner");
expect(insights.text()).toContain("browser");
expect(insights.text()).toContain("cap 12.345 ms");
expect(insights.text()).toContain("prep 1.5 ms");
expect(insights.text()).toContain("vf 0.75 ms");
expect(insights.text()).toContain("draw 2.25 ms");
expect(insights.text()).toContain("enc 8.5 ms");
expect(insights.text()).toContain("img 300x225");
expect(insights.text()).toContain("bytes 12.1 KB");
expect(insights.text()).toContain("cache miss");
expect(insights.text()).toContain("cache 0.75 ms");
expect(insights.text()).toContain("local 2.5 ms");
expect(insights.text()).toContain("proc 30.25 ms");
expect(insights.text()).toContain("up 40 ms");
expect(insights.text()).toContain("srv 42.5 ms");
const pingInsight = wrapper.get("[data-testid='request-queue-insight-ping']");
const bookingsInsight = wrapper.get("[data-testid='request-queue-insight-bookings']");
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(pingInsight.text()).toMatch(/now|s ago|m ago/);
expect(bookingsInsight.text()).toMatch(/now|s ago|m ago/);
expect(scannerInsight.text()).toMatch(/now|s ago|m ago/);
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("shows scanner cache hits without upstream timing", async () => {
const wrapper = mount(RequestQueueProgress);
const scannerRequest = createDeferred();
const request = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const scannerServerTiming =
"lpr_client_capture;dur=9.500, lpr_cache_hit;dur=1.000, lpr_cache;dur=0.430, lpr_local;dur=1.200, lpr_total;dur=1.200";
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await request;
await flushManyMicrotasks();
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("cache hit");
expect(scannerInsight.text()).toContain("cache 0.43 ms");
expect(scannerInsight.text()).toContain("local 1.2 ms");
expect(scannerInsight.text()).toContain("srv 1.2 ms");
expect(scannerInsight.text()).not.toContain("up ");
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("shows scanner insights when successful frames skip the generic recent request list", async () => {
const wrapper = mount(RequestQueueProgress);
const staleRecentRequest = createDeferred();
const scannerRequest = createDeferred();
const staleRequest = enqueueRequest(() => staleRecentRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
const request = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
staleRecentRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_client_capture;dur=99.000, lpr_total;dur=120.000",
},
});
await staleRequest;
await flushManyMicrotasks();
vi.advanceTimersByTime(5);
const scannerServerTiming = "lpr_client_capture;dur=8.000, lpr_total;dur=24.000";
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await request;
await flushManyMicrotasks();
expect(requestQueueState.recentRequests).toHaveLength(1);
expect(requestQueueState.recentRequests[0].serverTiming).toContain("dur=99.000");
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("browser");
expect(scannerInsight.text()).toContain("cap 8 ms");
expect(scannerInsight.text()).toContain("srv 24 ms");
expect(scannerInsight.text()).not.toContain("99 ms");
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("updates scanner request insights without replacing the insights container", async () => {
const firstScannerRequest = createDeferred();
const secondScannerRequest = createDeferred();
const firstRequest = enqueueRequest(() => firstScannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
firstScannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=24.000",
},
});
await firstRequest;
await flushManyMicrotasks();
const requestInsightsRef = requestQueueState.requestInsights;
expect(requestInsightsRef.scanner?.serverTiming).toBe("lpr_total;dur=24.000");
const secondRequest = enqueueRequest(() => secondScannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
secondScannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=18.000",
},
});
await secondRequest;
await flushManyMicrotasks();
expect(requestQueueState.requestInsights).toBe(requestInsightsRef);
expect(requestQueueState.requestInsights.scanner?.serverTiming).toBe("lpr_total;dur=18.000");
});
it("shows scanner queue wait separately from browser request time", async () => {
const wrapper = mount(RequestQueueProgress);
const blockingRequest = createDeferred();
const scannerRequest = createDeferred();
const requestOne = enqueueRequest(() => blockingRequest.promise, { method: "POST", url: "/blocking-post" });
const requestTwo = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
vi.advanceTimersByTime(37);
blockingRequest.resolve({ status: 200 });
await requestOne;
await flushManyMicrotasks();
vi.advanceTimersByTime(63);
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=21.000",
},
});
await requestTwo;
await flushManyMicrotasks();
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("browser 63 ms");
expect(scannerInsight.text()).toContain("queue 37 ms");
expect(scannerInsight.text()).toContain("net 42 ms");
expect(scannerInsight.text()).toContain("srv 21 ms");
});
it("does not emit recursive update errors while processing many requests", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const requests = Array.from({ length: 30 }, (_, index) =>
enqueueRequest(async () => ({ status: 200, data: { index } }), {
method: "GET",
url: `/bulk-recursive-check-${index}`,
})
);
await Promise.all(requests);
await flushManyMicrotasks(30);
const hasRecursiveError = [...errorSpy.mock.calls, ...warnSpy.mock.calls].some((call) =>
call.join(" ").includes("Maximum recursive updates exceeded")
);
expect(hasRecursiveError).toBe(false);
});
it("stores server timing headers with completed and failed request entries", async () => {
__configureRequestQueueForTests({
errorHistoryLimit: 2,
retryByStatusCode: {},
});
const completed = enqueueRequest(
async () => ({
status: 200,
headers: {
"server-timing": "lpr_total;dur=42.500",
},
data: { ok: true },
}),
{
method: "POST",
url: "/modules/scanner/lpr",
}
);
const failed = enqueueRequest(
async () => {
throw {
message: "Scanner failed",
response: {
status: 500,
headers: {
"Server-Timing": "lpr_upstream;dur=450.000",
},
data: { ok: false },
},
};
},
{
method: "POST",
url: "/modules/scanner/lpr",
}
).catch((error) => error);
await Promise.all([completed, failed]);
await flushManyMicrotasks();
expect(requestQueueState.recentRequests).toHaveLength(2);
expect(requestQueueState.recentRequests.find((request) => request.success === true)?.serverTiming).toBe(
"lpr_total;dur=42.500"
);
expect(requestQueueState.recentRequests.find((request) => request.success === false)?.serverTiming).toBe(
"lpr_upstream;dur=450.000"
);
expect(requestQueueState.errorRequests[0].serverTiming).toBe("lpr_upstream;dur=450.000");
});
it("stores errors with request and response payloads and respects configured error limit", async () => {
__configureRequestQueueForTests({
errorHistoryLimit: 1,
missingPermissionsLimit: 1,
retryByStatusCode: {},
payloadMaxChars: 8000,
});
const wrapper = mount(RequestQueueProgress);
const requestOne = enqueueRequest(
async () => {
throw {
message: "First failure",
response: { status: 500, data: { reason: "first" } },
};
},
{
method: "POST",
url: "/first-error",
requestData: { data: { requestBody: "first" } },
}
).catch((error) => error);
const requestTwo = enqueueRequest(
async () => {
throw {
message: "Second failure",
response: {
status: 403,
data: {
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
meta: [],
includes: [],
},
},
};
},
{
method: "PATCH",
url: "/second-error",
requestData: { data: { requestBody: "second" } },
}
).catch((error) => error);
await Promise.all([requestOne, requestTwo]);
await flushManyMicrotasks();
expect(requestQueueState.errorRequests.length).toBe(1);
expect(requestQueueState.errorRequests[0].url).toBe("/second-error");
expect(requestQueueState.errorRequests[0].requestText).toContain("requestBody");
expect(requestQueueState.errorRequests[0].responseText).toContain("Missing permission(s)");
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get");
await triggerShiftTriplePress();
await flushManyMicrotasks();
const errorsBox = wrapper.get("[data-testid='request-queue-errors-box']");
expect(errorsBox.text()).toContain("Errors (1)");
expect(errorsBox.text()).toContain("/second-error");
expect(errorsBox.text()).toContain("Request and response data");
expect(errorsBox.text()).toContain("Request");
expect(errorsBox.text()).toContain("Response");
const missingPermissionsBox = wrapper.get("[data-testid='request-queue-missing-permissions-box']");
expect(missingPermissionsBox.text()).toContain("department_notification_sms_get");
await wrapper.get("[data-testid='request-queue-clear-errors']").trigger("click");
await flushManyMicrotasks();
expect(requestQueueState.errorRequests.length).toBe(0);
expect(wrapper.get("[data-testid='request-queue-errors-box']").text()).toContain("No stored errors");
expect(requestQueueState.missingPermissions.length).toBe(1);
await wrapper.get("[data-testid='request-queue-clear-permissions']").trigger("click");
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(0);
expect(wrapper.get("[data-testid='request-queue-missing-permissions-box']").text()).toContain(
"No missing permissions detected"
);
});
it("copies a redacted readable trace for stored request errors", async () => {
__configureRequestQueueForTests({
errorHistoryLimit: 2,
retryByStatusCode: {},
payloadMaxChars: 8000,
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
window.history.pushState({}, "", "/trace-copy-page?token=raw-active-url-token");
const wrapper = mount(RequestQueueProgress);
const request = enqueueRequest(
async () => {
throw {
message: "Trace copy failure",
response: {
status: 502,
statusText: "Bad Gateway",
headers: {
"server-timing": "app;dur=123.400",
},
data: {
message: "Trace copy failed",
secret: "raw-response-secret",
visible_response: "trace-copy-visible-response",
},
},
};
},
{
method: "POST",
url: "https://api-v2.truckwash.io/master/api/debug/trace-copy",
requestData: {
data: {
authorization: "Bearer raw-body-token",
visible_value: "trace-copy-visible-request",
},
headers: {
Authorization: "Bearer raw-header-token",
},
},
traceContext: {
component: "TraceCopyUnitHarness",
trace: "TraceCopyUnitHarness -> submit",
},
}
).catch((error) => error);
await request;
await flushManyMicrotasks();
const errorEntry = requestQueueState.errorRequests[0];
expect(errorEntry.request.data.authorization).toBe("[redacted]");
expect(errorEntry.response.data.secret).toBe("[redacted]");
await triggerShiftTriplePress();
await flushManyMicrotasks();
await wrapper.get(`[data-testid='request-queue-copy-trace-${errorEntry.id}']`).trigger("click");
await flushManyMicrotasks();
expect(writeText).toHaveBeenCalledTimes(1);
const copiedText = writeText.mock.calls[0][0];
expect(copiedText).toContain("Request Error Trace");
expect(copiedText).toContain("Active URL:");
expect(copiedText).toContain("token=[redacted]");
expect(copiedText).toContain("Initializing component: TraceCopyUnitHarness");
expect(copiedText).toContain("TraceCopyUnitHarness -> submit");
expect(copiedText).toContain("Method: POST");
expect(copiedText).toContain("Endpoint: /debug/trace-copy");
expect(copiedText).toContain("trace-copy-visible-request");
expect(copiedText).toContain('"authorization": "[redacted]"');
expect(copiedText).toContain("trace-copy-visible-response");
expect(copiedText).toContain("Server response time: app 123.4 ms");
expect(copiedText).toContain("Recreate: Open the active URL, repeat the action in TraceCopyUnitHarness");
expect(copiedText).not.toContain("https://api-v2.truckwash.io/master/api");
expect(copiedText).not.toContain("raw-active-url-token");
expect(copiedText).not.toContain("raw-body-token");
expect(copiedText).not.toContain("raw-header-token");
expect(copiedText).not.toContain("raw-response-secret");
});
it("stays visible after errors occur", async () => {
__configureRequestQueueForTests({ errorHistoryLimit: 2 });
const wrapper = mount(RequestQueueProgress);
const failed = enqueueRequest(
async () => {
throw {
message: "Failure should keep panel visible",
response: { status: 500, data: { detail: "boom" } },
};
},
{
method: "DELETE",
url: "/error-visible",
requestData: { data: { id: 123 } },
}
).catch((error) => error);
await failed;
await flushManyMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false);
await triggerShiftTriplePress();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true);
vi.advanceTimersByTime(30_000);
await flushManyMicrotasks();
expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true);
await flushManyMicrotasks();
const errorsBox = wrapper.get("[data-testid='request-queue-errors-box']");
expect(errorsBox.text()).toContain("Errors");
expect(errorsBox.text()).toContain("/error-visible");
});
it("stores missing permissions when response payload is stringified JSON", async () => {
__configureRequestQueueForTests({
missingPermissionsLimit: 5,
retryByStatusCode: {},
});
const request = enqueueRequest(
async () => {
throw {
message: "Request failed with status code 403",
response: {
status: 403,
data: JSON.stringify({
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
meta: [],
includes: [],
}),
},
};
},
{
method: "GET",
url: "/notification/sms",
}
).catch((error) => error);
await request;
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get");
});
it("stores component permissions with COMPONENT method and keeps them below API entries", async () => {
const wrapper = mount(RequestQueueProgress);
const apiRequest = enqueueRequest(
async () => {
throw {
message: "Request failed with status code 403",
response: {
status: 403,
data: {
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
},
},
};
},
{
method: "GET",
url: "/notification/sms",
}
).catch((error) => error);
await apiRequest;
reportComponentMissingPermission("goals_department_create", {
source: "DepartmentGoals:CreateGoalButton",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const rows = wrapper
.get("[data-testid='request-queue-missing-permissions-box']")
.findAll(".request-queue-progress__list-item--permission")
.map((item) => item.text());
expect(rows.length).toBe(2);
expect(rows[0]).toContain("department_notification_sms_get");
expect(rows[0]).toContain("GET");
expect(rows[1]).toContain("goals_department_create");
expect(rows[1]).toContain("COMPONENT");
});
it("throttles repeated component permission reports", async () => {
__configureRequestQueueForTests({
componentPermissionReportWindowMs: 5000,
missingPermissionsLimit: 10,
});
reportComponentMissingPermission("goals_department_create", {
source: "DepartmentGoals:CreateGoalButton",
});
reportComponentMissingPermission("goals_department_create", {
source: "DepartmentGoals:CreateGoalButton",
});
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].count).toBe(1);
expect(requestQueueState.missingPermissions[0].method).toBe("COMPONENT");
vi.advanceTimersByTime(5100);
reportComponentMissingPermission("goals_department_create", {
source: "DepartmentGoals:CreateGoalButton",
});
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].count).toBe(2);
});
it("shows add button for missing permissions when superuser impersonates a user", async () => {
localStorage.setItem("superuser_token", "su-token");
SessionUser.user.group_id.value = 19;
const wrapper = mount(RequestQueueProgress);
const request = enqueueRequest(
async () => {
throw {
message: "Request failed with status code 403",
response: {
status: 403,
data: {
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
},
},
};
},
{
method: "GET",
url: "/notification/sms",
}
).catch((error) => error);
await request;
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
expect(
wrapper.find("[data-testid='request-queue-permission-action-department_notification_sms_get-GET']").exists()
).toBe(true);
});
it("shows add button for COMPONENT missing permissions when superuser impersonates a user", async () => {
localStorage.setItem("superuser_token", "su-token");
SessionUser.user.group_id.value = 19;
const wrapper = mount(RequestQueueProgress);
reportComponentMissingPermission("goals_department_create", {
source: "DepartmentGoals:CreateGoalButton",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
expect(
wrapper.find("[data-testid='request-queue-permission-action-goals_department_create-COMPONENT']").exists()
).toBe(true);
});
it("adds missing permission with superuser token and shows disabled checkmark on success", async () => {
localStorage.setItem("superuser_token", "su-token");
SessionUser.user.group_id.value = 19;
const axiosPostSpy = vi.spyOn(axios, "post").mockResolvedValue({ status: 200, data: {} });
const wrapper = mount(RequestQueueProgress);
const request = enqueueRequest(
async () => {
throw {
message: "Request failed with status code 403",
response: {
status: 403,
data: {
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
},
},
};
},
{
method: "GET",
url: "/notification/sms",
}
).catch((error) => error);
await request;
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const actionButton = wrapper.get(
"[data-testid='request-queue-permission-action-department_notification_sms_get-GET']"
);
await actionButton.trigger("click");
await flushManyMicrotasks();
expect(axiosPostSpy).toHaveBeenCalledWith(
`${API_URL}/roles/permissions`,
{
group_id: 19,
permission_id: "department_notification_sms_get",
},
{
headers: {
Authorization: "Bearer su-token",
},
}
);
expect(actionButton.attributes("disabled")).toBeDefined();
expect(actionButton.find(".fa-check").exists()).toBe(true);
axiosPostSpy.mockRestore();
});
});