Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d66fdd3
feat(router): report the Router of each Angular app on a page
erkamyaman Nov 7, 2026
09ceaf6
feat(router): add an app picker to the Live router view
erkamyaman Nov 7, 2026
1a984ef
docs(docs): document several Angular apps on one page in the router docs
erkamyaman Nov 7, 2026
0c7f066
feat(overlay): report Ionic ion-router-outlet page stacks
erkamyaman Nov 7, 2026
cbdc8e2
feat: show Ionic page stacks in the Capacitor tab
erkamyaman Nov 7, 2026
99f4fbb
docs(docs): document Ionic page stacks on the Capacitor page
erkamyaman Nov 7, 2026
32776ce
test: seed Ionic page stacks in the panel axe run
erkamyaman Nov 7, 2026
0d28704
build(extension): rebuild extension/ui with the app picker and Ionic …
erkamyaman Nov 7, 2026
9001938
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
49111ad
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
a363123
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
5785b17
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
0204b15
fix(router): show no data for an app the page does not have
erkamyaman Nov 7, 2026
ae4f133
fix(router): find navigation loops for each router app
erkamyaman Nov 7, 2026
784e345
docs(router): list the app argument, split the Ionic direction paths …
erkamyaman Nov 7, 2026
ba6c6af
fix(router): register request-page-highlight while components or capa…
erkamyaman Nov 7, 2026
242c5a6
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
cd190ce
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
7acd4af
ci(router): read the app argument row in tools.md as an argument in t…
erkamyaman Nov 7, 2026
484ce90
fix(router): drop the loops a page sends for its router apps
erkamyaman Nov 7, 2026
393009f
fix(router): give list-pages the URL and title of a page only Ionic r…
erkamyaman Nov 7, 2026
57f7fc3
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
c5ce8f5
fix(router): read app navigations from the narrowed entry when clearing
erkamyaman Nov 7, 2026
d2fcd80
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
cb76736
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
df31e2d
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
017e849
fix(router): clear only the selected app's navigations
erkamyaman Nov 7, 2026
df9d8f3
fix(router): find an app that bootstraps into a root already on the page
erkamyaman Nov 7, 2026
41adc00
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
cd8578b
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
5db731c
Merge remote-tracking branch 'origin/main' into feat/router-multiapp-…
erkamyaman Nov 7, 2026
4d563f4
build(extension): rebuild extension/ui after the main merges
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
123 changes: 123 additions & 0 deletions app/src/__tests__/capacitor-ionic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { CapacitorIonic, directionText, type IonicPage } from '../pages/capacitor-ionic';

function ionicPage(pageId = 'tab1'): IonicPage {
return {
pageId,
url: '/tabs/tab1/detail',
title: 'Ionic app',
framework: '@ionic/angular',
mode: 'ios',
reportedAt: 1,
outlets: [
{
id: 'c1-1',
name: 'primary',
tabs: '/tabs/tab1',
source: 'angular',
canGoBack: true,
direction: 'back',
directionSource: 'event',
animated: true,
pages: [
{
component: 'HomePage',
tag: 'app-home',
url: '/tabs/tab1',
active: true,
elementId: 'e1',
},
{
component: 'DetailPage',
tag: 'app-detail',
url: '/tabs/tab1/detail',
active: false,
elementId: 'e2',
},
],
},
],
};
}

function fakeClient(pages: IonicPage[]) {
const calls: { name: string; arg: unknown }[] = [];
const rpc = {
call: (name: string, arg: unknown) => {
calls.push({ name, arg });
return Promise.resolve(null);
},
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () =>
name === 'ionic' ? { pages: Object.fromEntries(pages.map((p) => [p.pageId, p])) } : {},
on: () => () => {},
}),
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
return { client, calls };
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

afterEach(() => {
document.body.innerHTML = '';
});

describe('CapacitorIonic', () => {
it('lists the page stack of each outlet with the active page and the last direction', async () => {
const { client, calls } = fakeClient([ionicPage()]);
const fixture = TestBed.createComponent(CapacitorIonic);
fixture.componentRef.setInput('rpc', client);
fixture.componentRef.setInput('pageId', 'tab1');
document.body.append(fixture.nativeElement);
await settle(fixture);
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('h2')?.textContent).toBe('Ionic page stacks');
const rows = Array.from(host.querySelectorAll<HTMLButtonElement>('.stack-page'));
expect(rows.map((row) => row.querySelector('.component')?.textContent)).toEqual([
'HomePage',
'DetailPage',
]);
expect(rows[0].getAttribute('aria-current')).toBe('page');
expect(rows[1].getAttribute('aria-current')).toBeNull();
expect(host.textContent).toContain('/tabs/tab1');
expect(host.textContent).toContain('Back');

rows[1].dispatchEvent(new Event('focus'));
expect(calls.at(-1)).toEqual({
name: 'request-page-highlight',
arg: { pageId: 'tab1', id: 'e2', reveal: false },
});
rows[1].dispatchEvent(new Event('blur'));
expect(calls.at(-1)).toEqual({ name: 'request-page-highlight', arg: null });
});

it('shows nothing for a page that does not use Ionic', async () => {
const { client } = fakeClient([ionicPage('other')]);
const fixture = TestBed.createComponent(CapacitorIonic);
fixture.componentRef.setInput('rpc', client);
fixture.componentRef.setInput('pageId', 'tab1');
await settle(fixture);
expect((fixture.nativeElement as HTMLElement).querySelector('section')).toBeNull();
});

it('words the direction with its source', () => {
const outlet = ionicPage().outlets[0];
expect(directionText({ ...outlet, direction: undefined })).toBe('Not seen yet');
expect(directionText({ ...outlet, directionSource: 'inferred', direction: 'forward' })).toBe(
'Forward (from the stack)',
);
expect(directionText({ ...outlet, direction: 'root', tabSwitch: true, animated: false })).toBe(
'Root (tab switch, no animation)',
);
});
});
114 changes: 114 additions & 0 deletions app/src/__tests__/live-route-apps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { LiveRoute } from '../pages/live-route';
import type { RouterApp, RouterPage } from '../pages/router-types';

const root = { path: '', url: '', outlet: 'primary', params: {}, data: {}, children: [] };

function routerPage(extra: Partial<RouterPage> = {}): RouterPage {
return {
pageId: 'p1',
reportedAt: 1,
snapshot: { url: '/home', queryParams: {}, fragment: null, root },
navigations: [
{
id: 1,
url: '/home',
trigger: 'imperative',
startedAt: 1,
endedAt: 2,
outcome: 'succeeded',
},
],
...extra,
};
}

const apps: RouterApp[] = [
{ id: 'app-root', name: 'ShellApp', selector: 'app-root', primary: true },
{
id: 'cart-widget',
name: 'CartWidget',
selector: 'cart-widget',
snapshot: { url: '/checkout', queryParams: {}, fragment: null, root },
navigations: [
{
id: 4,
url: '/checkout',
trigger: 'imperative',
startedAt: 3,
endedAt: 4,
outcome: 'failed',
},
],
},
];

function fakeClient(pages: RouterPage[]) {
const calls: { name: string; arg: unknown }[] = [];
const rpc = {
call: (name: string, arg: unknown) => {
calls.push({ name, arg });
return Promise.resolve(null);
},
callEvent: () => Promise.resolve(),
sharedState: () =>
Promise.resolve({
value: () => ({ pages }),
on: () => () => {},
}),
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
return { client, calls };
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

afterEach(() => {
document.body.innerHTML = '';
});

describe('LiveRoute on a page with several apps', () => {
it('offers an app picker and shows the picked app', async () => {
const { client } = fakeClient([routerPage({ apps })]);
const fixture = TestBed.createComponent(LiveRoute);
fixture.componentRef.setInput('rpc', client);
document.body.append(fixture.nativeElement);
await settle(fixture);
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('#live-route-app-label')?.textContent).toBe('App');
expect(fixture.componentInstance.appId()).toBe('app-root');
expect(fixture.componentInstance.page()?.snapshot?.url).toBe('/home');
expect(fixture.componentInstance.appOptions().map((o) => o.label)).toEqual([
'ShellApp <app-root>',
'CartWidget <cart-widget>',
]);

fixture.componentInstance.appId.set('cart-widget');
await settle(fixture);
const view = fixture.componentInstance.page();
expect(view?.appId).toBe('cart-widget');
expect(view?.pageId).toBe('p1');
expect(view?.snapshot?.url).toBe('/checkout');
expect(fixture.componentInstance.problems()).toBe(1);
});

it('shows no app picker on a page with one app', async () => {
const page = routerPage();
const { client } = fakeClient([page]);
const fixture = TestBed.createComponent(LiveRoute);
fixture.componentRef.setInput('rpc', client);
document.body.append(fixture.nativeElement);
await settle(fixture);
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('#live-route-app-label')).toBeNull();
expect(fixture.componentInstance.appId()).toBeNull();
expect(fixture.componentInstance.page()).toBe(page);
});
});
29 changes: 29 additions & 0 deletions app/src/__tests__/router-app-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { routerAppView, type RouterPage } from '../pages/router-types';

describe('routerAppView', () => {
it('carries the loops of a secondary app into its view', () => {
const loop = {
kind: 'redirect' as const,
ids: [1, 2],
cycle: ['/a', '/b'],
hops: [],
guards: [],
bounces: 1,
end: '/b',
};
const page = {
pageId: 'p',
reportedAt: 1,
changedAt: 1,
snapshot: null,
navigations: [],
apps: [
{ id: 'app-root', name: 'Shell', selector: 'app-root', primary: true },
{ id: 'cart', name: 'Cart', selector: 'cart-widget', navigations: [], loops: [loop] },
],
} as unknown as RouterPage;
expect(routerAppView(page, 'cart').loops).toEqual([loop]);
expect(routerAppView(page, 'app-root').loops).toBeUndefined();
});
});
5 changes: 4 additions & 1 deletion app/src/pages/capacitor-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { LimitNote } from '../ui/limit-note';
import { Select, type SelectOption } from '../ui/select';
import { panelConfig } from '../devtools-config';
import { CapacitorCallDetail } from './capacitor-call-detail';
import { CapacitorIonic } from './capacitor-ionic';
import { CapacitorState, pluginStateCards } from './capacitor-state';
import {
SLOW_MS,
Expand Down Expand Up @@ -54,7 +55,7 @@ const FILTER_LABELS: Record<CallFilter, string> = {

@Component({
selector: 'app-capacitor-inspector',
imports: [SearchField, LimitNote, Select, CapacitorCallDetail, CapacitorState],
imports: [SearchField, LimitNote, Select, CapacitorCallDetail, CapacitorIonic, CapacitorState],
template: `
@if (!loaded()) {
<p class="state" role="status">Loading Capacitor data…</p>
Expand Down Expand Up @@ -164,6 +165,8 @@ const FILTER_LABELS: Record<CallFilter, string> = {
</section>
}

<app-capacitor-ionic [rpc]="rpc()" [pageId]="page.pageId" />

<section aria-labelledby="cap-calls-title">
<h2 id="cap-calls-title">Plugin calls</h2>
@if (!page.info.bridge && !page.info.webCalls) {
Expand Down
Loading
Loading