359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { dwell } from "../../helpers/causal-waits";
|
|
|
|
// Task dependencies: a peer "B cannot start until A finishes" relationship,
|
|
// separate from the parent/child subtask hierarchy. These specs cover the
|
|
// guarantees that only hold end to end, where the create path, the event bus,
|
|
// the auto-start chokepoint, and the derived projection all participate:
|
|
//
|
|
// - a blocked task is not launched by any automated path,
|
|
// - only SUCCESS resolves a dependency, so a failed predecessor halts a chain,
|
|
// - resolution launches the dependent exactly once and never restarts the
|
|
// predecessor,
|
|
// - a cycle is refused with the offending path,
|
|
// - the projection reaches the browser, which is where it kept getting lost.
|
|
//
|
|
// Predecessors are seeded WITHOUT an agent and completed over the API. That is
|
|
// deliberate: driving the terminal transition directly exercises the same
|
|
// task.state_changed path a real agent turn ends on, without making the assertion
|
|
// depend on mock-agent timing. The DEPENDENTS carry agent profiles, because
|
|
// whether they launch is the thing under test.
|
|
|
|
/** Sessions a task owns. A blocked task must have none. */
|
|
async function sessionCount(apiClient: ApiClient, taskId: string): Promise<number> {
|
|
const { total } = await apiClient.listTaskSessions(taskId);
|
|
return total;
|
|
}
|
|
|
|
/** Poll until a task owns at least one session, failing with a readable message. */
|
|
async function expectStarted(apiClient: ApiClient, taskId: string, label: string): Promise<void> {
|
|
await expect
|
|
.poll(() => sessionCount(apiClient, taskId), {
|
|
timeout: 30_000,
|
|
message: `${label} should have been launched by dependency resolution`,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
}
|
|
|
|
/**
|
|
* Assert a task stays sessionless. Dependency resolution is asynchronous, so a
|
|
* single read would pass before the launch it is meant to catch. Poll for a
|
|
* fixed window and require the count to stay at zero throughout.
|
|
*/
|
|
async function expectStaysUnstarted(
|
|
apiClient: ApiClient,
|
|
taskId: string,
|
|
label: string,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + 6_000;
|
|
while (Date.now() < deadline) {
|
|
expect(await sessionCount(apiClient, taskId), `${label} must not be started`).toBe(0);
|
|
await dwell(
|
|
500,
|
|
"poll-interval",
|
|
"sampling interval for the window above, not a settle: the assertion is that nothing starts during it, so the loop keeps sampling rather than awaiting one event",
|
|
);
|
|
}
|
|
}
|
|
|
|
test.describe("Task dependencies", () => {
|
|
test("a chain runs one step at a time and never restarts a predecessor", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const a = await apiClient.createTask(seedData.workspaceId, "Chain A", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
const withAgent = {
|
|
description: 'e2e:message("step done")',
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
};
|
|
const b = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Chain B",
|
|
seedData.agentProfileId,
|
|
{ ...withAgent, blocked_by: [a.id] },
|
|
);
|
|
const c = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Chain C",
|
|
seedData.agentProfileId,
|
|
{ ...withAgent, blocked_by: [b.id] },
|
|
);
|
|
|
|
// A create that declares dependencies records its start rather than firing
|
|
// it, even though start_agent defaulted to true.
|
|
expect(b.session_id, "B must not be launched on create").toBeFalsy();
|
|
expect(c.session_id, "C must not be launched on create").toBeFalsy();
|
|
expect(await sessionCount(apiClient, b.id)).toBe(0);
|
|
expect(await sessionCount(apiClient, c.id)).toBe(0);
|
|
|
|
const blockedB = await apiClient.getTaskDependencies(b.id);
|
|
expect(blockedB.blocked).toBe(true);
|
|
expect(blockedB.blocked_reason).toBe("pending");
|
|
expect(blockedB.depends_on?.map((d) => d.id)).toEqual([a.id]);
|
|
// The reverse direction is derived from the same edge, not stored twice.
|
|
expect((await apiClient.getTaskDependencies(a.id)).blocks?.map((d) => d.id)).toEqual([b.id]);
|
|
|
|
await apiClient.updateTaskState(a.id, "COMPLETED");
|
|
|
|
await expectStarted(apiClient, b.id, "B");
|
|
// C is two hops out. Resolution must not walk the graph transitively.
|
|
await expectStaysUnstarted(apiClient, c.id, "C while B is still running");
|
|
|
|
const bSessions = await sessionCount(apiClient, b.id);
|
|
await apiClient.updateTaskState(b.id, "COMPLETED");
|
|
|
|
await expectStarted(apiClient, c.id, "C");
|
|
// The predecessors are untouched by their dependents starting. A restarted
|
|
// A is the failure this chain exists to prevent.
|
|
expect(await sessionCount(apiClient, a.id), "A must not be restarted").toBe(0);
|
|
expect(await sessionCount(apiClient, b.id), "B must launch exactly once").toBe(bSessions);
|
|
});
|
|
|
|
test("a failed predecessor halts the chain until it succeeds", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const predecessor = await apiClient.createTask(seedData.workspaceId, "Halt A", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
const dependent = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Halt B",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: 'e2e:message("should only run after a success")',
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
blocked_by: [predecessor.id],
|
|
},
|
|
);
|
|
|
|
await apiClient.updateTaskState(predecessor.id, "FAILED");
|
|
|
|
// FAILED is terminal for the parent/child completion signal but NOT for a
|
|
// dependency: the chain stops and waits for a human.
|
|
await expectStaysUnstarted(apiClient, dependent.id, "the dependent of a failed predecessor");
|
|
const blocked = await apiClient.getTaskDependencies(dependent.id);
|
|
expect(blocked.blocked).toBe(true);
|
|
expect(blocked.blocked_reason).toBe("failed");
|
|
expect(blocked.depends_on?.[0]?.status).toBe("failed");
|
|
|
|
// Retrying the predecessor to success is one of the three ways out, and the
|
|
// intent must survive the failed attempt rather than having been consumed.
|
|
await apiClient.updateTaskState(predecessor.id, "COMPLETED");
|
|
await expectStarted(apiClient, dependent.id, "the dependent after a retry");
|
|
});
|
|
|
|
test("removing the last edge unblocks without launching", async ({ apiClient, seedData }) => {
|
|
const predecessor = await apiClient.createTask(seedData.workspaceId, "Edge A", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
const dependent = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Edge B",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: 'e2e:message("unreachable")',
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
blocked_by: [predecessor.id],
|
|
},
|
|
);
|
|
|
|
const after = await apiClient.removeTaskDependency(dependent.id, predecessor.id);
|
|
expect(after.blocked).toBe(false);
|
|
expect(after.depends_on ?? []).toEqual([]);
|
|
|
|
// Unblocking by edge removal is not success, so the recorded intent must
|
|
// not fire. Only a resolved predecessor starts a dependent.
|
|
await expectStaysUnstarted(apiClient, dependent.id, "a dependent unblocked by edge removal");
|
|
|
|
// Removing an edge that is not there is a success no-op, not a 404.
|
|
await expect(
|
|
apiClient.removeTaskDependency(dependent.id, predecessor.id),
|
|
).resolves.toBeTruthy();
|
|
});
|
|
|
|
test("a cycle is refused with the offending path", async ({ apiClient, seedData }) => {
|
|
const base = {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
};
|
|
const a = await apiClient.createTask(seedData.workspaceId, "Cycle A", base);
|
|
const b = await apiClient.createTask(seedData.workspaceId, "Cycle B", base);
|
|
const c = await apiClient.createTask(seedData.workspaceId, "Cycle C", base);
|
|
|
|
await apiClient.addTaskDependency(b.id, a.id);
|
|
await apiClient.addTaskDependency(c.id, b.id);
|
|
|
|
// A depending on C would close A -> C -> B -> A.
|
|
const response = await apiClient.rawAddTaskDependency(a.id, c.id);
|
|
expect(response.status).toBe(409);
|
|
const body = (await response.json()) as { cycle?: string[] };
|
|
expect(body.cycle, "the 409 must carry the path so the UI can render it").toEqual([
|
|
a.id,
|
|
c.id,
|
|
b.id,
|
|
a.id,
|
|
]);
|
|
|
|
// The rejected edge must not have been written.
|
|
expect((await apiClient.getTaskDependencies(a.id)).depends_on ?? []).toEqual([]);
|
|
});
|
|
|
|
test("the board and the open task both show the dependency", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const predecessor = await apiClient.createTask(seedData.workspaceId, "Visible A", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
const dependent = await apiClient.createTask(seedData.workspaceId, "Visible B", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
blocked_by: [predecessor.id],
|
|
});
|
|
|
|
await testPage.goto("/");
|
|
await expect(
|
|
testPage.getByTestId(`task-card-${dependent.id}`).getByTestId("kanban-card-blocked-badge"),
|
|
).toBeVisible();
|
|
await expect(
|
|
testPage.getByTestId(`task-card-${predecessor.id}`).getByTestId("kanban-card-blocked-badge"),
|
|
).toHaveCount(0);
|
|
|
|
// The chip has to survive the boot payload, the WS event stream, and the
|
|
// store projection. Every regression in this feature so far has been a
|
|
// dropped hop between those, so assert it from the rendered page.
|
|
await testPage.goto(`/t/${dependent.id}`);
|
|
const chip = testPage.getByTestId("task-dependency-chip");
|
|
await expect(chip).toBeVisible();
|
|
await chip.click();
|
|
await expect(
|
|
testPage.getByTestId("task-dependency-entry").filter({ hasText: "Visible A" }),
|
|
).toBeVisible();
|
|
|
|
// A task with no edges in either direction renders no chip at all.
|
|
const unrelated = await apiClient.createTask(seedData.workspaceId, "Visible C", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
await testPage.goto(`/t/${unrelated.id}`);
|
|
await expect(testPage.getByTestId("task-dependency-chip")).toHaveCount(0);
|
|
});
|
|
|
|
test("dependency links preserve the desktop task workbench", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const predecessor = await apiClient.createTask(seedData.workspaceId, "Navigation predecessor", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
const target = await apiClient.createTask(seedData.workspaceId, "Navigation target", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
blocked_by: [predecessor.id],
|
|
});
|
|
const dependent = await apiClient.createTask(seedData.workspaceId, "Navigation dependent", {
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
blocked_by: [target.id],
|
|
});
|
|
|
|
await testPage.goto(`/t/${target.id}`);
|
|
const title = testPage.locator('[data-testid="task-topbar-title"]:visible');
|
|
await expect(title).toHaveText(target.title);
|
|
|
|
const documentRequests: string[] = [];
|
|
const onRequest = (request: import("@playwright/test").Request) => {
|
|
if (
|
|
request.isNavigationRequest() &&
|
|
request.frame() === testPage.mainFrame() &&
|
|
request.resourceType() === "document"
|
|
) {
|
|
documentRequests.push(request.url());
|
|
}
|
|
};
|
|
testPage.on("request", onRequest);
|
|
|
|
try {
|
|
await testPage.evaluate(() => {
|
|
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel =
|
|
"dependency-links-stay-in-spa";
|
|
});
|
|
|
|
const chip = testPage.getByTestId("task-dependency-chip");
|
|
await chip.click();
|
|
const blockedBy = testPage
|
|
.getByTestId("task-dependency-entry")
|
|
.filter({ hasText: predecessor.title });
|
|
await expect(blockedBy).toHaveAttribute("href", `/t/${predecessor.id}`);
|
|
await blockedBy.click();
|
|
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${predecessor.id}$`));
|
|
await expect(title).toHaveText(predecessor.title);
|
|
await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0);
|
|
expect(documentRequests).toHaveLength(0);
|
|
expect(
|
|
await testPage.evaluate(
|
|
() =>
|
|
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
|
),
|
|
).toBe("dependency-links-stay-in-spa");
|
|
|
|
await testPage.goBack();
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`));
|
|
await expect(title).toHaveText(target.title);
|
|
expect(documentRequests).toHaveLength(0);
|
|
|
|
await chip.click();
|
|
const blocks = testPage
|
|
.getByTestId("task-dependency-entry")
|
|
.filter({ hasText: dependent.title });
|
|
await expect(blocks).toHaveAttribute("href", `/t/${dependent.id}`);
|
|
await blocks.click();
|
|
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${dependent.id}$`));
|
|
await expect(title).toHaveText(dependent.title);
|
|
await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0);
|
|
expect(documentRequests).toHaveLength(0);
|
|
expect(
|
|
await testPage.evaluate(
|
|
() =>
|
|
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
|
),
|
|
).toBe("dependency-links-stay-in-spa");
|
|
} finally {
|
|
testPage.off("request", onRequest);
|
|
}
|
|
});
|
|
});
|