Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7b1e221
ci: fail the commit message check on a bad commit in the range
erkamyaman Nov 7, 2026
0a5c966
refactor(router): share the router report types with the panel
erkamyaman Nov 7, 2026
6be9025
feat(http): refetch the TransferState payload, rule presets for agent…
erkamyaman Nov 7, 2026
a06eb1d
refactor: merge the HTTP, JSON text and Analog redaction into one module
erkamyaman Nov 7, 2026
830c0d4
feat: show how many values redaction masked in the panel and agent an…
erkamyaman Nov 7, 2026
f237447
feat(overlay): tap-to-pick and a box highlight for NativeScript and A…
erkamyaman Nov 7, 2026
1980f3e
build(extension): add a Firefox build of the extension
erkamyaman Nov 7, 2026
31a2e36
build(extension): rebuild extension/ui
erkamyaman Nov 7, 2026
78e87d1
Merge remote-tracking branch 'origin/main' into feat/uncovered-merge
erkamyaman Nov 7, 2026
e38bdf5
build(extension): rebuild extension/ui
erkamyaman Nov 7, 2026
65c54f2
docs: add refetch-transfer-state to the agent tool index
erkamyaman Nov 7, 2026
9189952
docs: fix the action tool count, the Firefox data collection shape an…
erkamyaman Nov 7, 2026
aa6a2c4
fix(http): keep the Refetched mark when the page sends its payload again
erkamyaman Nov 7, 2026
8f3a600
fix(mcp): leave JSON answers behind any notice without the redaction …
erkamyaman Nov 7, 2026
72a8372
fix(http): compare fetched-twice audit findings by their normalized key
erkamyaman Nov 7, 2026
6bd2675
fix(overlay): remove the NativeScript highlight box on an empty request
erkamyaman Nov 7, 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
2 changes: 1 addition & 1 deletion .claude/skills/devtools-commit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The rules are in `docs/contributing/commit-message-guidelines.md`. Summary:
## Pull requests

- Pull requests are squash merged; the title becomes the commit on `main`, so it must follow the header format.
- A `commit-msg` hook (`scripts/commit-message.mjs`, enabled by `pnpm install`) warns about a bad message as you commit; CI checks the title and every commit in the pull request: a bad title fails the check, and problems in the commits show as warnings. Run `pnpm commit:check` before pushing, and fix flagged messages with `git commit --amend` or a reword rebase.
- A `commit-msg` hook (`scripts/commit-message.mjs`, enabled by `pnpm install`) warns about a bad message as you commit; CI checks the title and every commit in the pull request: a bad title or a bad commit message fails the check. Run `pnpm commit:check` before pushing, and fix flagged messages with `git commit --amend` or a reword rebase.
- Address review feedback with fixup commits (`docs/contributing/using-fixup-commits.md`).
- One feature per pull request, with its tests (including agent tool tests when tools change).
- Rebuild and commit `extension/ui` when `app/` changed.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/commit-message.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ jobs:
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: node scripts/commit-message.mjs --range "$BASE..$HEAD" --warn
run: node scripts/commit-message.mjs --range "$BASE..$HEAD"
1 change: 1 addition & 0 deletions app/src/__tests__/live-route-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function routerPage(extra: Partial<RouterPage> = {}): RouterPage {
return {
pageId: 'p1',
reportedAt: 1,
changedAt: 1,
snapshot: { url: '/home', queryParams: {}, fragment: null, root },
navigations: [
{
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/live-route-pinned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function routerPage(pageId: string): RouterPage {
return {
pageId,
reportedAt: 1,
changedAt: 1,
snapshot: {
url: `/${pageId}`,
queryParams: {},
Expand Down
3 changes: 2 additions & 1 deletion app/src/__tests__/native-controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,12 @@ describe('browser-only controls', () => {
});

it.each(['angular-native', 'nativescript'])(
'hides the picker and the live views for a %s page, and keeps recording and the tree',
'offers the device picker, hides the live views for a %s page, and keeps recording and the tree',
async (platform) => {
const fixture = await render(platform);
const host = fixture.nativeElement as HTMLElement;
expect(buttons(fixture)).not.toContain('Pick component on page');
expect(buttons(fixture)).toContain('Pick component on device');
expect(buttons(fixture)).toContain('Record');
expect(host.querySelector('#cd-heading')).not.toBeNull();
expect(buttons(fixture)).not.toContain('Flash checks');
Expand Down
102 changes: 102 additions & 0 deletions app/src/__tests__/network-payload-refetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

const calls: [string, unknown][] = [];
let answer: unknown = { ok: true, found: true, entries: 2, status: 200 };

vi.mock('../rpc', () => ({
rpcCall: async (_rpc: unknown, name: string, arg?: unknown) => {
calls.push([name, arg]);
return answer;
},
}));

const { NetworkInspector } = await import('../pages/network-inspector');

const page = {
pageId: 'p1',
url: 'http://localhost:4000/trips',
initialUrl: 'http://localhost:4000/trips',
title: 'Trips',
hydration: null,
calls: [],
reportedAt: 1,
};

function client(
actions: Record<string, boolean> = {},
payload: Record<string, unknown> = { found: true, size: 7, entries: [] },
): DevframeRpcClient {
const values: Record<string, unknown> = {
http: { serverCalls: [], pages: [page], rules: [], ssrOverrides: [] },
'http-payloads': { pages: { p1: payload } },
};
const rpc = {
sharedState: (name: string) =>
Promise.resolve({ value: () => values[name], on: () => () => {} }),
};
return {
connectionMeta: { configs: { pangular: { actions } } },
scope: () => ({ rpc }),
} as unknown as DevframeRpcClient;
}

let fixture: ComponentFixture<InstanceType<typeof NetworkInspector>>;

async function render(rpc: DevframeRpcClient) {
fixture = TestBed.createComponent(NetworkInspector);
fixture.componentRef.setInput('rpc', rpc);
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
return fixture.nativeElement as HTMLElement;
}

const button = (host: HTMLElement) =>
host.querySelector<HTMLButtonElement>('button[data-refetch-payload]')!;

describe('NetworkInspector payload refetch', () => {
afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
calls.length = 0;
answer = { ok: true, found: true, entries: 2, status: 200 };
});

it('asks the server to refetch the selected page and announces the result', async () => {
const host = await render(client());
const refetch = button(host);
expect(refetch.textContent!.trim()).toBe('Refetch');
expect(refetch.getAttribute('aria-describedby')).toBe('refetch-hint');
refetch.click();
await fixture.whenStable();
expect(calls).toContainEqual(['request-http-payload-refetch', { pageId: 'p1' }]);
expect(host.querySelector('.toolbar [role="status"]')!.textContent).toBe(
'Refetched: 2 entries.',
);
});

it('reads out the reason when the refetch does nothing', async () => {
answer = { error: 'The page did not answer within 15s. Is the tab still open?' };
const host = await render(client());
button(host).click();
await fixture.whenStable();
expect(host.querySelector('.toolbar [role="status"]')!.textContent).toBe(
'The page did not answer within 15s. Is the tab still open?',
);
});

it('is off when HTTP actions are off, and says why', async () => {
const host = await render(client({ http: false }));
const refetch = button(host);
expect(refetch.disabled).toBe(true);
expect(refetch.getAttribute('aria-describedby')).toBe('http-writes-off refetch-hint');
expect(host.querySelector('#http-writes-off')).not.toBeNull();
});

it('marks a payload that came from a refetch', async () => {
const host = await render(client({}, { found: true, size: 7, entries: [], refetchedAt: 5 }));
expect(host.querySelector('[data-refetched]')!.textContent).toBe('Refetched');
});
});
47 changes: 47 additions & 0 deletions app/src/__tests__/redaction-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { RedactionCount } from '../ui/redaction-count';
import { StoreStateTree } from '../pages/store-state-tree';

function render<T>(type: Type<T>, inputs: Record<string, unknown>) {
const fixture = TestBed.createComponent(type);
for (const [name, value] of Object.entries(inputs)) fixture.componentRef.setInput(name, value);
fixture.detectChanges();
return fixture;
}

describe('RedactionCount', () => {
afterEach(() => TestBed.resetTestingModule());

it('says how many values the redaction masked', () => {
const fixture = render(RedactionCount, {
value: { token: '[redacted]', user: { email: '[email]', name: 'Ada' } },
});
const line = (fixture.nativeElement as HTMLElement).querySelector('[data-redaction-count]')!;
expect(line.textContent!.replace(/\s+/g, ' ').trim()).toBe(
'2 values masked by redaction (secret names, tokens and emails).',
);
});

it('shows nothing when nothing was masked', () => {
const fixture = render(RedactionCount, { value: { name: 'Ada' } });
expect(
(fixture.nativeElement as HTMLElement).querySelector('[data-redaction-count]'),
).toBeNull();
});

it('marks a count past the budget as a lower bound', () => {
const fixture = render(RedactionCount, {
value: Array.from({ length: 30_000 }, () => '[redacted]'),
});
const line = (fixture.nativeElement as HTMLElement).querySelector('[data-redaction-count]')!;
expect(line.textContent).toContain('At least 20000 values masked');
});

it('shows the count above the store state tree', () => {
const fixture = render(StoreStateTree, { value: { auth: { token: '[redacted]' } } });
const line = (fixture.nativeElement as HTMLElement).querySelector('[data-redaction-count]')!;
expect(line.textContent).toContain('1 value masked');
});
});
2 changes: 1 addition & 1 deletion app/src/__tests__/route-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function nav(id: number, extra: Partial<NavigationRecord> = {}): NavigationRecor
}

function page(navigations: NavigationRecord[]): RouterPage {
return { pageId: 'p1', reportedAt: 1, snapshot: null, navigations };
return { pageId: 'p1', reportedAt: 1, changedAt: 1, snapshot: null, navigations };
}

async function settle(fixture: ComponentFixture<unknown>) {
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/route-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async function settle(fixture: ComponentFixture<unknown>) {
const page: RouterPage = {
pageId: 'p1',
reportedAt: 1,
changedAt: 1,
snapshot: null,
navigations: [],
config: [{ id: '0', path: 'account', fullPath: '/account', kind: 'component', component: 'A' }],
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/route-timeline-clear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function nav(id: number, url: string, extra: Partial<NavigationRecord> = {}): Na
}

function page(extra: Partial<RouterPage> = {}): RouterPage {
return { pageId: 'p1', reportedAt: 1, snapshot: null, navigations: [], ...extra };
return { pageId: 'p1', reportedAt: 1, changedAt: 1, snapshot: null, navigations: [], ...extra };
}

async function settle(fixture: ComponentFixture<unknown>) {
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/route-timeline-extras.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function mount(page: Partial<RouterPage>, call?: (name: string, arg: unknown) =>
fixture.componentRef.setInput('page', {
pageId: 'p1',
reportedAt: 1,
changedAt: 1,
snapshot: null,
navigations: [],
...page,
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/route-timeline-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('RouteTimeline phase colors', () => {
const page: RouterPage = {
pageId: 'p1',
reportedAt: 1,
changedAt: 1,
snapshot: null,
navigations: [
{
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/router-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function page(extra: Partial<RouterPage> = {}): RouterPage {
return {
pageId: 'p1',
reportedAt: 1,
changedAt: 1,
snapshot: {
url: '/users/42',
queryParams: {},
Expand Down
44 changes: 26 additions & 18 deletions app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { LeaksSection, type LeaksPage } from './leaks-section';
import { VirtualWindow } from '../ui/virtual-window';
import { keepBelowSticky, stickyClearance } from '../ui/sticky-clearance';
import { SearchField } from '../ui/search-field';
import { RedactionCount } from '../ui/redaction-count';

interface SourceComponent {
selector: string;
Expand Down Expand Up @@ -228,6 +229,7 @@ function storedDirectiveHosts(): boolean {
selector: 'app-component-tree',
imports: [
SearchField,
RedactionCount,
NgTemplateOutlet,
Select,
CdRecording,
Expand Down Expand Up @@ -317,20 +319,18 @@ function storedDirectiveHosts(): boolean {
</svg>
<span class="label">Collapse all</span>
</button>
@if (!native()) {
<button
type="button"
class="pick"
[class.on]="picking()"
[attr.title]="pickLabel()"
(click)="picking() ? cancelPick() : pick()"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4l6.5 16 2.3-6.7L19.5 11z" />
</svg>
<span class="label">{{ pickLabel() }}</span>
</button>
}
<button
type="button"
class="pick"
[class.on]="picking()"
[attr.title]="pickLabel()"
(click)="picking() ? cancelPick() : pick()"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4l6.5 16 2.3-6.7L19.5 11z" />
</svg>
<span class="label">{{ pickLabel() }}</span>
</button>
</div>
</div>
<p class="sr-only" role="status">{{ announcement() }}</p>
Expand Down Expand Up @@ -604,6 +604,7 @@ function storedDirectiveHosts(): boolean {
</div>

@if (shownValues(); as v) {
<app-redaction-count [value]="v" />
@if (propQuery() && !v.count) {
<div class="state compact">
<p class="state-title">
Expand Down Expand Up @@ -1907,7 +1908,13 @@ export class ComponentTree {
readonly focusId = signal<string | null>(null);
/** The full text of the focused row when its name or tag is cut off, shown below the row. */
readonly nameTip = signal<{ text: string; top: number } | null>(null);
readonly pickLabel = computed(() => (this.picking() ? 'Cancel pick' : 'Pick component on page'));
readonly pickLabel = computed(() =>
this.picking()
? 'Cancel pick'
: this.native()
? 'Pick component on device'
: 'Pick component on page',
);
private readonly bridge = inject(ExtensionBridge);
readonly actionMessage = linkedSignal<string | null, string>({
source: this.selectedId,
Expand Down Expand Up @@ -2300,8 +2307,7 @@ export class ComponentTree {
}
if (command.kind === 'pick') {
untracked(() => {
if (this.native()) this.say('Picking needs a page in a browser.');
else if (!this.picking()) void this.pick();
if (!this.picking()) void this.pick();
this.commandHandled.emit();
});
return;
Expand Down Expand Up @@ -2615,7 +2621,9 @@ export class ComponentTree {
this.pickPageId = this.page()?.pageId ?? null;
this.picking.set(true);
this.say(
'Click a component in the app, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.',
this.native()
? 'Tap a component on the device, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.'
: 'Click a component in the app, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.',
);
const result = (await client
.scope('pangular')
Expand Down
3 changes: 3 additions & 0 deletions app/src/pages/forms-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type FormSetupError,
type WebMcpPage,
} from './forms-types';
import { RedactionCount } from '../ui/redaction-count';

type Tab = 'fields' | 'schema' | 'timeline' | 'submit' | 'lint';

Expand Down Expand Up @@ -71,6 +72,7 @@ function countFields(node: FormFieldNode): number {
@Component({
selector: 'app-forms-inspector',
imports: [
RedactionCount,
FormsActionBar,
FormsExportTest,
FormsFields,
Expand Down Expand Up @@ -248,6 +250,7 @@ function countFields(node: FormFieldNode): number {
>
@switch (tab_()) {
@case ('fields') {
<app-redaction-count [value]="form" />
<app-forms-fields
[form]="form"
[rows]="rows()"
Expand Down
Loading
Loading