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
85 changes: 85 additions & 0 deletions app/src/__tests__/network-rule-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { NetworkInspector } from '../pages/network-inspector';

let fixture: ComponentFixture<NetworkInspector>;

async function mount() {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const pattern = host.querySelector<HTMLInputElement>('#rule-pattern')!;
pattern.value = '/api/products';
pattern.dispatchEvent(new Event('input'));
await fixture.whenStable();
const trigger = host.querySelector<HTMLButtonElement>(
'button[role="combobox"][aria-labelledby="rule-status-label"]',
)!;
const hint = () => host.querySelector('#rule-hint')!.textContent!.trim();
return { host, trigger, hint, inspector: fixture.componentInstance };
}

describe('NetworkInspector rule status', () => {
beforeAll(() => {
Element.prototype.scrollIntoView ??= () => {};
});

afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
});

it('labels the status dropdown and lists no 1xx statuses', async () => {
const { host, trigger } = await mount();
expect(host.querySelector('#rule-status-label')!.textContent!.trim()).toBe('Status');
expect(trigger.textContent!.trim()).toBe('None');
trigger.click();
await fixture.whenStable();
const labels = [...host.querySelectorAll('[role="option"]')].map((o) => o.textContent!.trim());
expect(labels[0]).toBe('None');
expect(labels.some((l) => l.startsWith('1'))).toBe(false);
expect(labels).toContain('499 Client Closed Request');
expect(labels).toContain('524 A Timeout Occurred');
});

it('sets the rule status from the option picked in the dropdown', async () => {
const { host, trigger, inspector } = await mount();
trigger.click();
await fixture.whenStable();
const option = [...host.querySelectorAll<HTMLElement>('[role="option"]')].find(
(o) => o.textContent!.trim() === '503 Service Unavailable',
)!;
option.click();
await fixture.whenStable();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(trigger.textContent!.trim()).toBe('503 Service Unavailable');
expect(inspector.draft().status).toBe('503');
expect(inspector.draftRule()).toMatchObject({ pattern: '/api/products', status: 503 });
});

it('picks a status by typing its code', async () => {
const { trigger, inspector } = await mount();
trigger.focus();
for (const key of '404') {
trigger.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
await fixture.whenStable();
expect(inspector.draft().status).toBe('404');
expect(inspector.draftRule()).toMatchObject({ status: 404 });
});

it('refuses a status that is not in the list', async () => {
const { hint, inspector } = await mount();
for (const status of ['599', '101', '42', 'abc']) {
inspector.setDraft('status', status);
await fixture.whenStable();
expect(inspector.draftRule()).toBeNull();
expect(hint()).toBe('Pick a status from the list.');
}
inspector.setDraft('status', '520');
await fixture.whenStable();
expect(inspector.draftRule()).toMatchObject({ status: 520 });
expect(hint()).toBe('');
});
});
44 changes: 21 additions & 23 deletions app/src/pages/network-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { DevframeRpcClient } from 'devframe/client';
import { hostPageId } from '../page-id';
import { rpcCall as call } from '../rpc';
import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-config';
import { HTTP_RULE_STATUSES, isHttpRuleStatus } from '@santoshyadavdev/ng-devtools/config';
import { LimitNote } from '../ui/limit-note';
import { Select, type SelectOption } from '../ui/select';

Expand Down Expand Up @@ -126,6 +127,14 @@ const EMPTY_DRAFT: RuleDraft = {

const MAX_RULES = 50;

const HTTP_STATUS_OPTIONS: SelectOption[] = [
{ value: '', label: 'None' },
...HTTP_RULE_STATUSES.map(([status, reason]) => ({
value: String(status),
label: `${status} ${reason}`,
})),
];

@Component({
selector: 'app-network-inspector',
imports: [JsonPipe, LimitNote, Select],
Expand Down Expand Up @@ -336,20 +345,15 @@ const MAX_RULES = 50;
</div>
</div>
<div class="row">
<label>
Status
<input
type="number"
inputmode="numeric"
min="100"
max="599"
placeholder="e.g. 500"
<div class="field-group">
<span id="rule-status-label">Status</span>
<app-select
labelledBy="rule-status-label"
[options]="statusOptions"
[value]="draft().status"
(input)="patch('status', $event)"
[attr.aria-invalid]="statusError() ? 'true' : null"
aria-describedby="rule-hint"
(valueChange)="setDraft('status', $event ?? '')"
/>
</label>
</div>
<label>
Delay (ms)
<input
Expand Down Expand Up @@ -1373,6 +1377,7 @@ export class NetworkInspector {
readonly draft = signal<RuleDraft>({ ...EMPTY_DRAFT });
readonly message = signal('');
readonly bodyPlaceholder = '{ "error": "Service unavailable" }';
readonly statusOptions = HTTP_STATUS_OPTIONS;

private unsubscribe: (() => void)[] = [];
private readonly destroyRef = inject(DestroyRef);
Expand Down Expand Up @@ -1411,23 +1416,15 @@ export class NetworkInspector {
return !!rule && this.mocksOnServer(rule);
});

readonly statusError = computed(() => {
const raw = this.draft().status.trim();
if (!raw) return '';
const status = Number(raw);
return Number.isInteger(status) && status >= 100 && status <= 599
? ''
: 'Set a status from 100 to 599.';
});

readonly draftRule = computed<Omit<HttpRule, 'id'> | null>(() => {
const draft = this.draft();
const pattern = draft.pattern.trim();
if (!pattern || this.bodyError() || this.statusError()) return null;
if (!pattern || this.bodyError()) return null;
Comment thread
abiramcodes marked this conversation as resolved.
if (this.rules().length >= MAX_RULES) return null;
const body = draft.body.trim();
const delayMs = Math.min(Math.max(Math.round(Number(draft.delayMs)) || 0, 0), 10_000);
const status = draft.status.trim() ? Number(draft.status) : body ? 200 : undefined;
if (status !== undefined && !isHttpRuleStatus(status)) return null;
if (status === undefined && !delayMs) return null;
return {
pattern,
Expand All @@ -1446,8 +1443,9 @@ export class NetworkInspector {
return `You can add up to ${MAX_RULES} rules. Remove one to add another.`;
}
if (!draft.pattern.trim()) return 'Enter a URL pattern to add a rule.';
if (this.statusError()) return this.statusError();
if (this.bodyError()) return '';
if (draft.status.trim() && !isHttpRuleStatus(Number(draft.status)))
return 'Pick a status from the list.';
if (!this.draftRule()) return 'Set a status, a delay or a mock body.';
if (!draft.status.trim() && draft.body.trim())
return 'With no status, the mock body returns 200.';
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/guides/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const serverRoutes: ServerRoute[] = [
<strong>SSR + client</strong>, <strong>SSR only</strong> or <strong>Client only</strong>.
</ngmd-step>
<ngmd-step title="Set the response">
Set a status (for example <code>500</code>), a delay, or a mock JSON body. Click <strong>Add rule</strong>.
Pick a status (for example <strong>500 Internal Server Error</strong>), a delay, or a mock JSON body. Click <strong>Add rule</strong>.
</ngmd-step>
<ngmd-step title="Reload">
SSR rules apply from the next page load. Client rules apply right away.
Expand Down
9 changes: 5 additions & 4 deletions apps/docs/src/content/inspectors/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ Add a rule with these fields:
- **URL pattern**: a substring, or a glob where `*` matches anything. `/api/*` matches both relative and absolute URLs.
- **Method**: any, or one method.
- **Apply on**: SSR + client, SSR only, or client only.
- **Status**, **Delay (ms)** up to 10000, and an optional JSON body.
- **Status**: **None**, or a status from the list. The list has the standard 2xx to 5xx statuses, plus `499` (nginx) and `520` to `524` (Cloudflare). Type the code, for example `503`, to jump to it.
- **Delay (ms)** up to 10000, and an optional JSON body.

A status of 400 or more fails the request with an `HttpErrorResponse`. A lower status returns the body as a mocked response. A body without a status returns it with status 200. A rule with only a delay passes the request through, later. A rule needs a status, a delay or a body, so **Add rule** stays off until it has one. The form clears after each added rule. The first enabled rule that matches wins.
A status of 400 or more fails the request with an `HttpErrorResponse`. A lower status returns the body as a mocked response. A body with the status on **None** returns it with status 200. A stored rule with a status outside the list loses that status, and is dropped when nothing else is left to change. A rule with only a delay passes the request through, later. A rule needs a status, a delay or a body, so **Add rule** stays off until it has one. The form clears after each added rule. The first enabled rule that matches wins.

The body follows the request's `responseType`. A `json` request gets the parsed JSON (or the raw string when it does not parse) with `content-type: application/json`. A `text` request gets the string with `text/plain`. A `blob` request gets a `Blob`, and an `arraybuffer` request an `ArrayBuffer`, both with `application/octet-stream`. The same value is the `error` of an injected failure.

Expand Down Expand Up @@ -111,7 +112,7 @@ The interceptor works in development builds only. In production it passes reques

<ngmd-workflow>
<ngmd-step title="Add a rule">
Enter the URL pattern, pick <strong>Client only</strong>, and set the status to <code>500</code>. Click <strong>Add rule</strong>.
Enter the URL pattern, pick <strong>Client only</strong>, and pick <strong>500 Internal Server Error</strong> as the status. Click <strong>Add rule</strong>.
</ngmd-step>
<ngmd-step title="Use the page">
Trigger the request. The row is marked <strong>faulted</strong>.
Expand All @@ -128,7 +129,7 @@ The interceptor works in development builds only. In production it passes reques

<ngmd-workflow>
<ngmd-step title="Add a delay-only rule">
Enter the URL pattern, leave the status and the body empty, and set a delay, for example 3000 ms. Click <strong>Add rule</strong>.
Enter the URL pattern, leave the status on <strong>None</strong> and the body empty, and set a delay, for example 3000 ms. Click <strong>Add rule</strong>.
</ngmd-step>
<ngmd-step title="Watch the loading state">
The request still reaches the API, only later. Its row is marked <strong>delayed 3000 ms</strong>.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading