feat: add routable browser URLs for stacks and shell views (#1586)

* feat: add routable browser URLs for stacks and shell views

Sync in-memory navigation to the address bar via a History API hook so
deep links, refresh, Back/Forward, and bookmarks work across nodes,
views, stack editor tabs, and mobile surfaces. Gate role/tier URL
normalization on permissions and license readiness, preserve URLs on
metadata fetch failure, and surface retryable stack-list errors without
rewriting pending stack paths.

* fix: preserve deep-link views on cold load and refresh

Stop the node-switch effect from resetting to dashboard on initial mount.

Defer URL writer settlement until hydrated activeView matches the route.

Adds E2E coverage for shell cold loads, stack refresh, and compose env tab.

* fix: keep mobile dashboard on list surface so sidebar renders

On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the
content surface, hiding the stack list sidebar. This prevented the
data-stacks-loaded sentinel from appearing, causing sidebar truncation
E2E tests to time out after reload on a mobile viewport.

Mobile dashboard now stays on the list surface; other non-editor views
still render on the content surface.

* fix: complete mobile URL routing follow-ups for stack deep links

Restore mobile /dashboard vs /stacks, list surface always writes /stacks.

Hydrate pendingDetailStack, freeze compose failures with routeDetailError,

and add unit plus E2E coverage.

* fix: hydrate shell views from URL and sync in-app navigation

Bootstrap activeView and tab state from the pathname on cold load.

Settle route phase when state already matches, normalize unknown segments,

and open Monaco editor tabs from stack deep links via applyEditorRouteState.

* fix: prevent mobile stack deep links from hanging on cold load

The resolvePendingStack effect did not re-fire when the pending stack ref
was populated during URL hydration, because the urlHydratingStack state
set in the same callback was not listed in the effect's dependency array.
Adding it causes the effect to retry once hydration has committed.

A resolvingRef mutex prevents concurrent invocations. When the target file
is already loaded, route state is applied directly without calling
loadFileForRoute, which avoids unmounting the editor (and hiding the
recovery chip) if a background refresh triggers route resolution during
a deploy operation.

* test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs

* ci: raise E2E Playwright job timeout to 20 minutes
This commit is contained in:
Anso
2026-07-08 13:07:16 -04:00
committed by GitHub
parent 0c37d18586
commit 5fe0843eb2
35 changed files with 2263 additions and 199 deletions
+7 -2
View File
@@ -382,12 +382,17 @@ test.describe('Deploy feedback modal', () => {
await expect.poll(() => opCalls, { timeout: 15_000 }).toBeGreaterThanOrEqual(2);
// Mobile shell (below the md break) renders the recovery card inline.
// Open from the stack list so URL hydration does not drop us into the
// full-screen compose editor surface (the /compose deep link does that).
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await page.goto('/nodes/local/stacks');
await waitForStacksLoaded(page);
await page.getByText(HAPPY_STACK, { exact: true }).first().click();
await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('stack-deploy-button')).toBeEnabled({ timeout: 10_000 });
await page.getByTestId('stack-deploy-button').click();
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Deploy failed')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 10_000 });
});
test.afterEach(async ({ page }) => {
+195
View File
@@ -0,0 +1,195 @@
/**
* Browser URL routing E2E tests.
* Verifies deep links, navigation sync, refresh persistence, and Back/Forward.
*/
import { test, expect } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
async function firstStackName(page: import('@playwright/test').Page): Promise<string | null> {
const row = page.locator('[role="listbox"] [role="option"]').first();
if (!(await row.isVisible().catch(() => false))) return null;
const text = await row.textContent();
return text?.trim() ?? null;
}
test.describe('URL routing', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await waitForStacksLoaded(page);
});
test('cold load of /nodes/local/dashboard lands on home', async ({ page }) => {
await page.goto('/nodes/local/dashboard');
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/local\/dashboard/);
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible();
});
test('cold load of shell views preserves the URL and mounts the view', async ({ page }) => {
await page.goto('/nodes/local/fleet');
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/local\/fleet/);
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible({ timeout: 15_000 });
await page.goto('/nodes/local/security/images');
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/local\/security\/images/);
await expect(page.getByRole('tab', { name: 'Images' })).toBeVisible();
await page.goto('/nodes/local/resources');
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/local\/resources/);
await expect(page.getByRole('tab', { name: 'Images' })).toBeVisible();
});
test('top-level navigation updates the address bar', async ({ page }) => {
await page.getByRole('button', { name: 'Resources', exact: true }).click();
await expect(page).toHaveURL(/\/nodes\/local\/resources/);
await page.getByRole('button', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/nodes\/local\/dashboard/);
});
test('opening a stack writes a stack editor URL', async ({ page }) => {
const stackName = await firstStackName(page);
test.skip(!stackName, 'No stacks available to open');
await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click();
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`));
});
test('refresh preserves a stack editor deep link', async ({ page }) => {
const stackName = await firstStackName(page);
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
await waitForStacksLoaded(page);
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug}|${stackName}`));
await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible();
await page.reload();
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/local\/stacks\//);
await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible();
});
test('compose editor env tab updates the URL', async ({ page }) => {
const stackName = await firstStackName(page);
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click();
await expect(page).toHaveURL(/\/compose/);
await page.getByRole('button', { name: 'edit', exact: true }).click();
const envTab = page.getByRole('tab', { name: '.env' });
test.skip(!(await envTab.isEnabled()), 'Stack has no .env file');
await envTab.click();
await expect(page).toHaveURL(/\/env/);
});
test('browser Back returns to the prior view', async ({ page }) => {
await page.getByRole('button', { name: 'Resources', exact: true }).click();
await expect(page).toHaveURL(/\/nodes\/local\/resources/);
await page.getByRole('button', { name: 'Security', exact: true }).click();
await expect(page).toHaveURL(/\/nodes\/local\/security/);
await page.goBack();
await expect(page).toHaveURL(/\/nodes\/local\/resources/);
});
test('root path redirects into a node dashboard', async ({ page }) => {
await page.goto('/');
await waitForStacksLoaded(page);
await expect(page).toHaveURL(/\/nodes\/[^/]+\/dashboard/);
});
});
test.describe('URL routing (mobile)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await page.setViewportSize({ width: 390, height: 844 });
});
test('cold load of stack deep link opens mobile detail', async ({ page }) => {
const stackName = await page.evaluate(async () => {
const res = await fetch('/api/stacks', { credentials: 'include' });
const stacks = await res.json() as string[];
return stacks[0] ?? null;
});
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`));
await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('tab', { name: 'Health' })).toBeVisible();
});
test('reload preserves mobile stack deep link', async ({ page }) => {
const stackName = await page.evaluate(async () => {
const res = await fetch('/api/stacks', { credentials: 'include' });
const stacks = await res.json() as string[];
return stacks[0] ?? null;
});
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 });
await page.reload();
await expect(page).toHaveURL(/\/nodes\/local\/stacks\//);
await expect(page.getByRole('tab', { name: 'Health' })).toBeVisible({ timeout: 15_000 });
});
test('stack list API failure shows retryable mobile error and preserves URL', async ({ page }) => {
const stackName = await page.evaluate(async () => {
const res = await fetch('/api/stacks', { credentials: 'include' });
const stacks = await res.json() as string[];
return stacks[0] ?? null;
});
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.route('**/api/stacks', async (route) => {
if (route.request().method() === 'GET' && !route.request().url().includes(`/api/stacks/${encodeURIComponent(stackName!)}`)) {
await route.fulfill({ status: 500, body: 'list failure' });
return;
}
await route.continue();
});
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`));
});
test('compose API failure shows retryable mobile error and preserves URL', async ({ page }) => {
const stackName = await page.evaluate(async () => {
const res = await fetch('/api/stacks', { credentials: 'include' });
const stacks = await res.json() as string[];
return stacks[0] ?? null;
});
test.skip(!stackName, 'No stacks available to open');
const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await page.route(`**/api/stacks/${encodeURIComponent(stackName!)}`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 500, body: 'compose failure' });
return;
}
await route.continue();
});
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({ timeout: 15_000 });
await expect(page).not.toHaveURL(/\/dashboard/);
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/compose`));
});
test('Stacks tab from Fleet updates URL to stack list', async ({ page }) => {
await page.goto('/nodes/local/fleet');
await expect(page).toHaveURL(/\/nodes\/local\/fleet/);
await page.getByRole('button', { name: 'Stacks', exact: true }).click();
await expect(page).toHaveURL(/\/nodes\/local\/stacks$/);
});
});
+7 -5
View File
@@ -263,8 +263,12 @@ test.describe('Sidebar stack name truncation', () => {
test('active row with a long name still truncates', async ({ page }) => {
const row = page.locator('[data-testid="stack-row"]').filter({ hasText: LONG_STACK });
await row.click();
await expect(row).toHaveClass(/bg-accent/);
const slug = LONG_STACK.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
await Promise.all([
page.waitForURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)),
row.click(),
]);
await expect(row).toHaveClass(/bg-accent/, { timeout: 10_000 });
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
});
@@ -312,13 +316,11 @@ test.describe('Sidebar stack name truncation', () => {
test('mobile viewport keeps long names truncated', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await page.goto('/nodes/local/stacks');
await waitForStacksLoaded(page);
const metrics = await measureRow(page, LONG_STACK);
expect(metrics.nameTruncated).toBe(true);
expect(metrics.rowWithinSidebar).toBe(true);
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible();
});
});
+29 -17
View File
@@ -366,19 +366,16 @@ test.describe('Stack file explorer: symlink semantics', () => {
test.describe('Stack file explorer: UI lifecycle', () => {
test.setTimeout(90_000);
/** Open the Files tab on the seeded stack. Mirrors openFilesTab in stack-files.spec.ts. */
function stackSlug(stackName: string): string {
return stackName.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
}
/** Open the Files tab via the routable stack editor URL. */
async function openFilesTab(page: Page): Promise<void> {
const slug = stackSlug(STACK);
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/files`);
await waitForStacksLoaded(page);
const stackInSidebar = page.getByText(STACK, { exact: true }).first();
if (!await stackInSidebar.isVisible().catch(() => false)) {
await page.reload();
await loginAs(page);
await waitForStacksLoaded(page);
}
await page.getByText(STACK, { exact: true }).first().click();
const filesBtn = page.getByTestId('anatomy-files-btn');
await expect(filesBtn).toBeVisible({ timeout: 8_000 });
await filesBtn.click();
await expect(page.getByRole('button', { name: 'New file' })).toBeVisible({ timeout: 10_000 });
await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 });
}
@@ -409,7 +406,6 @@ test.describe('Stack file explorer: UI lifecycle', () => {
);
expect(renameRes.status()).toBe(204);
await page.reload();
await openFilesTab(page);
await expect(
page.locator('span.font-mono').filter({ hasText: /^lifecycle-rename-dst\.txt$/ }).first(),
@@ -424,7 +420,6 @@ test.describe('Stack file explorer: UI lifecycle', () => {
);
expect(res.status()).toBe(204);
await page.reload();
await openFilesTab(page);
await expect(
page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(),
@@ -442,10 +437,21 @@ test.describe('Stack file explorer: UI lifecycle', () => {
).toBeVisible({ timeout: 8_000 });
});
test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page }) => {
await fs.writeFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'original\n');
await page.reload();
test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page, request }) => {
const cookie = await cookieHeader(page);
const seedRes = await request.put(
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
{
headers: { cookie, 'Content-Type': 'application/json' },
data: { content: 'original\n' },
},
);
expect(seedRes.ok()).toBeTruthy();
await openFilesTab(page);
await expect(
page.locator('span.font-mono').filter({ hasText: /^dup-guard\.txt$/ }).first(),
).toBeVisible({ timeout: 8_000 });
await page.getByRole('button', { name: 'New file' }).click();
await page.getByLabel('File name').fill('dup-guard.txt');
@@ -453,7 +459,13 @@ test.describe('Stack file explorer: UI lifecycle', () => {
await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 });
// The server rejected the create, so the existing contents are intact.
expect(await fs.readFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'utf-8')).toBe('original\n');
const verifyRes = await request.get(
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
{ headers: { cookie } },
);
expect(verifyRes.ok()).toBeTruthy();
const payload = await verifyRes.json() as { content?: string };
expect(payload.content).toBe('original\n');
});
test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => {