fix: simplify Ticket reporting and speed up browser lists
Remove model-supplied assignment and retry identities from implementation reports; retain Host idempotency, authentication and notification evidence. Add a SQL-only browser projection and decouple auxiliary page loads. Preserve authoritative MR evidence queries. Include regression tests and component benchmarks, documenting the remaining no-match search cost.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
@@ -137,6 +137,12 @@ export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProje
|
||||
|
||||
export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
|
||||
|
||||
export type TicketBrowseQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; current_closure?: TicketClosureJudgment | null; id: string; resource_key: string; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; updated_at?: string | null; };
|
||||
|
||||
export type TicketBrowseQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; closure_reason?: TicketClosureReason | null; cursor?: string | null; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; query?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; };
|
||||
|
||||
export type TicketBrowseQueryResponse = { items: Array<TicketBrowseQueryItem>; page: QueryPage; record_authority: string; };
|
||||
|
||||
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
|
||||
|
||||
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
|
||||
@@ -169,7 +175,7 @@ export type TicketImplementationResultKind = "handoff" | "result" | "blocker";
|
||||
|
||||
export type TicketImplementationResultReceipt = { created_at: string; request: TicketImplementationResultRequest; result_id: string; ticket_id: string; };
|
||||
|
||||
export type TicketImplementationResultRequest = { assignment_id: string; body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; orchestrator_assignment_id: string; };
|
||||
export type TicketImplementationResultRequest = { body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; };
|
||||
|
||||
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -2,6 +2,7 @@ declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
|
||||
import type { TicketBrowseQueryResponse } from "#lib/generated/ticket-api.ts";
|
||||
import { loadJson } from "./http.ts";
|
||||
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
parseObjectiveListResponse,
|
||||
parseTicketDetail,
|
||||
parseTicketListResponse,
|
||||
parseTicketBrowseQueryResponse,
|
||||
parseTicketRecordRef,
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
parseTicketSummary,
|
||||
@@ -45,6 +47,97 @@ const page = {
|
||||
source_truncated: false,
|
||||
};
|
||||
|
||||
function browseFixture(): TicketBrowseQueryResponse {
|
||||
return {
|
||||
record_authority: "sqlite",
|
||||
page: { ...page, has_more: true, next_cursor: "opaque-cursor" },
|
||||
items: [{
|
||||
id: "ticket-id", resource_key: "T-1", title: "Minimal browse row",
|
||||
state: "open", updated_at: null, tags: [], current_closure: null,
|
||||
assignment_tracking: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Ticket browse accepts the generated minimal row and preserves cursor metadata without enriched query fields", () => {
|
||||
const fixture = browseFixture();
|
||||
const parsed = parseTicketBrowseQueryResponse(fixture);
|
||||
assertEquals(parsed, { page: fixture.page, record_authority: "sqlite", items: fixture.items });
|
||||
for (const field of ["evidence", "merge_requests", "content_digest", "snippet", "blocker_count", "current_worker"]) {
|
||||
assertEquals(field in parsed.items[0], false);
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: null }] }), "unknown field");
|
||||
}
|
||||
assertEquals(parseTicketBrowseQueryResponse({ ...fixture, items: [], page: { ...page, returned: 0 } }).items, []);
|
||||
});
|
||||
|
||||
Deno.test("Ticket browse preserves case, tags and self-reported handling independently", () => {
|
||||
const fixture = browseFixture();
|
||||
const assignment = {
|
||||
workspace_id: "workspace-a", ticket_id: "ticket-id", assignment_id: "delegation-1",
|
||||
role: "orchestrator", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
assigned_by: "user:alice", assigned_at: "2026-10-15T00:00:00Z", current: true,
|
||||
release: null, last_event_id: "event-1", updated_at: null, migration_diagnostic: null,
|
||||
tracking: { status: "done", waiting_reason: null, progress: "Investigated", next_action: "Human decision", result: "Options returned" },
|
||||
};
|
||||
for (const state of ["open", "closed"]) {
|
||||
const row = parseTicketBrowseQueryResponse({
|
||||
...fixture,
|
||||
items: [{ ...fixture.items[0], state, tags, current_closure: state === "closed" ? closure : null, assignment_tracking: [assignment] }],
|
||||
}).items[0];
|
||||
assertEquals(row.state, state);
|
||||
assertEquals(row.tags, tags);
|
||||
for (const [field, expected] of Object.entries(assignment)) {
|
||||
assertEquals((row.assignment_tracking[0] as unknown as Record<string, unknown>)[field], expected);
|
||||
}
|
||||
assertEquals(row.current_closure, state === "closed" ? closure : null);
|
||||
}
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], assignment_tracking: [{ ...assignment, tracking: { status: "running" } }] }] }), "status is invalid");
|
||||
});
|
||||
|
||||
Deno.test("Ticket browse rejects missing required fields, legacy states and malformed row values", () => {
|
||||
const fixture = browseFixture();
|
||||
for (const field of ["id", "resource_key", "title", "state", "tags", "assignment_tracking"]) {
|
||||
const row: Record<string, unknown> = { ...fixture.items[0] };
|
||||
delete row[field];
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [row] }), `items[0].${field}`);
|
||||
}
|
||||
for (const [field, value] of [
|
||||
["id", 1], ["title", false], ["state", "done"], ["state", "queued"],
|
||||
["updated_at", 7], ["tags", null], ["tags", [{ ...tags[0], id: " " }]],
|
||||
["assignment_tracking", {}], ["current_closure", { ...closure, reason: "done" }],
|
||||
] as const) {
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: value }] }), `items[0].${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Ticket browse validates envelope, pagination and collection bounds", () => {
|
||||
const fixture = browseFixture();
|
||||
for (const field of ["items", "page", "record_authority"]) {
|
||||
const wire: Record<string, unknown> = { ...fixture };
|
||||
delete wire[field];
|
||||
assertThrows(() => parseTicketBrowseQueryResponse(wire), `response.${field}`);
|
||||
}
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, unexpected: true }), "unknown field");
|
||||
for (const [field, value] of [["has_more", "yes"], ["next_cursor", 42], ["limit", -1], ["returned", 1.5], ["source_truncated", null]] as const) {
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, [field]: value } }), `page.${field}`);
|
||||
}
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, extra: true } }), "unknown field");
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: Array(4097).fill(fixture.items[0]) }), "items exceeds its item limit");
|
||||
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], title: "x".repeat(262145) }] }), "title exceeds its length limit");
|
||||
});
|
||||
|
||||
Deno.test("Ticket browse load diagnostics identify the malformed field without exposing row content", async () => {
|
||||
const fixture = browseFixture();
|
||||
const result = await loadJson(
|
||||
(async () => Response.json({ ...fixture, items: [{ ...fixture.items[0], title: "PRIVATE_TITLE", tags: null }] })) as typeof fetch,
|
||||
"/api/w/workspace-a/tickets/browse", { method: "POST" },
|
||||
parseTicketBrowseQueryResponse, TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
assertEquals(result.data, null);
|
||||
assertEquals(result.error, "Ticket Browser API: Ticket browse response.items[0].tags must be an array");
|
||||
assertEquals(result.error?.includes("PRIVATE_TITLE"), false);
|
||||
});
|
||||
|
||||
Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => {
|
||||
const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" });
|
||||
const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } });
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
TicketListItemSummary,
|
||||
TicketListResponse,
|
||||
TicketMergeRequestSummary,
|
||||
TicketBrowseQueryItem,
|
||||
TicketBrowseQueryResponse,
|
||||
TicketQueryItem,
|
||||
TicketQueryResponse,
|
||||
TicketQueueOutcome,
|
||||
@@ -1776,6 +1778,37 @@ export function parseMergeRequestDetailResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTicketBrowseQueryResponse(value: unknown): TicketBrowseQueryResponse {
|
||||
const label = "Ticket browse response";
|
||||
const page = object(value, label);
|
||||
exact(page, ["items", "page", "record_authority"], label);
|
||||
return {
|
||||
page: parseQueryPage(page.page, `${label}.page`),
|
||||
record_authority: string(page.record_authority, `${label}.record_authority`),
|
||||
items: array(page.items, `${label}.items`, (value, label): TicketBrowseQueryItem => {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"id", "resource_key", "title", "state", "updated_at", "tags",
|
||||
"current_closure", "assignment_tracking",
|
||||
], label);
|
||||
return {
|
||||
id: string(item.id, `${label}.id`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
title: string(item.title, `${label}.title`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
|
||||
current_closure: item.current_closure == null
|
||||
? item.current_closure as null | undefined
|
||||
: parseClosure(item.current_closure, `${label}.current_closure`),
|
||||
assignment_tracking: array(
|
||||
item.assignment_tracking, `${label}.assignment_tracking`, parseAssignmentTrackingProjection,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTicketQueryResponse(value: unknown): TicketQueryResponse {
|
||||
const label = "Ticket query response";
|
||||
const page = object(value, label);
|
||||
|
||||
@@ -391,7 +391,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
|
||||
// クエリ・ページングの振舞いはticket-search.testとTicket browser suiteで検証する。
|
||||
assert(
|
||||
ticketsLoad.includes("compileTicketSearch") &&
|
||||
ticketsLoad.includes("/tickets/query") &&
|
||||
ticketsLoad.includes("/tickets/browse") &&
|
||||
ticketsPage.includes('class="ticket-list"') &&
|
||||
!ticketsPage.includes("TicketQueryFilters") &&
|
||||
!ticketsPage.includes("ticket-kanban"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
TicketAssignmentPrincipalSnapshot,
|
||||
TicketClosureReason,
|
||||
TicketQueryRequest,
|
||||
TicketBrowseQueryRequest,
|
||||
WorkspaceTag,
|
||||
} from "../../generated/ticket-api.ts";
|
||||
|
||||
@@ -114,15 +114,15 @@ function reason(value: string, quoted: boolean): TicketClosureReason {
|
||||
export function compileTicketSearch(
|
||||
input: string,
|
||||
tags: readonly WorkspaceTag[],
|
||||
): TicketQueryRequest {
|
||||
const request: TicketQueryRequest = {
|
||||
): TicketBrowseQueryRequest {
|
||||
const request: TicketBrowseQueryRequest = {
|
||||
query: null,
|
||||
states: [],
|
||||
tag_ids: [],
|
||||
assigned_principal: null,
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30 as TicketQueryRequest["limit"],
|
||||
limit: 30 as TicketBrowseQueryRequest["limit"],
|
||||
cursor: null,
|
||||
};
|
||||
const text: string[] = [];
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
|
||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
parseTicketQueryResponse,
|
||||
parseTicketBrowseQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
ticketSearchNeedsTags,
|
||||
} from "#lib/workspace/tickets/ticket-search.ts";
|
||||
import type {
|
||||
TicketQueryRequest,
|
||||
TicketBrowseQueryRequest,
|
||||
WorkspaceTag,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
@@ -22,7 +20,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
|
||||
const workspaceId = params.workspaceId;
|
||||
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
|
||||
const loadTickets = async () => {
|
||||
let queryRequest: TicketQueryRequest | null = null;
|
||||
let queryRequest: TicketBrowseQueryRequest | null = null;
|
||||
try {
|
||||
const tags: WorkspaceTag[] = [];
|
||||
// タグ名をstable IDへ解決する場合だけ一覧を取得する。状態検索をタグAPIに依存させない。
|
||||
@@ -54,13 +52,13 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
|
||||
queryRequest = compileTicketSearch(search, tags);
|
||||
const tickets = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
workspaceApiPath(workspaceId, "/tickets/browse"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(queryRequest),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
parseTicketBrowseQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
return { queryRequest, tickets };
|
||||
@@ -74,15 +72,6 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
|
||||
};
|
||||
}
|
||||
};
|
||||
const [result, orchestrator, repositories] = await Promise.all([
|
||||
loadTickets(),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, workspaceId),
|
||||
]);
|
||||
return { workspaceId, search, ...result, orchestrator, repositories };
|
||||
const result = await loadTickets();
|
||||
return { workspaceId, search, ...result };
|
||||
});
|
||||
|
||||
@@ -10,15 +10,17 @@
|
||||
workspaceApiPath,
|
||||
} from "#lib/workspace/api/http.ts";
|
||||
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
|
||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||
import type { RepositoryListResponse } from "#lib/generated/repository-api.ts";
|
||||
import {
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
parseTicketQueryResponse,
|
||||
parseTicketBrowseQueryResponse,
|
||||
parseTicketRecordRef,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import type {
|
||||
NewTicket,
|
||||
TicketQueryItem as TicketSummary,
|
||||
TicketBrowseQueryItem as TicketSummary,
|
||||
TicketTarget,
|
||||
TicketTargetAccess,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
@@ -44,19 +46,33 @@
|
||||
let searchBusy = $state(false);
|
||||
let pageBusy = $state(false);
|
||||
let navigationError = $state<string | null>(null);
|
||||
let routeGeneration = 0;
|
||||
$effect(() => {
|
||||
const currentData = data;
|
||||
const generation = ++routeGeneration;
|
||||
untrack(() => {
|
||||
response = currentData.tickets;
|
||||
searchText = currentData.search;
|
||||
searchBusy = false;
|
||||
pageBusy = false;
|
||||
navigationError = null;
|
||||
orchestrator = currentData.orchestrator;
|
||||
orchestrator = { data: null, error: null };
|
||||
orchestratorLoading = true;
|
||||
orchestratorStarting = false;
|
||||
repositoryResponse = { data: null, error: null };
|
||||
repositoriesLoading = false;
|
||||
creatingTicket = false;
|
||||
createBusy = false;
|
||||
createError = null;
|
||||
createTitle = "";
|
||||
createBody = "";
|
||||
createTargets = [];
|
||||
void loadOrchestrator(currentData, generation);
|
||||
});
|
||||
return () => { ++routeGeneration; };
|
||||
});
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>({ data: null, error: null });
|
||||
let orchestratorLoading = $state(true);
|
||||
let orchestratorStarting = $state(false);
|
||||
let creatingTicket = $state(false);
|
||||
let createBusy = $state(false);
|
||||
@@ -64,8 +80,11 @@
|
||||
let createTitle = $state("");
|
||||
let createBody = $state("");
|
||||
let createTargets = $state<EditableTicketTarget[]>([]);
|
||||
const repositories = $derived(data.repositories.data?.items ?? []);
|
||||
let repositoryResponse = $state<ApiResult<RepositoryListResponse>>({ data: null, error: null });
|
||||
let repositoriesLoading = $state(false);
|
||||
const repositories = $derived(repositoryResponse.data?.items ?? []);
|
||||
const createTargetsSavable = $derived.by(() => {
|
||||
if (repositoriesLoading || !repositoryResponse.data) return false;
|
||||
const keys = new Set<string>();
|
||||
for (const target of createTargets) {
|
||||
if (
|
||||
@@ -77,9 +96,42 @@
|
||||
}
|
||||
return true;
|
||||
});
|
||||
function isCurrent(observedData: PageData, generation: number): boolean {
|
||||
return data === observedData && routeGeneration === generation;
|
||||
}
|
||||
|
||||
async function loadOrchestrator(observedData: PageData, generation: number): Promise<void> {
|
||||
const result = await loadJson(
|
||||
fetch, workspaceApiPath(observedData.workspaceId, "/orchestrator"), undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
);
|
||||
if (!isCurrent(observedData, generation)) return;
|
||||
orchestrator = result;
|
||||
orchestratorLoading = false;
|
||||
}
|
||||
|
||||
async function loadRepositories(): Promise<void> {
|
||||
if (repositoriesLoading) return;
|
||||
const observedData = data;
|
||||
const generation = routeGeneration;
|
||||
repositoriesLoading = true;
|
||||
repositoryResponse = { data: null, error: null };
|
||||
const result = await loadWorkspaceRepositoryList(fetch, observedData.workspaceId);
|
||||
if (!isCurrent(observedData, generation)) return;
|
||||
repositoryResponse = result;
|
||||
repositoriesLoading = false;
|
||||
}
|
||||
|
||||
function toggleCreate(): void {
|
||||
creatingTicket = !creatingTicket;
|
||||
if (creatingTicket && !repositoryResponse.data) void loadRepositories();
|
||||
}
|
||||
|
||||
async function searchTickets(event?: SubmitEvent) {
|
||||
event?.preventDefault();
|
||||
if (searchBusy) return;
|
||||
const observedData = data;
|
||||
const generation = routeGeneration;
|
||||
searchBusy = true;
|
||||
navigationError = null;
|
||||
try {
|
||||
@@ -88,9 +140,11 @@
|
||||
reset: false, refreshAll: true,
|
||||
});
|
||||
} catch (error) {
|
||||
navigationError = error instanceof Error ? error.message : String(error);
|
||||
if (isCurrent(observedData, generation)) {
|
||||
navigationError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
searchBusy = false;
|
||||
if (isCurrent(observedData, generation)) searchBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,21 +160,22 @@
|
||||
async function loadMore(): Promise<void> {
|
||||
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
|
||||
const observedData = data;
|
||||
const generation = routeGeneration;
|
||||
const previous = response.data;
|
||||
pageBusy = true;
|
||||
response = { data: previous, error: null };
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
|
||||
workspaceApiPath(observedData.workspaceId, "/tickets/browse"),
|
||||
{
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
parseTicketBrowseQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
|
||||
if (data !== observedData) return;
|
||||
if (!isCurrent(observedData, generation)) return;
|
||||
response = result.data
|
||||
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
|
||||
: { data: previous, error: result.error };
|
||||
@@ -152,6 +207,8 @@
|
||||
async function createTicket(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
|
||||
const observedData = data;
|
||||
const generation = routeGeneration;
|
||||
createBusy = true;
|
||||
createError = null;
|
||||
const request: NewTicket = {
|
||||
@@ -166,31 +223,38 @@
|
||||
};
|
||||
try {
|
||||
const created = await workspaceApiJsonWithBody(
|
||||
workspaceApiPath(data.workspaceId, "/tickets"),
|
||||
workspaceApiPath(observedData.workspaceId, "/tickets"),
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
parseTicketRecordRef,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (!isCurrent(observedData, generation)) return;
|
||||
const reference = created.resource_key ?? created.id;
|
||||
await goto(
|
||||
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
createError = error instanceof Error ? error.message : String(error);
|
||||
if (isCurrent(observedData, generation)) {
|
||||
createError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
createBusy = false;
|
||||
if (isCurrent(observedData, generation)) createBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startOrchestrator() {
|
||||
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||
if (orchestratorLoading || orchestratorStarting || orchestrator.data?.online) return;
|
||||
const observedData = data;
|
||||
const generation = routeGeneration;
|
||||
orchestratorStarting = true;
|
||||
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
|
||||
const result = await loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(data.workspaceId, "/orchestrator"),
|
||||
workspaceApiPath(observedData.workspaceId, "/orchestrator"),
|
||||
{ method: "POST" },
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
);
|
||||
if (!isCurrent(observedData, generation)) return;
|
||||
orchestrator = result;
|
||||
orchestratorStarting = false;
|
||||
}
|
||||
|
||||
@@ -214,13 +278,13 @@
|
||||
<button
|
||||
class="workspace-primary-button"
|
||||
type="button"
|
||||
onclick={() => creatingTicket = !creatingTicket}
|
||||
onclick={toggleCreate}
|
||||
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
|
||||
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
||||
<div class="orchestrator-status" data-online={orchestrator.data?.online} aria-busy={orchestratorLoading}>
|
||||
<span class="orchestrator-status-dot"></span>
|
||||
<div>
|
||||
<strong>Orchestrator</strong>
|
||||
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
|
||||
<span>{orchestratorLoading ? "Checking…" : orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
|
||||
</div>
|
||||
{#if orchestrator.data && !orchestrator.data.online}
|
||||
<button
|
||||
@@ -240,8 +304,11 @@
|
||||
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
|
||||
<h2>New Ticket</h2>
|
||||
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
|
||||
{#if data.repositories.error}
|
||||
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
|
||||
{#if repositoriesLoading}
|
||||
<p role="status">Loading repositories…</p>
|
||||
{:else if repositoryResponse.error}
|
||||
<p class="workspace-callout is-error" role="alert">Repositories: {repositoryResponse.error}</p>
|
||||
<button class="workspace-secondary-button" type="button" onclick={loadRepositories}>Retry repositories</button>
|
||||
{/if}
|
||||
<label>Title<input bind:value={createTitle} required /></label>
|
||||
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { type ComponentProps, tick } from "svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
TicketBrowseQueryItem,
|
||||
TicketBrowseQueryResponse,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
|
||||
import { compileTicketSearch } from "#lib/workspace/tickets/ticket-search.ts";
|
||||
import TicketsPage from "./PageContent.svelte";
|
||||
import { load } from "./+page.ts";
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
type Data = ComponentProps<typeof TicketsPage>["data"];
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function ticket(id = "1", title = "First ticket"): TicketBrowseQueryItem {
|
||||
return {
|
||||
id,
|
||||
resource_key: `T-${id}`,
|
||||
title,
|
||||
state: "open",
|
||||
updated_at: null,
|
||||
tags: [],
|
||||
current_closure: null,
|
||||
assignment_tracking: [],
|
||||
};
|
||||
}
|
||||
function browse(
|
||||
items = [ticket()],
|
||||
cursor: string | null = null,
|
||||
): TicketBrowseQueryResponse {
|
||||
return {
|
||||
items,
|
||||
record_authority: "sqlite",
|
||||
page: {
|
||||
has_more: cursor !== null,
|
||||
next_cursor: cursor,
|
||||
limit: 50,
|
||||
returned: items.length,
|
||||
sort: "updated_desc",
|
||||
source_limit: null,
|
||||
source_truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
function data(
|
||||
workspaceId = "workspace-a",
|
||||
result = browse(),
|
||||
search = "state:open",
|
||||
): Data {
|
||||
return {
|
||||
workspaceId,
|
||||
search,
|
||||
queryRequest: compileTicketSearch(search, []),
|
||||
tickets: { data: result, error: null },
|
||||
} as Data;
|
||||
}
|
||||
function status(workspaceId = "workspace-a", online = true) {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
online,
|
||||
disposition: "observed",
|
||||
worker: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
function repositories(workspaceId = "workspace-a") {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
items: [],
|
||||
source: "sqlite",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
function requests(routes: Record<string, () => Promise<Response>>) {
|
||||
const request = vi.fn(
|
||||
(input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
|
||||
const route = routes[String(input)];
|
||||
if (!route) throw new Error(`Unexpected request: ${input}`);
|
||||
return route();
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", request);
|
||||
return request;
|
||||
}
|
||||
async function loadPage(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId = "workspace-a",
|
||||
search = "state:open",
|
||||
) {
|
||||
const result = await load(
|
||||
{
|
||||
fetch: fetchFn,
|
||||
params: { workspaceId },
|
||||
url: new URL(
|
||||
`https://example.test/w/${workspaceId}/tickets?q=${
|
||||
encodeURIComponent(search)
|
||||
}`,
|
||||
),
|
||||
} as Parameters<typeof load>[0],
|
||||
).deferredPage;
|
||||
if (!result.ok) throw new Error(result.failure.message);
|
||||
return result.data as Data;
|
||||
}
|
||||
// Let response body decoding and Svelte effects finish before negative stale-result assertions.
|
||||
async function flushResponses() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await tick();
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("actual loader posts browse only; rows render while orchestrator is unresolved and repositories stay lazy", async () => {
|
||||
const rows = deferred<Response>();
|
||||
const orchestrator = deferred<Response>();
|
||||
const repos = deferred<Response>();
|
||||
const request = requests({
|
||||
"/api/w/workspace-a/tickets/browse": () => rows.promise,
|
||||
"/api/w/workspace-a/orchestrator": () => orchestrator.promise,
|
||||
"/api/w/workspace-a/repositories": () => repos.promise,
|
||||
});
|
||||
const pending = loadPage(request);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
|
||||
expect(request.mock.calls[0][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
|
||||
compileTicketSearch("state:open", []),
|
||||
);
|
||||
rows.resolve(Response.json(browse()));
|
||||
const view = render(TicketsPage, { data: await pending });
|
||||
expect(await screen.findByText("First ticket")).toBeTruthy();
|
||||
expect(screen.getByText("Checking…")).toBeTruthy();
|
||||
expect(
|
||||
view.container.querySelector(".orchestrator-status")?.getAttribute(
|
||||
"aria-busy",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(screen.queryByText("Offline")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
|
||||
.toBeNull();
|
||||
expect(request.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/api/w/workspace-a/tickets/browse",
|
||||
"/api/w/workspace-a/orchestrator",
|
||||
]);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
|
||||
expect(
|
||||
request.mock.calls.filter(([path]) =>
|
||||
String(path).endsWith("/repositories")
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(screen.getByText("Loading repositories…")).toBeTruthy();
|
||||
expect(screen.getByText("First ticket")).toBeTruthy();
|
||||
repos.resolve(Response.json(repositories()));
|
||||
orchestrator.resolve(Response.json(status()));
|
||||
expect(await screen.findByText("Online")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("lazy repository HTTP failure is visible and retry enables targetless creation without reloading tickets", async () => {
|
||||
const first = deferred<Response>();
|
||||
const retry = deferred<Response>();
|
||||
const repoRequest = vi.fn().mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(retry.promise);
|
||||
const request = requests({
|
||||
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
|
||||
"/api/w/workspace-a/repositories": repoRequest,
|
||||
});
|
||||
render(TicketsPage, { data: data() });
|
||||
expect(repoRequest).not.toHaveBeenCalled();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
|
||||
await fireEvent.input(screen.getByLabelText("Title"), {
|
||||
target: { value: "New title" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
first.resolve(new Response("private failure body", { status: 503 }));
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Repositories: Repository API request failed with HTTP 503",
|
||||
);
|
||||
expect(screen.queryByText(/private failure body/)).toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Retry repositories" }),
|
||||
);
|
||||
expect(repoRequest).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText("Loading repositories…")).toBeTruthy();
|
||||
retry.resolve(Response.json(repositories()));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Create Ticket",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByText("First ticket")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
|
||||
expect(repoRequest).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls.some(([path]) => String(path).includes("/tickets")))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("failed status is Unavailable, not Offline, and does not hide rows", async () => {
|
||||
const orchestrator = deferred<Response>();
|
||||
requests({ "/api/w/workspace-a/orchestrator": () => orchestrator.promise });
|
||||
render(TicketsPage, { data: data() });
|
||||
expect(screen.getByText("Checking…")).toBeTruthy();
|
||||
orchestrator.reject(new Error("Controlled status failure"));
|
||||
expect(await screen.findByText("Unavailable")).toBeTruthy();
|
||||
expect(screen.getByText("Orchestrator status: Controlled status failure"))
|
||||
.toBeTruthy();
|
||||
expect(screen.queryByText("Offline")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
|
||||
.toBeNull();
|
||||
expect(screen.getByText("First ticket")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("late prior-Workspace status and repository responses cannot replace current state or finish its pending load", async () => {
|
||||
const oldStatus = deferred<Response>();
|
||||
const oldRepos = deferred<Response>();
|
||||
const newStatus = deferred<Response>();
|
||||
const newRepos = deferred<Response>();
|
||||
requests({
|
||||
"/api/w/workspace-a/orchestrator": () => oldStatus.promise,
|
||||
"/api/w/workspace-a/repositories": () => oldRepos.promise,
|
||||
"/api/w/workspace-b/orchestrator": () => newStatus.promise,
|
||||
"/api/w/workspace-b/repositories": () => newRepos.promise,
|
||||
});
|
||||
const view = render(TicketsPage, { data: data() });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
|
||||
await view.rerender({
|
||||
data: data(
|
||||
"workspace-b",
|
||||
browse([ticket("2", "Current Workspace ticket")]),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("New Ticket")).toBeNull();
|
||||
expect(screen.queryByText("First ticket")).toBeNull();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
|
||||
await fireEvent.input(screen.getByLabelText("Title"), {
|
||||
target: { value: "Current draft" },
|
||||
});
|
||||
oldStatus.resolve(Response.json(status("workspace-a", false)));
|
||||
oldRepos.resolve(Response.json(repositories("workspace-a")));
|
||||
await flushResponses();
|
||||
expect(screen.getByText("Checking…")).toBeTruthy();
|
||||
expect(screen.queryByText("Offline")).toBeNull();
|
||||
expect(screen.getByText("Loading repositories…")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByText("Current Workspace ticket")).toBeTruthy();
|
||||
newStatus.resolve(Response.json(status("workspace-b")));
|
||||
newRepos.resolve(Response.json(repositories("workspace-b")));
|
||||
expect(await screen.findByText("Online")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Create Ticket",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect((screen.getByLabelText("Title") as HTMLInputElement).value).toBe(
|
||||
"Current draft",
|
||||
);
|
||||
});
|
||||
|
||||
test("pagination posts browse with the query and cursor, preserves server order, and merges duplicate ids", async () => {
|
||||
const next = deferred<Response>();
|
||||
const request = requests({
|
||||
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
|
||||
"/api/w/workspace-a/tickets/browse": () => next.promise,
|
||||
});
|
||||
const initial = data("workspace-a", browse([ticket()], "cursor-1"));
|
||||
const view = render(TicketsPage, { data: initial });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Load more tickets" }),
|
||||
);
|
||||
const call = request.mock.calls.find(([path]) =>
|
||||
String(path).endsWith("/tickets/browse")
|
||||
)!;
|
||||
expect(call[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(call[1]?.body))).toEqual({
|
||||
...initial.queryRequest,
|
||||
cursor: "cursor-1",
|
||||
});
|
||||
expect(screen.getByText("First ticket")).toBeTruthy();
|
||||
next.resolve(
|
||||
Response.json(
|
||||
browse([
|
||||
ticket("1", "Updated first ticket"),
|
||||
ticket("2", "Second ticket"),
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("2 tickets loaded")).toBeTruthy();
|
||||
expect(
|
||||
[...view.container.querySelectorAll(".ticket-list-title")].map((node) =>
|
||||
node.textContent
|
||||
),
|
||||
).toEqual(["Updated first ticket", "Second ticket"]);
|
||||
expect(screen.queryByRole("button", { name: "Load more tickets" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
for (const staleFailure of [false, true]) {
|
||||
test(`prior-query pagination ${staleFailure ? "failure" : "success"} cannot overwrite current rows, cursor, or busy state`, async () => {
|
||||
const oldPage = deferred<Response>();
|
||||
const newPage = deferred<Response>();
|
||||
const pageRequest = vi.fn().mockReturnValueOnce(oldPage.promise)
|
||||
.mockReturnValueOnce(newPage.promise);
|
||||
const request = requests({
|
||||
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
|
||||
"/api/w/workspace-a/tickets/browse": pageRequest,
|
||||
});
|
||||
const view = render(TicketsPage, {
|
||||
data: data("workspace-a", browse([ticket()], "old-cursor")),
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Load more tickets" }),
|
||||
);
|
||||
const current = data(
|
||||
"workspace-a",
|
||||
browse([ticket("2", "Current query ticket")], "new-cursor"),
|
||||
"state:closed",
|
||||
);
|
||||
await view.rerender({ data: current });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Load more tickets" }),
|
||||
);
|
||||
oldPage.resolve(
|
||||
staleFailure
|
||||
? new Response("stale error", { status: 500 })
|
||||
: Response.json(
|
||||
browse([ticket("3", "Stale page ticket")], "stale-cursor"),
|
||||
),
|
||||
);
|
||||
await flushResponses();
|
||||
expect(screen.queryByText("Stale page ticket")).toBeNull();
|
||||
expect(screen.queryByText("First ticket")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByText("Current query ticket")).toBeTruthy();
|
||||
expect(screen.getByText("Loading…")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Tickets" }).getAttribute("aria-busy"),
|
||||
).toBe("true");
|
||||
const calls = request.mock.calls.filter(([path]) =>
|
||||
String(path).endsWith("/tickets/browse")
|
||||
);
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
|
||||
...current.queryRequest,
|
||||
cursor: "new-cursor",
|
||||
});
|
||||
newPage.resolve(Response.json(browse([ticket("4", "Current next page")])));
|
||||
expect(await screen.findByText("Current next page")).toBeTruthy();
|
||||
expect(screen.getByText("2 tickets loaded")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading…")).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test("controlled loader-to-component timing isolates the former blocking Promise.all dependency gate", async () => {
|
||||
// A local dependency-gating comparison, NOT a production latency benchmark.
|
||||
// Both arms use the actual current browse loader/parser and real Svelte component.
|
||||
// The baseline restores only the old Promise.all wait for status + repositories;
|
||||
// it deliberately does not emulate the old enriched query's backend work.
|
||||
const delays = { tickets: 20, secondary: 100 };
|
||||
const measurements: Record<string, number> = {};
|
||||
for (const blocking of [true, false]) {
|
||||
const outstanding: Promise<Response>[] = [];
|
||||
const delayed = (payload: unknown, ms: number) => {
|
||||
const promise = new Promise<Response>((resolve) =>
|
||||
setTimeout(() => resolve(Response.json(payload)), ms)
|
||||
);
|
||||
outstanding.push(promise);
|
||||
return promise;
|
||||
};
|
||||
const request = requests({
|
||||
"/api/w/workspace-a/tickets/browse": () =>
|
||||
delayed(browse(), delays.tickets),
|
||||
"/api/w/workspace-a/orchestrator": () =>
|
||||
delayed(status(), delays.secondary),
|
||||
"/api/w/workspace-a/repositories": () =>
|
||||
delayed(repositories(), delays.secondary),
|
||||
});
|
||||
const start = performance.now();
|
||||
const page = blocking
|
||||
? (await Promise.all([
|
||||
loadPage(request),
|
||||
loadJson(
|
||||
request,
|
||||
workspaceApiPath("workspace-a", "/orchestrator"),
|
||||
undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
),
|
||||
loadWorkspaceRepositoryList(request, "workspace-a"),
|
||||
]))[0]
|
||||
: await loadPage(request);
|
||||
render(TicketsPage, { data: page });
|
||||
expect(await screen.findByText("First ticket")).toBeTruthy();
|
||||
measurements[blocking ? "blockingBaselineMs" : "actualLoaderMs"] = Number(
|
||||
(performance.now() - start).toFixed(2),
|
||||
);
|
||||
if (!blocking) {
|
||||
expect(
|
||||
request.mock.calls.some(([path]) =>
|
||||
String(path).endsWith("/repositories")
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
cleanup();
|
||||
await Promise.all(outstanding);
|
||||
await flushResponses();
|
||||
}
|
||||
// No speed threshold: scheduler noise must not turn this into a flaky performance gate.
|
||||
console.info(
|
||||
"Controlled mocked loader-to-component timing (not production):",
|
||||
JSON.stringify({ delaysMs: delays, ...measurements }),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user