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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(api): centralize request-summary metric calculations and formatting in `SummaryMetricComparison`, preserving history labels, order, units, rounding, percentages, trends, and panel links.
- feat(api): add framework-neutral `PanelComparison` for ordered panel IDs, failure precedence, capture states, and combined structural/state counts without changing adapter output or diagnostic values.
- refactor: share captured URL-to-path display conversion through `Text::urlToPath()` while retaining original diagnostic URLs and adapter-owned presentation models.
- feat(events): add the shared execution inspector, stable chronology, grouped filters, bounded optional context and traces, and explicit lifecycle correlation.
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,54 @@ Both adapters currently require `php-forge/debug-core` at `^0.1@dev`; this const
installed or locked development revision includes these classes. Update and verify consuming application locks together.
Local adapter installations linked to this workspace verify integration but do not validate older published artifacts.
No adapter constructor, property, getter, return type, template, asset, or persisted representation changes.

### Event table and diagnostics

Events uses one filterable, sortable table with native diagnostic controls in the event column. Original observation
numbers, offsets from the first captured event, and previous-observation gaps remain stable across filtering, sorting,
and pagination. Event/source shortcuts show whole-capture counts and retain the adapter's substring filter semantics.

`EventInspectorRenderer::renderControls()` renders group shortcuts and capture guidance. Adapters reuse one
`EventSequence` for the complete capture and call `renderTimeCell()` and `renderEventCell()` for each visible row.
Append `renderDetailRow()` immediately after each event row, passing the table's column count. The native disclosure
reveals context and source trace across the table width, side by side on larger screens and stacked on narrow screens.
Diagnostics do not repeat the timestamp, event name, class, source, or static flag already available in the table.
There is no standalone execution-flow renderer or secondary event table.

`PanelMessage` centralizes static presentation text, starting with Events. Shared labels have unprefixed case names;
event-specific guidance and capture-state descriptions use `EVENT_`. Pass cases directly to `content()` without
`->value`; `ui-awesome/html-mixin ^0.8.1` normalizes the enum value before HTML encoding. Captured values, filter keys,
and dynamic text remain outside the catalog. The rendered wording and snapshot format are unchanged.

```php
use PHPForge\Debug\Panel\PanelMessage;
use UIAwesome\Html\Flow\P;

echo P::tag()->content(PanelMessage::EVENT_CAPTURE_GUIDANCE)->render();
```

`EventRow::withInspection()` creates an enriched copy without changing the captured row. `EventInspection` supplies
optional bounded scalar context, argument-free source locations, capture states, and request-local lifecycle correlation.
Rows without diagnostics omit `inspection` from JSON; enriched rows include it. Construct `EventInspection` without
arguments, configure optional groups through immutable methods, and read values through getters.

```php
use PHPForge\Debug\Panel\Event\EventInspection;

$inspection = (new EventInspection())
->withContext(['View file' => '/views/site.php'], 'captured')
->withTrace(['/app/action.php:42'], 'captured')
->withLifecycle(1, 'enter', 0, 10.25);
```

Omit groups that are not captured. Context and trace methods require an explicit capture state; lifecycle metadata
must describe an actual observation rather than an inferred pair. Capture bounds and hydration validation are unchanged.

Lifecycle intervals are shown only for one explicitly correlated entry/leave pair with a consistent source, depth,
and monotonic clock. They include nested work and dispatch overhead; they are not listener or exclusive middleware
durations. Missing or ambiguous observations remain unavailable, never zero or successful. Context and trace capture
are adapter opt-ins; listener execution and final propagation results are not captured. See each adapter's Events
configuration for its capture coverage and selected fields.

Run `npm run test:events` against the configured local applications for keyboard, filter, responsive layout, and
light/dark accessibility checks on fresh captures. These checks do not require seeded history fixtures.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"ui-awesome/html-core-component": "^0.4",
"ui-awesome/html-helper": "^0.7",
"ui-awesome/html-interop": "^0.4",
"ui-awesome/html-mixin": "^0.8.1",
"ui-awesome/html-svg": "^0.6"
},
"require-dev": {
Expand Down
111 changes: 111 additions & 0 deletions e2e/events.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

import { debugApps } from "./support/environment.js";
import { expandToolbar, waitForToolbar } from "./support/debug-ui.js";

for (const app of debugApps()) {
for (const theme of ["light", "dark"]) {
test(`${app.name} ${theme} event table preserves chronology and accessible controls`, async ({
page,
}) => {
await page.goto(app.baseURL);
const toolbar = await waitForToolbar(page);
await expandToolbar(toolbar);
const eventLink = toolbar.locator('a[href*="panel=event"]').first();
await expect(eventLink).toBeAttached();
const href = await eventLink.getAttribute("href");
expect(href).toBeTruthy();
const url = new URL(href, app.baseURL);
url.searchParams.set("yii_debug_theme", theme);
url.searchParams.set("per-page", "10");
await page.goto(url.href);

const grid = page.locator(".yii-debug-grid-event");
const items = grid.locator(".yii-debug-event-item");
await expect(grid).toBeVisible();
await expect(grid.locator("table")).toHaveCount(1);
await expect(items).toHaveCount(10);
await expect(
page.locator(".yii-debug-event-raw, .yii-debug-event-flow"),
).toHaveCount(0);
await expect(grid.locator('input[name="Event[class]"]')).toBeVisible();
await expect(items.first()).toHaveAttribute("id", "event-1");
await expect(grid.locator("tbody tr").first()).toContainText("+0.000 ms");
const summary = items.first().locator(":scope > summary");
const detail = grid.locator("#event-1-detail");
await expect(detail).toBeHidden();
await summary.focus();
await page.keyboard.press("Enter");
await expect(items.first()).toHaveAttribute("open", "");
await expect(summary).toHaveAttribute("aria-controls", "event-1-detail");
await expect(detail).toBeVisible();
await expect(detail).toContainText("Context");
await expect(detail).toContainText("Source trace");
await expect(detail).toContainText("Not captured");
await expect(detail).not.toContainText("Event name");
await expect(detail).not.toContainText("Observed at");

const layout = await detail.evaluate((element) => {
const context = element.querySelector(".yii-debug-event-context");
const trace = element.querySelector(".yii-debug-event-trace");
const table = element.closest("table");
return {
width: element.getBoundingClientRect().width,
tableWidth: table.getBoundingClientRect().width,
context: context.getBoundingClientRect().toJSON(),
trace: trace.getBoundingClientRect().toJSON(),
stacked: window.matchMedia("(width < 768px)").matches,
};
});
expect(Math.abs(layout.width - layout.tableWidth)).toBeLessThan(4);
if (layout.stacked) {
expect(layout.trace.top).toBeGreaterThanOrEqual(layout.context.bottom);
} else {
expect(layout.trace.left).toBeGreaterThan(layout.context.right);
expect(layout.trace.top).toEqual(layout.context.top);
}

const audit = await new AxeBuilder({ page })
.include(".yii-debug-grid-event")
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(audit.violations).toEqual([]);
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth + 1,
);
expect(overflow, "The event table must not overflow the document").toBe(
false,
);
await summary.press("Enter");
await expect(items.first()).not.toHaveAttribute("open", "");
await expect(detail).toBeHidden();

const secondPage = page.getByRole("link", { name: "2", exact: true });
await expect(secondPage).toBeVisible();
await secondPage.click();
await expect(
page.locator(".yii-debug-event-item").first(),
).toHaveAttribute("id", "event-11");

await page.getByText("Group filters", { exact: true }).click();
const firstGroup = page.locator(".yii-debug-event-group[href]").first();
const filterURL = await firstGroup.getAttribute("href");
expect(filterURL).toBeTruthy();
await page.goto(new URL(filterURL, app.baseURL).href);
await expect(page.locator(".yii-debug-grid-event")).toBeVisible();
expect(new URL(page.url()).searchParams.has("page")).toBe(false);
const classFilter = page.locator('input[name="Event[class]"]');
await classFilter.fill("__no_such_event__");
await classFilter.press("Enter");
await expect(page.locator(".yii-debug-event-item")).toHaveCount(0);
await expect(page.locator(".yii-debug-active-filters")).toBeVisible();
await page.goto(new URL(filterURL, app.baseURL).href);
await expect(
page.locator(".yii-debug-event-item").first(),
).toHaveAttribute("id", /event-\d+/);
});
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"check:size": "node tools/check-asset-size.mjs",
"check:contrast": "node tools/check-token-contrast.mjs",
"check:contrast:strict": "node tools/check-token-contrast.mjs --strict",
"test:events": "DEBUG_UI_SEED_FIXTURES=0 playwright test e2e/events.spec.js",
"test:e2e": "playwright test e2e/smoke.spec.js e2e/security.spec.js",
"test:e2e:list": "DEBUG_UI_SEED_FIXTURES=0 playwright test --list",
"test:a11y": "playwright test e2e/accessibility.spec.js",
Expand All @@ -27,7 +28,7 @@
"test:visual:update": "DEBUG_UI_VISUAL_COMPARE=1 playwright test e2e/visual.spec.js --update-snapshots",
"test:perf": "DEBUG_UI_SEED_FIXTURES=0 playwright test e2e/large-data.spec.js --project=desktop-1440",
"test:perf:live": "playwright test e2e/live-performance.spec.js --project=desktop-1440",
"test:browser": "playwright test e2e/smoke.spec.js e2e/security.spec.js e2e/accessibility.spec.js e2e/visual.spec.js"
"test:browser": "playwright test e2e/smoke.spec.js e2e/security.spec.js e2e/accessibility.spec.js e2e/visual.spec.js e2e/events.spec.js"
Comment thread
terabytesoftw marked this conversation as resolved.
},
"engines": {
"node": "^22.12.0 || >=24.0.0"
Expand Down
2 changes: 1 addition & 1 deletion resources/assets/dist/css/debug.min.css

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions resources/src/styles/events.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/* Event diagnostics extend the shared table without duplicating captured rows. */
.yii-debug .yii-debug-event-controls {
margin-block: var(--yii-debug-space-4);
font-size: var(--yii-debug-fs-xs);
}

.yii-debug .yii-debug-event-groups {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--yii-debug-space-2);
margin-block: var(--yii-debug-space-4);
}

.yii-debug .yii-debug-event-group {
display: inline-flex;
align-items: center;
gap: var(--yii-debug-space-2);
min-height: var(--yii-debug-control-min-size);
padding: var(--yii-debug-space-1) var(--yii-debug-space-2);
border: var(--yii-debug-border);
border-radius: var(--yii-debug-radius-pill);
background: var(--yii-debug-panel-surface-muted);
overflow-wrap: anywhere;
}

.yii-debug .yii-debug-event-item > summary {
cursor: pointer;
list-style: none;
}

.yii-debug .yii-debug-event-item > summary:focus-visible {
outline: 2px solid var(--yii-debug-panel-primary);
outline-offset: 4px;
border-radius: var(--yii-debug-radius-sm);
}

.yii-debug :is(.yii-debug-event-clock, .yii-debug-event-identity) {
display: grid;
gap: var(--yii-debug-space-1);
}

.yii-debug .yii-debug-event-clock {
min-width: 105px;
}

.yii-debug .yii-debug-event-time {
white-space: nowrap;
color: var(--yii-debug-panel-primary);
}

.yii-debug .yii-debug-event-identity {
min-width: 0;
overflow-wrap: anywhere;
}

.yii-debug .yii-debug-event-name::before {
content: "+ ";
color: var(--yii-debug-panel-primary);
}

.yii-debug .yii-debug-event-item[open] .yii-debug-event-name::before {
content: "− ";
}

.yii-debug .yii-debug-event-timing {
font-size: var(--yii-debug-fs-xs);
}

.yii-debug .yii-debug-event-detail-row {
display: none;
}

.yii-debug .yii-debug-table tr:has(.yii-debug-event-item[open]) + .yii-debug-event-detail-row {
display: table-row;
}

.yii-debug .yii-debug-table .yii-debug-event-detail-row > td {
padding: 0;
}

.yii-debug .yii-debug-event-detail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--yii-debug-space-4);
padding: var(--yii-debug-space-4);
background: var(--yii-debug-panel-surface-muted);
white-space: normal;
}

.yii-debug .yii-debug-event-detail > div {
min-width: 0;
}

.yii-debug .yii-debug-event-detail-footer {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: var(--yii-debug-space-2) var(--yii-debug-space-4);
}

.yii-debug .yii-debug-event-metadata {
display: grid;
grid-template-columns: minmax(75px, 0.4fr) minmax(0, 1fr);
gap: var(--yii-debug-space-2) var(--yii-debug-space-3);
}

.yii-debug .yii-debug-event-metadata dt {
color: var(--yii-debug-panel-muted);
}

.yii-debug .yii-debug-event-metadata dd {
margin: 0;
}

.yii-debug .yii-debug-event-trace pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}

.yii-debug :is(.yii-debug-event-context, .yii-debug-event-trace) > :last-child {
margin-bottom: 0;
}

@media (width < 768px) {
.yii-debug .yii-debug-event-detail {
grid-template-columns: minmax(0, 1fr);
}
}

.yii-debug .yii-debug-event-coverage > summary {
min-height: var(--yii-debug-control-min-size);
padding-block: var(--yii-debug-space-2);
cursor: pointer;
}
16 changes: 4 additions & 12 deletions resources/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

@import "./tokens.css";
@import "./fonts.css";
@import "./events.css";

/**
* ---------------------------------------------------------------------------
Expand Down Expand Up @@ -5468,23 +5469,14 @@
}

/**
* Event FQCNs already wrap mid-word through the inherited
* `overflow-wrap: anywhere`; releasing the GridView minimum width lets
* narrow viewports shrink the grid so long class names wrap in place
* instead of pushing the whole table sideways. Under the mobile
* breakpoint every cell row densifies to the History rhythm — header
* and filter cells included, since the single-word column titles bind
* four of the five column minimums — so the nowrap timestamp column
* fits beside classic desktop scrollbars without residual horizontal
* scroll. Wrapping stays untouched.
* Event rows keep the shared table rhythm; the wrapper keeps every filter
* and diagnostic column reachable on narrow viewports.
*/
.yii-debug-grid-event .yii-debug-table {
min-width: 0;

@media (width <= 767px) {
thead th,
thead td,
tbody td,
tbody tr:not(.yii-debug-event-detail-row) td,
tbody th,
tfoot td {
padding: 8px 10px;
Expand Down
2 changes: 1 addition & 1 deletion scaffold-lock.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"providers": {
"php-forge/baseline": {
"version": "0.1.7",
"version": "0.2.0",
"path": "vendor/php-forge/baseline"
},
"php-forge/coding-standard": {
Expand Down
Loading
Loading