Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0c3aa7f
test(cli): migrate transport fixtures to API v2
Optic00 Sep 6, 2026
404c327
test(cli): cover task pagination and workspace-aware resolution
Optic00 Sep 6, 2026
c4644a8
test: align service contracts after API v2 extraction
Optic00 Sep 6, 2026
d25873c
test(v2): add explicit HTTP contract fixtures and route inventory
Optic00 Sep 6, 2026
08d093b
test(services): refresh item notification and push fixtures
Optic00 Sep 6, 2026
25e91bb
test: verify CLI parent clearing and actual v2 mount exposure
Optic00 Sep 6, 2026
da0fb3c
test(cli): reject legacy multipart entity type
Optic00 Sep 6, 2026
6431557
test: migrate page label contracts to v2 on both database engines
Optic00 Sep 7, 2026
410b13f
test: migrate browser setup and request assertions to v2
Optic00 Sep 7, 2026
28fefa2
test: cover deletion feedback and stale detail responses
Optic00 Sep 7, 2026
81e4760
test: cover incomplete final task pages
Optic00 Sep 7, 2026
1c6d40f
test: migrate comment contracts to API v2
Optic00 Sep 7, 2026
14abd15
test: migrate test management and discovery to API v2
Optic00 Sep 7, 2026
ad4f783
test: migrate CLI and MCP execution fixtures to API v2
Optic00 Sep 7, 2026
62ce156
test: restore shared admin identity and workspace lockdown fixtures
Optic00 Sep 7, 2026
03de444
test: migrate shared CLI and MCP fixture world to API v2
Optic00 Sep 7, 2026
f6aec50
test: cover v2 assets while retaining v1 contracts
Optic00 Sep 7, 2026
c866f35
test: migrate asset-tag browser flow to API v2
Optic00 Sep 7, 2026
910cf97
test: migrate diagram handler contracts to API v2
Optic00 Sep 7, 2026
93dcac8
test: migrate label and link contracts to API v2
Optic00 Sep 7, 2026
4f1a171
test: migrate permission matrix and priorities to API v2
Optic00 Sep 7, 2026
3c586f3
test: migrate iteration lifecycle and permissions to API v2
Optic00 Sep 7, 2026
879271f
test: migrate page and planning frontend API contracts
Optic00 Sep 7, 2026
e771f66
test: align remaining frontend API fixtures with v2
Optic00 Sep 8, 2026
0fa4172
test: align startup fixtures and cover shell load lifecycle
Optic00 Sep 8, 2026
57d3c35
test: correct mobile create custom-field fixtures
Optic00 Sep 8, 2026
0b1ea9f
test: migrate dashboard and run detail v2 fixtures
Optic00 Sep 8, 2026
0062615
Align remaining frontend fixtures with current Core contracts
Optic00 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions e2e/fixtures/api-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,28 @@ export async function createWorkspaceViaAPI(
time_project_id?: number;
}
) {
const response = await request.post(`${BASE_URL}/api/workspaces`, {
const response = await request.post(`${BASE_URL}/api/v2/workspaces`, {
headers: defaultHeaders,
data,
});
expect(
response.ok(),
`create workspace failed (${response.status()}): ${await response.text()}`
).toBeTruthy();
return response.json();
return (await response.json()).data;
}

/**
* List item types via the API (global catalog). Returns the raw array.
*/
export async function listItemTypesViaAPI(request: APIRequestContext) {
const response = await request.get(`${BASE_URL}/api/item-types`, {
const response = await request.get(`${BASE_URL}/api/v2/item-types`, {
headers: defaultHeaders,
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
return Array.isArray(body) ? body : (body.data ?? body.items ?? []);
expect(Array.isArray(body.data)).toBeTruthy();
return body.data;
}

/**
Expand Down Expand Up @@ -94,14 +95,30 @@ export async function createItemViaAPI(
start_date?: string;
end_date?: string;
custom_field_values?: Record<string, unknown>;
item_type_id?: number;
}
) {
const response = await request.post(`${BASE_URL}/api/items`, {
const { status, priority, custom_field_values, ...fields } = data;
const payload: Record<string, unknown> = { ...fields, workspace_id: workspaceId };
if (custom_field_values !== undefined) payload.custom_fields = custom_field_values;
for (const [name, value, path] of [
['status_id', status, `/workspaces/${workspaceId}/statuses`],
['priority_id', priority, '/priorities'],
] as const) {
if (value === undefined) continue;
const catalog = await request.get(`${BASE_URL}/api/v2${path}`, { headers: defaultHeaders });
expect(catalog.ok(), await catalog.text()).toBeTruthy();
const entries = (await catalog.json()).data as Array<{ id: number; name: string }>;
const matches = entries.filter((entry) => entry.name.toLowerCase() === value.toLowerCase());
expect(matches, `expected one exact ${name} match for ${value}`).toHaveLength(1);
payload[name] = matches[0].id;
}
const response = await request.post(`${BASE_URL}/api/v2/items`, {
headers: defaultHeaders,
data: { ...data, workspace_id: workspaceId },
data: payload,
});
expect(response.ok()).toBeTruthy();
return response.json();
expect(response.status(), await response.text()).toBe(201);
return (await response.json()).data;
}

/**
Expand Down Expand Up @@ -320,12 +337,13 @@ export async function createTimeProjectViaAPI(
* Duplicates (6), Child Of (7).
*/
export async function listLinkTypesViaAPI(request: APIRequestContext) {
const response = await request.get(`${BASE_URL}/api/link-types`, {
const response = await request.get(`${BASE_URL}/api/v2/link-types`, {
headers: defaultHeaders,
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
return (body.data ?? body) as Array<{
expect(Array.isArray(body.data)).toBeTruthy();
return body.data as Array<{
id: number;
name: string;
active?: boolean;
Expand All @@ -347,12 +365,12 @@ export async function createLinkViaAPI(
target_id: number;
}
) {
const response = await request.post(`${BASE_URL}/api/links`, {
const response = await request.post(`${BASE_URL}/api/v2/links`, {
headers: defaultHeaders,
data,
});
expect(response.ok()).toBeTruthy();
return response.json();
return (await response.json()).data;
}

/**
Expand Down
94 changes: 67 additions & 27 deletions e2e/tests/asset-tag-editing-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { expect, test } from '../fixtures/context-path';
import { authenticateAdminRequest } from '../fixtures/api-helpers';
import { expect, logicalPath, test } from '../fixtures/context-path';

const SEC_FETCH = { 'Sec-Fetch-Site': 'same-origin' };

test('asset UI creates, preserves, changes, and clears asset tags', async ({ request, page }) => {
await authenticateAdminRequest(request);
const stamp = Date.now();
const originalTag = `TAG-${stamp}`;
const changedTag = `TAG-${stamp}-changed`;

const setResponse = await request.post('/api/asset-sets', {
const setResponse = await request.post('/api/v2/asset-sets', {
headers: SEC_FETCH,
data: { name: `Tagged assets ${stamp}` },
});
expect(setResponse.ok(), `create asset set: ${await setResponse.text()}`).toBeTruthy();
const assetSet = await setResponse.json();
expect(setResponse.status(), `create asset set: ${await setResponse.text()}`).toBe(201);
const assetSet = (await setResponse.json()).data;
expect(assetSet.id).toBeGreaterThan(0);

const typeResponse = await request.post(`/api/asset-sets/${assetSet.id}/types`, {
const typeResponse = await request.post(`/api/v2/asset-sets/${assetSet.id}/types`, {
headers: SEC_FETCH,
data: { name: `Tagged server ${stamp}` },
});
expect(typeResponse.ok(), `create asset type: ${await typeResponse.text()}`).toBeTruthy();
expect(typeResponse.status(), `create asset type: ${await typeResponse.text()}`).toBe(201);
expect((await typeResponse.json()).data.id).toBeGreaterThan(0);

await page.goto('/assets');
await page.locator('#asset-set-select').click();
Expand All @@ -30,42 +34,46 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req
await page.locator('#asset-tag-input').blur();
const createPromise = page.waitForResponse(
(response) =>
response.url().endsWith(`/api/asset-sets/${assetSet.id}/assets`) &&
response.url().endsWith(`/api/v2/asset-sets/${assetSet.id}/assets`) &&
response.request().method() === 'POST'
);
await page.getByTestId('asset-submit').click();
const createResponse = await createPromise;
expect(createResponse.ok()).toBeTruthy();
expect(createResponse.status()).toBe(201);
expect(createResponse.request().postDataJSON().asset_tag).toBe(originalTag);
const asset = await createResponse.json();
const asset = (await createResponse.json()).data;
expect(asset.id).toBeGreaterThan(0);
expect(asset.asset_tag).toBe(originalTag);

await page.goto(`/assets/${asset.id}`);
await page.getByTestId('asset-edit').click();
await expect(page.locator('#asset-tag-input')).toHaveValue(originalTag);
await page.locator('#asset-title-input').fill(`Tagged asset renamed ${stamp}`);
const preservePromise = page.waitForResponse(
(response) =>
response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT'
response.url().endsWith(`/api/v2/assets/${asset.id}`) &&
response.request().method() === 'PATCH'
);
await page.getByTestId('asset-submit').click();
const preserveResponse = await preservePromise;
expect(preserveResponse.ok()).toBeTruthy();
expect(preserveResponse.status()).toBe(200);
expect(preserveResponse.request().postDataJSON().asset_tag).toBe(originalTag);
expect((await preserveResponse.json()).asset_tag).toBe(originalTag);
expect((await preserveResponse.json()).data.asset_tag).toBe(originalTag);
await expect(page.getByTestId('asset-submit')).toHaveCount(0);

await page.getByTestId('asset-edit').click();
await expect(page.locator('#asset-tag-input')).toHaveValue(originalTag);
await page.locator('#asset-tag-input').fill(changedTag);
const changePromise = page.waitForResponse(
(response) =>
response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT'
response.url().endsWith(`/api/v2/assets/${asset.id}`) &&
response.request().method() === 'PATCH'
);
await page.getByTestId('asset-submit').click();
const changeResponse = await changePromise;
expect(changeResponse.ok()).toBeTruthy();
expect(changeResponse.status()).toBe(200);
expect(changeResponse.request().postDataJSON().asset_tag).toBe(changedTag);
expect((await changeResponse.json()).asset_tag).toBe(changedTag);
expect((await changeResponse.json()).data.asset_tag).toBe(changedTag);
await expect(page.getByTestId('asset-submit')).toHaveCount(0);

await page.getByTestId('asset-edit').click();
Expand All @@ -74,19 +82,25 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req
await expect(page.locator('#asset-tag-input')).toHaveValue('');
const clearPromise = page.waitForResponse(
(response) =>
response.url().endsWith(`/api/assets/${asset.id}`) && response.request().method() === 'PUT'
response.url().endsWith(`/api/v2/assets/${asset.id}`) &&
response.request().method() === 'PATCH'
);
await page.getByTestId('asset-submit').click();
const clearResponse = await clearPromise;
expect(clearResponse.ok()).toBeTruthy();
expect(clearResponse.status()).toBe(200);
expect(clearResponse.request().postDataJSON().asset_tag).toBe('');
expect((await clearResponse.json()).asset_tag ?? '').toBe('');
expect((await clearResponse.json()).data.asset_tag ?? '').toBe('');

const storedResponse = await request.get(`/api/assets/${asset.id}`, {
await expect(page.getByTestId('asset-submit')).toHaveCount(0);
await page.reload();
await page.getByTestId('asset-edit').click();
await expect(page.locator('#asset-tag-input')).toHaveValue('');

const storedResponse = await request.get(`/api/v2/assets/${asset.id}`, {
headers: SEC_FETCH,
});
expect(storedResponse.ok()).toBeTruthy();
expect((await storedResponse.json()).asset_tag ?? '').toBe('');
expect(storedResponse.status()).toBe(200);
expect((await storedResponse.json()).data.asset_tag ?? '').toBe('');

// Search is part of the browser contract, not merely an API capability.
// Re-enter the set from a fresh page and prove both the matching and empty
Expand All @@ -95,6 +109,8 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req
await page.locator('#asset-set-select').click();
await page.locator(`#asset-set-select-option-${assetSet.id}`).click();

await expect(page.getByTestId('asset-row')).toHaveCount(1);

let releaseMatchingResponse!: () => void;
let matchingRequestStarted!: () => void;
let matchingResponseReleased!: () => void;
Expand All @@ -109,22 +125,46 @@ test('asset UI creates, preserves, changes, and clears asset tags', async ({ req
});
await page.route(
(url) =>
url.pathname === `/api/asset-sets/${assetSet.id}/assets` &&
logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` &&
(url.searchParams.get('ql') ?? '').includes(`renamed ${stamp}`),
async (route) => {
const response = await route.fetch();
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.data.map((entry: { id: number }) => entry.id)).toEqual([asset.id]);
matchingRequestStarted();
await matchingRelease;
await route.fulfill({ response });
matchingResponseReleased();
}
);

await page.getByTestId('asset-search').fill(`renamed ${stamp}`);
await matchingRequest;
await page.getByTestId('asset-search').fill(`no-match-${stamp}`);
await expect(page.getByTestId('asset-row')).toHaveCount(0);
releaseMatchingResponse();
const staleResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` &&
(url.searchParams.get('ql') ?? '').includes(`renamed ${stamp}`)
);
});
try {
await page.getByTestId('asset-search').fill(`renamed ${stamp}`);
await matchingRequest;
const emptyResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
logicalPath(url.pathname) === `/api/v2/asset-sets/${assetSet.id}/assets` &&
(url.searchParams.get('ql') ?? '').includes(`no-match-${stamp}`)
);
});
await page.getByTestId('asset-search').fill(`no-match-${stamp}`);
const emptyResponse = await emptyResponsePromise;
expect(emptyResponse.status()).toBe(200);
expect((await emptyResponse.json()).data).toEqual([]);
await expect(page.getByTestId('asset-row')).toHaveCount(0);
} finally {
releaseMatchingResponse();
}
await matchingReleased;
await (await staleResponsePromise).finished();
await expect(page.getByTestId('asset-row')).toHaveCount(0);
});
69 changes: 67 additions & 2 deletions e2e/tests/item-delete-feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { createItemViaAPI, createWorkspaceViaAPI } from '../fixtures/api-helpers';
import {
createItemViaAPI,
createWorkspaceViaAPI,
listItemTypesViaAPI,
} from '../fixtures/api-helpers';
import { expect, test } from '../fixtures/errors';

test.describe('item deletion feedback', () => {
Expand Down Expand Up @@ -34,7 +38,8 @@ test.describe('item deletion feedback', () => {
await page.locator('#item-delete-confirm').click();
await expect(deleteDialog).toBeHidden({ timeout: 10_000 });

const deletionToast = page.getByTestId('toast').first();
const deletionToast = page.getByTestId('toast');
await expect(deletionToast).toHaveCount(1);
await expect(deletionToast).toContainText('This item was deleted.');
await expect.soft(deletionToast).toHaveAttribute('data-toast-variant', 'info');

Expand All @@ -43,5 +48,65 @@ test.describe('item deletion feedback', () => {
await page.getByTestId(`board-item-${remainingItem.id}`).click();
await expect(page.getByTestId('item-detail-ready')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('item-title-edit')).toHaveText(remainingItem.title);
await expect(deletionToast).toHaveCount(1);
});

test('loads the new parent after reparent-and-delete without live events', async ({
page,
request,
}) => {
// Exercise the supported polling fallback so HTTP completion owns navigation.
await page.addInitScript(() => {
Object.defineProperty(window, 'EventSource', { value: undefined, configurable: true });
});
const stamp = Date.now().toString(36);
const workspace = await createWorkspaceViaAPI(request, {
name: `Reparent ${stamp}`,
key: `RP${stamp.slice(-6)}`.toUpperCase(),
});
const types = await listItemTypesViaAPI(request);
const levels = [
...new Set<number>(types.map((type: { hierarchy_level: number }) => type.hierarchy_level)),
]
.filter((level) => level >= 0)
.sort((a, b) => a - b);
expect(levels.length).toBeGreaterThanOrEqual(2);
const typeAt = (level: number) =>
types.find((type: { hierarchy_level: number }) => type.hierarchy_level === level).id;
const parent = await createItemViaAPI(request, workspace.id, {
title: `Parent ${stamp}`,
item_type_id: typeAt(levels[0]),
});
const deleted = await createItemViaAPI(request, workspace.id, {
title: `Delete ${stamp}`,
parent_id: parent.id,
item_type_id: typeAt(levels[1]),
});
const child = await createItemViaAPI(request, workspace.id, {
title: `Child ${stamp}`,
parent_id: deleted.id,
// Generic sub-tasks can move between these regular parent levels.
item_type_id: typeAt(-1),
});
await page.goto(`/workspaces/${workspace.id}/items/${deleted.id}`);
await expect(page.getByTestId('item-title-edit')).toHaveText(deleted.title);
await page.getByTestId('item-detail-actions-menu').click();
await page.getByTestId('item-delete-open').click();
await page.getByTestId('item-delete-reparent').check();
const reparented = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/v2/items/${deleted.id}/reparent-children`
);
await page.locator('#item-delete-confirm').click();
const reparentResponse = await reparented;
expect(reparentResponse.request().method()).toBe('POST');
expect(reparentResponse.status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`/items/${parent.id}$`));
await expect(page.getByTestId('item-title-edit')).toHaveText(parent.title);
const response = await request.get(`/api/v2/items/${child.id}`);
expect(response.status()).toBe(200);
expect((await response.json()).data.parent_id).toBe(parent.id);
await expect(page.getByTestId('toast')).toHaveCount(1);
await expect(page.getByTestId('toast')).toHaveAttribute('data-toast-variant', 'info');
});
});
6 changes: 3 additions & 3 deletions e2e/tests/item-move-cross-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ test('moves a work item to another workspace from its detail view', async ({ pag
const previewResponse = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname.endsWith(`/api/items/${item.id}/move-workspace/preview`)
new URL(response.url()).pathname.endsWith(`/api/v2/items/${item.id}/move-workspace/preview`)
);
await page.getByTestId(`item-move-workspace-option-${destination.id}`).click();
expect((await previewResponse).ok()).toBeTruthy();
Expand All @@ -44,12 +44,12 @@ test('moves a work item to another workspace from its detail view', async ({ pag
const moveResponse = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname.endsWith(`/api/items/${item.id}/move-workspace`)
new URL(response.url()).pathname.endsWith(`/api/v2/items/${item.id}/move-workspace`)
);
await page.getByTestId('item-move-workspace-confirm').click();
const response = await moveResponse;
expect(response.ok()).toBeTruthy();
const result = await response.json();
const result = (await response.json()).data;
expect(result.old_key).toBe(oldKey);
expect(result.new_key).toMatch(new RegExp(`^${destination.key}-\\d+$`));

Expand Down
Loading