test(e2e): fix dead specs and cover log streaming + auth negatives (#4868)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-07-27 09:45:31 -07:00
committed by GitHub
parent fe6549e5f5
commit 3783125b6a
7 changed files with 163 additions and 21 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
<mdi:cog class="size-6" /> <mdi:cog class="size-6" />
</router-link> </router-link>
<dropdown class="dropdown-end" v-if="config.user"> <dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
<template #trigger> <template #trigger>
<template v-if="config.disableAvatars || !config.user.email"> <template v-if="config.disableAvatars || !config.user.email">
<material-symbols:person class="size-6" /> <material-symbols:person class="size-6" />
+51 -3
View File
@@ -1,3 +1,10 @@
x-dozzle-healthcheck: &dozzle-healthcheck
test: ["CMD", "/dozzle", "healthcheck"]
interval: 5s
retries: 5
start_period: 5s
start_interval: 5s
services: services:
custom_base: custom_base:
container_name: custom_base container_name: custom_base
@@ -10,6 +17,7 @@ services:
- DOZZLE_HOSTNAME=custom name - DOZZLE_HOSTNAME=custom name
ports: ports:
- 8080:8080 - 8080:8080
healthcheck: *dozzle-healthcheck
build: build:
context: . context: .
simple-auth: simple-auth:
@@ -20,6 +28,7 @@ services:
environment: environment:
- DOZZLE_AUTH_PROVIDER=simple - DOZZLE_AUTH_PROVIDER=simple
- DOZZLE_NO_ANALYTICS=1 - DOZZLE_NO_ANALYTICS=1
healthcheck: *dozzle-healthcheck
build: build:
context: . context: .
dozzle: dozzle:
@@ -33,8 +42,33 @@ services:
- DOZZLE_LEVEL=debug - DOZZLE_LEVEL=debug
ports: ports:
- 7070:8080 - 7070:8080
# Deliberately no healthcheck: this container is the subject of the visual
# snapshots, and a healthcheck makes a health badge appear in the sidebar
# (HostMenu.vue), which shifts the reference images.
build: build:
context: . context: .
# Dedicated instance + noisy container so log streaming can be asserted without
# perturbing the `name=dozzle` filter the other instances rely on.
logs-viewer:
container_name: logs-viewer
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_FILTER=name=logspam
- DOZZLE_NO_ANALYTICS=1
- DOZZLE_HOSTNAME=logs
healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
- logspam
logspam:
container_name: logspam
image: alpine:3
command:
- sh
- -c
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
remote: remote:
container_name: remote container_name: remote
environment: environment:
@@ -43,6 +77,7 @@ services:
- DOZZLE_NO_ANALYTICS=1 - DOZZLE_NO_ANALYTICS=1
ports: ports:
- 5050:8080 - 5050:8080
healthcheck: *dozzle-healthcheck
build: build:
context: . context: .
depends_on: depends_on:
@@ -56,6 +91,7 @@ services:
- DOZZLE_LEVEL=debug - DOZZLE_LEVEL=debug
ports: ports:
- 8082:8080 - 8082:8080
healthcheck: *dozzle-healthcheck
build: build:
context: . context: .
depends_on: depends_on:
@@ -85,6 +121,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
- CONTAINERS=1 - CONTAINERS=1
# Dozzle calls /info on connect; without it the host never initializes and
# lists zero containers.
- INFO=1
healthcheck: healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"] test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
interval: 5s interval: 5s
@@ -105,6 +144,15 @@ services:
- PWTEST_SKIP_TEST_OUTPUT=1 - PWTEST_SKIP_TEST_OUTPUT=1
- CI=1 - CI=1
depends_on: depends_on:
- dozzle dozzle:
- custom_base condition: service_started
- remote custom_base:
condition: service_healthy
remote:
condition: service_healthy
simple-auth:
condition: service_healthy
dozzle-with-agent:
condition: service_healthy
logs-viewer:
condition: service_healthy
+1
View File
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("http://dozzle-with-agent:8080/"); await page.goto("http://dozzle-with-agent:8080/");
}); });
-15
View File
@@ -1,15 +0,0 @@
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("http://remote:8080/");
});
test("has right title", async ({ page }) => {
await expect(page).toHaveTitle(/.* - Dozzle/);
});
test("select running container", async ({ page }) => {
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
await expect(page).toHaveURL(/\/container/);
await expect(page.getByText("Accepting connections")).toBeVisible();
});
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
// Targets a dedicated instance filtered to the `logspam` container, which prints a
// numbered line every second. That makes "logs are actually streaming" assertable
// without depending on incidental output from Dozzle itself.
test.beforeEach(async ({ page }) => {
await page.goto("http://logs-viewer:8080/");
await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
await expect(page).toHaveURL(/\/container\//);
});
test("renders historical logs on load", async ({ page }) => {
await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
await expect(page.getByTestId("no-logs")).toBeHidden();
await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
});
test("streams new logs over SSE without a reload", async ({ page }) => {
const entries = page.locator("ul[data-logs] > li");
await expect(entries.first()).toBeVisible();
const initial = await entries.count();
// logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
});
test("keeps the newest log entry in view while streaming", async ({ page }) => {
const entries = page.locator("ul[data-logs] > li");
await expect(entries.first()).toBeVisible();
const initial = await entries.count();
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
await expect(entries.last()).toBeInViewport();
});
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
test.beforeEach(async ({ page }) => {
await page.goto("http://remote:8080/");
});
test("has right title", async ({ page }) => {
await expect(page).toHaveTitle(/.* - Dozzle/);
});
test("shows the labeled remote host", async ({ page }) => {
// The host column on the dashboard, rather than a bare getByText: the label also
// appears in the sidebar and the merge link, and which of them exist depends on
// whether containers have loaded yet.
await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
});
test("select running container", async ({ page }) => {
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
await expect(page).toHaveURL(/\/container/);
await expect(page.getByText("Accepting connections")).toBeVisible();
});
+52 -2
View File
@@ -1,9 +1,59 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
test("simple authentication", async ({ page }) => { const BASE = "http://simple-auth:8080";
await page.goto("http://simple-auth:8080/");
async function login(page: import("@playwright/test").Page) {
await page.locator('input[name="username"]').fill("admin"); await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("password"); await page.locator('input[name="password"]').fill("password");
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
}
test("simple authentication", async ({ page }) => {
await page.goto(BASE + "/");
await login(page);
await expect(page.getByTestId("settings")).toBeVisible(); await expect(page.getByTestId("settings")).toBeVisible();
}); });
test("unauthenticated request redirects to the login page", async ({ page }) => {
await page.goto(BASE + "/");
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[name="username"]')).toBeVisible();
});
test("preserves the requested url as redirectUrl", async ({ page }) => {
await page.goto(BASE + "/settings");
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
});
test("rejects a wrong password", async ({ page }) => {
await page.goto(BASE + "/");
await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("not-the-password");
await page.locator('button[type="submit"]').click();
await expect(page.getByText("Username or password are not valid")).toBeVisible();
await expect(page.getByTestId("settings")).toBeHidden();
});
test("api rejects unauthenticated requests with 401", async ({ request }) => {
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
const response = await request.get(BASE + path, { maxRedirects: 0 });
expect(response.status(), `${path} should be unauthorized`).toBe(401);
}
});
test("logout clears the session", async ({ page }) => {
await page.goto(BASE + "/");
await login(page);
await expect(page.getByTestId("settings")).toBeVisible();
await page.getByTestId("user-menu").locator("label").click();
await page.getByRole("button", { name: "Logout" }).click();
await expect(page.locator('input[name="username"]')).toBeVisible();
// The cookie must be gone server-side, not just visually logged out.
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
expect(response.status()).toBe(401);
});