mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
e2001fed72
* feat: open compose editor in edit mode with one click Remove the desktop double edit gate so Anatomy "Edit compose" and empty-stack create land in an immediately editable workspace. Close discards both compose and env buffers; deferred loads keep startInComposeEdit through unsaved confirm. * docs: align compose editor close behavior and Edit compose labels Update stack-management and editor docs for one-click edit mode. Close discards unsaved compose/env edits. Anatomy shortcuts use Edit compose. * fix(e2e): update routing test to use anatomy-edit-compose-btn test id The compose editor env tab routing test was written against the old 'edit' button name. The single-compose-edit-gate branch renamed that button to 'Edit compose' with test id 'anatomy-edit-compose-btn'. Update the selector to match.
215 lines
9.8 KiB
TypeScript
215 lines
9.8 KiB
TypeScript
/**
|
|
* 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 detail 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, '');
|
|
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`));
|
|
await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible();
|
|
});
|
|
|
|
test('refresh preserves a stack detail 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, '');
|
|
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}`);
|
|
await waitForStacksLoaded(page);
|
|
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`));
|
|
await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible();
|
|
await page.reload();
|
|
await waitForStacksLoaded(page);
|
|
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`));
|
|
await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible();
|
|
});
|
|
|
|
test('refresh preserves a compose 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, '');
|
|
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`);
|
|
await waitForStacksLoaded(page);
|
|
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/compose`));
|
|
await page.reload();
|
|
await waitForStacksLoaded(page);
|
|
await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/compose`));
|
|
});
|
|
|
|
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(new RegExp(`/nodes/local/stacks/`));
|
|
await expect(page).not.toHaveURL(/\/compose$/);
|
|
|
|
await page.getByTestId('anatomy-edit-compose-btn').click();
|
|
await expect(page).toHaveURL(/\/compose/);
|
|
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$/);
|
|
});
|
|
});
|