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
767 changes: 723 additions & 44 deletions apps/datatug-app/e2e/demo-handoff.spec.ts

Large diffs are not rendered by default.

165 changes: 165 additions & 0 deletions apps/datatug-app/src/app/datatug-app-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
handoffOrRoot,
routes,
} from './datatug-app-routes';
import {
DEMO_HANDOFF_KEY,
DEMO_HANDOFF_STASH,
resetHandoffAskedForTests,
} from './demo-handoff-asked';
import { handoffUrlMatcher } from './demo-handoff-path';

// Task 13 (S108) — see this file's own header comment in datatug-app-routes.ts.
Expand Down Expand Up @@ -80,6 +85,8 @@ describe('DataTug app routes', () => {
.filter((r) => r.matcher)
.map((r) => ({ ...r, loadComponent: () => HandoffStub })),
{ path: '', pathMatch: 'full', component: HomeStub },
// What the datatug-main routes make of a short project address that the hand-off route does not take.
{ path: 'project/**', component: ProjectStub },
// The app shell's side menu: a named outlet with an empty path, which the router adds an empty group for.
{ path: '', outlet: 'menu', component: OtherStub },
{ path: '**', component: OtherStub },
Expand Down Expand Up @@ -126,6 +133,162 @@ describe('DataTug app routes', () => {
expect(result.url).toBe('/demo/other');
});

// The short project route and the holding page share the project chat address. Which one an address gets is
// decided from the query that index.html took out of the address bar (the router never sees it), and the
// demo flag is not consulted: until the chat can run a question, the holding page is the only way not to lose it.
describe('the project chat address: the holding page only when it arrived with a question', () => {
const stash = (search: string) => {
(window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH] = search;
};
beforeEach(() => {
resetHandoffAskedForTests();
delete (window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH];
window.sessionStorage.clear();
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH];
window.sessionStorage.clear();
resetHandoffAskedForTests();
});

it.each([
'/project/github.com/datatug/chinook-demo/chat',
'/project/github.com/Datatug/Chinook-Demo/chat',
'/project/github.com/datatug/chinook-demo/tree/HEAD/-/chat',
'/project/github.com/someone/else/chat',
'/project/github.com/datatug/chinook-demo/tree/abc123/-/chat',
])('%s: a question gets the holding page, no question gets the project', async (url) => {
for (const [search, expected] of [
['?msg=Hello', HandoffStub],
['?q=Hello&lang=ru', HandoffStub],
['?lang=ru', ProjectStub],
['?msg=', ProjectStub],
['?utm_source=x', ProjectStub],
] as const) {
TestBed.resetTestingModule();
resetHandoffAskedForTests();
stash(search);
expect((await visit('datatug.app', url)).component, search).toBe(expected);
}
TestBed.resetTestingModule();
resetHandoffAskedForTests();
delete (window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH];
expect((await visit('datatug.app', url)).component, 'no query').toBe(ProjectStub);
});

// review r2, B1: the script reads the path its own way, so a spelling of the address that only the router
// understands arrives with the question still in the navigation's query, and no stash.
describe('a question in the navigation query that the script did not take out (review r2, B1)', () => {
it.each([
'//project/github.com/datatug/chinook-demo/chat',
'//project/github.com/acme/demo/chat',
'///project/github.com/acme/demo/chat',
'/(project/github.com/datatug/chinook-demo/chat)',
'/(project/github.com/acme/demo/chat)',
'/(project/github.com/acme/demo/tree/HEAD/-/chat)',
'//project/github.com/acme/demo/tree/HEAD/-/chat',
'/project/github.com/acme/demo/Tree/HEAD/-/chat',
'/demo',
'//demo',
'/(demo)',
])('%s?msg=… is the holding page, and the question is stashed for it to capture', async (url) => {
const result = await visit('datatug.app', `${url}?msg=Q&lang=ru`);
expect(result.component).toBe(HandoffStub);
expect(
(window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH],
).toBe('?msg=Q&lang=ru');
});

it.each([
['?q=Q', '?q=Q'],
['?msg=A&msg=B', '?msg=A&msg=B'],
['?msg=a%20b%26c', '?msg=a+b%26c'],
])('the query %s is stashed as it was asked: %s', async (typed, stashed) => {
const result = await visit(
'datatug.app',
`//project/github.com/acme/demo/chat${typed}`,
);
expect(result.component).toBe(HandoffStub);
expect(
(window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH],
).toBe(stashed);
});

it.each([
'//project/github.com/acme/demo/chat',
'/(project/github.com/acme/demo/chat)',
])('%s with no question, or a blank one, is the project: the chat page', async (url) => {
for (const search of ['', '?msg=', '?lang=ru']) {
TestBed.resetTestingModule();
resetHandoffAskedForTests();
expect((await visit('datatug.app', url + search)).component, search).toBe(ProjectStub);
}
});

it('every other address under a project is the project, question or not: the short route drops the question', async () => {
for (const url of [
'//project/github.com/acme/demo/queries',
'/(project/github.com/acme/demo/queries)',
'/project/github.com/acme/demo/chat/more',
]) {
TestBed.resetTestingModule();
resetHandoffAskedForTests();
expect((await visit('datatug.app', url + '?msg=Q')).component, url).toBe(ProjectStub);
expect(
(window as unknown as Record<string, unknown>)[DEMO_HANDOFF_STASH],
url,
).toBeUndefined();
}
});

it('at app.incidentius.com the same addresses go to the root, with the query dropped', async () => {
const result = await visit(
'app.incidentius.com',
'//project/github.com/acme/demo/chat?msg=Q',
);
expect(result.component).toBe(HomeStub);
expect(result.url).toBe('/');
});
});

it('a reload of a question that was asked shows the holding page again; a fresh visit shows the project', async () => {
const url = '/project/github.com/datatug/chinook-demo/chat';
window.sessionStorage.setItem(DEMO_HANDOFF_KEY, url + '?msg=Hello');
expect((await visit('datatug.app', url)).component).toBe(HandoffStub);
});

it('every other address under a project is the project, question or not', async () => {
stash('?msg=Hello');
for (const url of [
'/project/github.com/datatug/chinook-demo',
'/project/github.com/datatug/chinook-demo/queries',
'/project/github.com/datatug/chinook-demo/tree/HEAD/dir/-/chat',
'/project/github.com/datatug/chinook-demo/chat/more',
]) {
TestBed.resetTestingModule();
expect((await visit('datatug.app', url)).component, url).toBe(ProjectStub);
}
});

it('/demo is the holding page with or without a question', async () => {
for (const search of ['?q=Hello', '?lang=ru', '']) {
TestBed.resetTestingModule();
stash(search);
expect((await visit('datatug.app', '/demo')).component, search).toBe(HandoffStub);
}
});

it('at app.incidentius.com every project chat address still goes to the root, with or without a question', async () => {
for (const search of ['?msg=Hello', '?lang=ru']) {
TestBed.resetTestingModule();
stash(search);
const result = await visit('app.incidentius.com', '/project/github.com/datatug/chinook-demo/chat');
expect(result.component, search).toBe(HomeStub);
expect(result.url).toBe('/');
}
});
});

it.each(['/demo(menu:x)', '/demo/(menu:x)', '/Demo(menu:x/y)'])(
"an address with an outlet group (%s) is handled by neither profile's hand-off route",
async (url) => {
Expand All @@ -144,5 +307,7 @@ describe('DataTug app routes', () => {
class HandoffStub {}
@Component({ selector: 'sneat-stub-home', template: '' })
class HomeStub {}
@Component({ selector: 'sneat-stub-project', template: '' })
class ProjectStub {}
@Component({ selector: 'sneat-stub-other', template: '' })
class OtherStub {}
32 changes: 28 additions & 4 deletions apps/datatug-app/src/app/datatug-app-routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { inject } from '@angular/core';
import { Router, Routes, UrlTree } from '@angular/router';
import { CanMatchFn, Router, Routes } from '@angular/router';
import { PRODUCT_PROFILE } from '@datatug/product-profiles';
import { cliChatCapability } from './cli-chat-capability';
import { searchOfQueryParams, showsHoldingPage } from './demo-handoff-asked';
import { handoffUrlMatcher } from './demo-handoff-path';

// Task 13 (S108, spec/research/2026-09-09-layered-acl-reconciliation.md,
Expand All @@ -18,10 +19,18 @@ import { handoffUrlMatcher } from './demo-handoff-path';
// chat's `?msg=` is the shape the hand-off is moving to. Until the live demo can answer a question, they show one
// holding page instead of failing to match any route, which opens Sentry's crash-report dialog and loses the
// visitor's question. No flag and no sign-in. demo-handoff-capture.ts explains how the question is taken out of
// the address bar before analytics starts, and which addresses may show it back (isEchoTrusted: `/demo` and the
// the address bar before analytics starts, and which addresses may show it back (isTrustedHandoff: `/demo` and the
// demo project's own chat; any other repository gets neutral wording and no question). Registered ahead of the
// root feature routes; every other path is matched exactly as before.
//
// Which addresses show the holding page (demo-handoff-asked.ts decides, from what index.html kept of the query):
// - `/demo`: always.
// - `/project/github.com/<owner>/<repo>[/tree/<ref>/-]/chat`: only when it arrived with a question (`msg`, or
// `q`). Without one it is the chat page of that project, opened at its short address like every other page of
// it (the short project route, in the datatug-main routes). The question is the only thing that has nowhere
// else to go until the chat can run it, so it is what decides.
// - the old form of a project address, and every other short address, are never hand-offs.
//
// One route with a matcher, not three `path`s: Angular's literal segments are case-sensitive, and `/Demo` must
// show the page too, as must `/demo;x=1` (the router ignores matrix parameters). demo-handoff-path.ts holds the
// rules, which index.html's inline script repeats so that the query is stripped for exactly these addresses.
Expand All @@ -35,8 +44,23 @@ const demoHoldingPage = () => import('./demo-holding-page.component').then((m) =
/** The hand-off page belongs to the DataTug product profile only. */
export const datatugProfileOnly = (): boolean => inject(PRODUCT_PROFILE).id === 'datatug';

/** `canMatch` of the hand-off route: matches under the DataTug profile, sends every other profile to the root. */
export const handoffOrRoot = (): boolean | UrlTree => datatugProfileOnly() || inject(Router).parseUrl('/');
/**
* `canMatch` of the hand-off route: under the DataTug profile the holding page for `/demo` and for a project chat
* address that arrived with a question, and no match (the router goes on to the project routes) for a project chat
* address without one; under every other profile, the root.
*/
export const handoffOrRoot: CanMatchFn = (_route, segments) => {
const router = inject(Router);
if (!datatugProfileOnly()) return router.parseUrl('/');
// The query the router has is normally empty (index.html's script took it out of the address bar before the
// router started). It is not when the script reads the path another way than the router does: then a question in
// it is as good as one the script stashed (demo-handoff-asked.ts).
return showsHoldingPage(
segments.map((segment) => segment.path),
undefined,
searchOfQueryParams(router.getCurrentNavigation()?.extractedUrl.queryParams ?? {}),
);
};

export const routes: Routes = [
{
Expand Down
17 changes: 12 additions & 5 deletions apps/datatug-app/src/app/demo-flag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { join } from 'node:path';
import { datatugDemoConfig as devConfig } from '../environments/environment';
import { datatugDemoConfig as prodConfig } from '../environments/environment.prod';
import { datatugDemoConfig as ssoConfig } from '../environments/environment.sso-e2e';
import { isEchoTrusted, isHandoffPath } from './demo-handoff-capture';
import { showsHoldingPage } from './demo-handoff-asked';
import { isTrustedHandoff, isHandoffPath } from './demo-handoff-capture';
import { routeSegments } from './demo-handoff-path';
import {
DEMO_ENABLED_OVERRIDE_KEY,
isDemoEnabled,
Expand Down Expand Up @@ -219,22 +221,27 @@ describe('the demo flag (G-F1)', () => {
'/store/x/project/y/chat',
'/',
];
const baseline = PATHS.map((p) => [isEchoTrusted(p), isHandoffPath(p)]);
const answers = (p: string) => [
isTrustedHandoff(p),
isHandoffPath(p),
// which page a hand-off address gets is not the flag's either (it is decided by whether a question was asked)
showsHoldingPage(routeSegments(p)),
];
const baseline = PATHS.map(answers);

it.each([[undefined], ['1'], ['0'], ['junk']])(
'the answers for every path are the same with the override at %j',
(override) => {
if (override !== undefined)
window.localStorage.setItem(DEMO_ENABLED_OVERRIDE_KEY, override);
expect(PATHS.map((p) => [isEchoTrusted(p), isHandoffPath(p)])).toEqual(
baseline,
);
expect(PATHS.map(answers)).toEqual(baseline);
},
);

it('the trust and hand-off code does not import the flag, and the flag does not import it', () => {
for (const file of [
'demo-handoff-capture.ts',
'demo-handoff-asked.ts',
'demo-handoff-path.ts',
'demo-holding-page.component.ts',
'datatug-app-routes.ts',
Expand Down
Loading
Loading