Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@ without having run them.

### Sign off locally before updating the pull request — never push and wait on CI

**Run sign-off only at the end, after all requested work, reviews, and focused
verification are complete. Sign-off is expensive: do not start or queue
`make ci-signoff`, full CI, or deploy-image builds while implementation or UI
iteration is still in progress. Use the existing watchers and focused tests
during development. If more work is requested after sign-off starts, stop that
run and finish the work before starting the final sign-off again.**

**A push to `master` IS a deploy, and remote CI is far too slow to be a feedback
loop: an image build alone runs ~85 minutes.** So `make ci-signoff` must be green
*before* you push the branch. It runs the checks locally and publishes
Expand Down
190 changes: 39 additions & 151 deletions e2e/tests/container-identity.spec.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions e2e/tests/dashboard-add-widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async function openFixture(page: Page): Promise<string> {
async function openWidgetDrawer(page: Page) {
await page.locator('[aria-label="Add a new widget"]').first().click();
await expect(page.locator("#visualizationTabs")).toBeVisible({ timeout: 20000 });
await expect(page.getByRole("textbox", { name: "Query", exact: true })).toBeVisible();
}

test("a slow avatar does not block dashboard initialization", async ({ page }) => {
Expand Down
102 changes: 98 additions & 4 deletions e2e/tests/infrastructure-time-window.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,102 @@ const views = [
["hosts", "hostsContainer", { provider, group: "region" }, "Search hosts"],
["images", "imagesContainer", { registry: "registry.example", runtime: "kubernetes" }, "Search images"],
["kubernetes", "kubernetesContainer", { resource: "pods", namespace, cluster }, "Search pods"],
["containers", "containersContainer", { namespace, cluster }, "Search containers"],
["host-map", "hostMapContainer", { provider, group: "region", fill: "memory" }, ""],
] as const;

for (const width of [390, 1440]) {
test(`shared host controls preserve filters, columns and grouped exports at ${width}px`, async ({ page }) => {
test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database");
await page.setViewportSize({ width, height: 900 });
await page.goto(`/p/${DEMO_PROJECT}/infrastructure/hosts?${new URLSearchParams({ provider, since: "1H" })}`);
const container = page.locator('#hostsContainer');
await expect(container.locator('tr[role="button"]')).toHaveCount(2);
if (width === 1440) {
await container.evaluate(async (target, url) => { await (window as any).htmx.ajax('GET', url, { target, swap: 'beforeend', select: '#imagesContainer-facets' }); }, `/p/${DEMO_PROJECT}/infrastructure/images?since=1H&deferred=1`);
const other = page.locator('#imagesContainer-facets');
await expect(other.getByRole('radio', { name: 'kubernetes', exact: true })).toBeVisible();
const search = container.locator('#hostsContainer-facets').getByRole('searchbox');
await search.fill(provider);
await expect(other.getByRole('radio', { name: 'kubernetes', exact: true })).toBeVisible();
await search.fill('');
await other.evaluate(rail => rail.remove());
}
await expect(container.getByRole('button', { name: 'Customize', exact: true })).toBeInViewport();
await expect(container.getByRole('button', { name: 'Export', exact: true })).toBeInViewport();
await container.getByRole('button', { name: 'Customize', exact: true }).click();
await container.locator('[data-column-toggle="CPU"]').uncheck();
await container.getByRole('button', { name: 'Customize', exact: true }).click();
await container.getByRole('combobox', { name: 'Group by', exact: true }).selectOption('region');
await expect(page).toHaveURL(/group=region/);
expect(new URL(page.url()).searchParams.get('provider')).toBe(provider);
expect(new URL(page.url()).searchParams.get('since')).toBe('1H');
expect(new URL(page.url()).searchParams.has('deferred')).toBe(false);
await expect(container.locator('[data-table-group]')).toHaveCount(1);
await expect(container.locator('td[data-column-key="CPU"]').first()).toBeHidden();
const search = container.getByRole('searchbox', { name: 'Search hosts', exact: true });
await search.fill('no-such-host');
await expect(container.locator('[data-table-group]')).toBeHidden();
await search.fill('');
await expect(container.locator('[data-table-group]')).toBeVisible();
const download = page.waitForEvent('download');
await container.getByRole('button', { name: 'Export', exact: true }).click();
const stream = await (await download).createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const csv = Buffer.concat(chunks).toString();
expect(csv.split('\n')).toHaveLength(3);
expect(csv).not.toContain('"CPU"');
expect(csv).toContain('infra-preset-host-30');
expect(csv).toContain('infra-preset-host-600');
await page.reload();
await expect(container.locator('tr[role="button"]')).toHaveCount(2);
await expect(container.locator('td[data-column-key="CPU"]').first()).toBeHidden();
await container.getByRole('button', { name: 'Customize', exact: true }).click();
await container.getByRole('button', { name: 'Reset columns', exact: true }).click();
await expect(container.locator('td[data-column-key="CPU"]').first()).toBeVisible();
if (width === 390) {
const system = container.locator('[data-column-toggle="System"]');
await expect(system).not.toBeChecked();
await system.check();
await expect(container.locator('td[data-column-key="System"]').first()).toBeVisible();
await container.getByRole('button', { name: 'Reset columns', exact: true }).click();
await expect(container.locator('td[data-column-key="System"]').first()).toBeHidden();
await page.setViewportSize({ width: 1440, height: 900 });
await expect(container.locator('td[data-column-key="System"]').first()).toBeVisible();
await expect(system).toBeChecked();
}
});
}

test('shared Kubernetes tabs preserve scoped filters and column choices', async ({ page }) => {
test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database");
await page.goto(`/p/${DEMO_PROJECT}/infrastructure/kubernetes?${new URLSearchParams({ resource: 'pods', namespace, cluster, since: '1H' })}`);
const container = page.locator('#kubernetesContainer');
await expect(container.locator('tr[role="button"]')).toHaveCount(2);
await container.getByRole('button', { name: 'Customize', exact: true }).click();
await container.locator('[data-column-toggle="Memory"]').uncheck();
await container.getByRole('button', { name: 'Customize', exact: true }).click();
await container.getByRole('tab', { name: 'Nodes', exact: true }).click();
await expect(page).toHaveURL(/resource=nodes/);
const params = new URL(page.url()).searchParams;
expect(params.get('namespace')).toBe(namespace);
expect(params.get('cluster')).toBe(cluster);
expect(params.get('since')).toBe('1H');
await expect(container.locator('tr[role="button"]')).toHaveCount(2);
await expect(container.locator('td[data-column-key="Memory"]').first()).toBeHidden();
});

for (const [route, container, filters, searchName] of views) {
test(`${route} time presets refresh scoped inventory`, async ({ page }) => {
test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database");
await page.setViewportSize({ width: 390, height: 900 });
const url = `/p/${DEMO_PROJECT}/infrastructure/${route}?${new URLSearchParams({ ...filters, from: "", to: "", since: "5m" })}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
await expect(page.locator("[data-deferred-shell]")).toHaveCount(0, { timeout: 5_000 });
const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`);
const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`);
await expect(entries).toHaveCount(1);
if (route === "containers") await expect(page.getByText(/containers reporting in the final 5m of this range/)).toBeVisible();
await page.locator('[popovertarget="n-timepicker-popover"]').click();
await page.locator("[data-mobile-live-toggle]").click();
await expect(page.locator("[data-time-transport]")).toHaveAttribute("data-interval", "0");
Expand All @@ -43,6 +128,7 @@ for (const [route, container, filters, searchName] of views) {
await page.locator('#n-timepicker-popover button[data-value="1H"]').click();
await expect(page).toHaveURL(/since=1H/);
await expect(entries).toHaveCount(2);
if (route === "containers") await expect(page.getByText(/containers reporting in the final 15m of this range/)).toBeVisible();
const params = new URL((await refresh).url()).searchParams;
expect(params.getAll("since")).toEqual(["1H"]);
expect(params.getAll("from")).toEqual([""]);
Expand Down Expand Up @@ -86,6 +172,14 @@ for (const [route, container, filters, searchName] of views) {
const restored = new URL((await entries.first().getAttribute("data-hx-get"))!, page.url()).searchParams;
expect(restored.get("since")).toBe("1H");
expect(restored.get("from") || restored.get("to")).toBeFalsy();
if (route === "containers") {
await page.reload({ waitUntil: "domcontentloaded" });
await expect(entries).toHaveCount(2);
await entries.filter({ hasText: "600" }).press("Enter");
const drawer = page.locator("#global-data-drawer-content");
await expect(drawer.getByText(/^Pod:\s*infra-preset-pod-600$/)).toBeVisible();
expect(new URL((await drawer.getByRole("link", { name: "View logs", exact: true }).getAttribute("href"))!, page.url()).searchParams.get("since")).toBe("1H");
}
});
test(`${route} time presets replace held initial inventory`, async ({ page }) => {
test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database");
Expand All @@ -107,22 +201,22 @@ for (const [route, container, filters, searchName] of views) {
await page.locator('#n-timepicker-popover button[data-value="1H"]').click();
release();
await expect(page.locator("[data-deferred-shell]")).toHaveCount(0, { timeout: 5_000 });
const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`);
const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`);
await expect(entries).toHaveCount(2);
expect(new URL((await entries.first().getAttribute("data-hx-get"))!, page.url()).searchParams.get("since")).toBe("1H");
} finally { release(); }
});
}

for (const [route, container, filters] of [...views, ["containers", "containersContainer", { namespace, cluster }]] as const) {
for (const [route, container, filters] of views) {
test(`${route} initial inventory does not wait for application script arrival`, async ({ page }) => {
test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database");
let release = () => {};
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(/\/public\/assets\/(?:js\/main\.|web-components\/dist\/js\/index\.).*\.js$/, async route => { await held; await route.continue(); });
try {
await page.goto(`/p/${DEMO_PROJECT}/infrastructure/${route}?${new URLSearchParams({ ...filters, from: "", to: "", since: "5M" })}&since=5m`, { waitUntil: "commit" });
const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`);
const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`);
await page.waitForFunction(() => Boolean((window as any).htmx));
await expect.poll(() => page.evaluate(() => typeof (window as any).params)).toBe("undefined");
// The shell may have loaded automatically before this test reaches it.
Expand Down
15 changes: 15 additions & 0 deletions e2e/tests/query-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ test.describe("Query editor", () => {
for (const tab of ["Popular", "Saved", "Recent"])
await expect(library.getByRole("tab", { name: tab })).toBeVisible();
});

test("facet Clear all removes facet clauses and keeps typed ones", async ({ page }) => {
const facets = page.locator(".facet-rail .facet-item input[data-field]");
const clear = page.locator(".facet-rail button", { hasText: "Clear all" });
await facets.first().waitFor({ state: "attached", timeout: 10_000 }).catch(() => {});
test.skip((await facets.count()) < 2, "no facet values in this database");
await page.locator("#filterElement").evaluate((el: any) => el.setValue("duration > 1000"));
await expect(clear).toBeHidden();
await facets.nth(0).check();
await facets.nth(1).check();
await expect(clear).toBeVisible();
await clear.click();
await expect.poll(() => page.locator("#filterElement").evaluate((el: any) => el.getValue())).toBe("duration > 1000");
await expect(clear).toBeHidden();
});
});

// Regression: the explorer ships without Tagify, so the monitor panel's teams picker threw
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/repositories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ test('one repository list owns service links and independent capabilities in bot
expect(sql(`SELECT count(*) FROM projects.code_mappings WHERE project_id = '${pid}'`).toString().trim()).toBe('2');
await page.getByRole('button', { name: 'Save review settings', exact: true }).press('Enter');
await expect(page.locator('#repository-detail')).toHaveCount(1);
expect(sql(`SELECT enabled FROM projects.pr_review_settings WHERE project_id = '${pid}' AND owner = 'team-a'`).toString().trim()).toBe('t');
await expect.poll(() => sql(`SELECT enabled FROM projects.pr_review_settings WHERE project_id = '${pid}' AND owner = 'team-a'`).toString().trim()).toBe('t');
await page.getByText('Move existing resources into this folder', { exact: true }).click();
await page.getByRole('combobox', { name: /Cron/ }).selectOption(cron);
await page.getByRole('combobox', { name: /Cron/ }).locator('..').locator('..').getByRole('button', { name: 'Move into folder', exact: true }).press('Enter');
Expand Down
2 changes: 2 additions & 0 deletions monoscope.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ library
Pages.Telemetry
Pkg.AI
Pkg.AssetManifestFingerprint
Pkg.Components.Facets
Pkg.Components.LogQueryBox
Pkg.Components.ServiceMap
Pkg.Components.Table
Expand Down Expand Up @@ -1030,6 +1031,7 @@ test-suite test-dev
Pages.Telemetry
Pkg.AI
Pkg.AssetManifestFingerprint
Pkg.Components.Facets
Pkg.Components.LogQueryBox
Pkg.Components.ServiceMap
Pkg.Components.Table
Expand Down
Loading
Loading