mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 13:11:05 +00:00
8a9115f48c
The Fleet tab was called Deployments while every surface inside it said Blueprints, its empty state repeated a heading and offered two create buttons, and the populated catalog opened with a third naming of the same thing. The filter row had a Drifted chip that missed failed, conflicting, blocked and awaiting-confirmation Blueprints, and a tile showed only a coloured dot, never the state it stood for. Every refresh blanked the whole tab (closing an open sheet's context) behind a plain "Loading blueprints" line. The tab is now Blueprints. One header row carries the count and New Blueprint; the empty state is just its card with one create action. Needs attention replaces Drifted and covers failed, name conflict, evict blocked, awaiting confirmation, held repair, drift and a rollout that needs reconfirming; a tile names its strongest non-active state, and an empty filter offers Show all. The first load shows a skeleton shaped like the catalog, and only if it is slow. Later refreshes keep the catalog and any open sheet on screen; a failed refresh over a catalog shows a notice with Retry and the last list, and the error card is only for a tab with nothing to show. Overlapping refreshes are sequenced, and a failed refresh after creating a Blueprint no longer opens its sheet later. Docs and the e2e tab lookup follow the new name.
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
/**
|
|
* Convert a live Direct Git source onto a Blueprint, then detach.
|
|
*
|
|
* Soft-skips when the system git binary is unavailable. Uses the local
|
|
* fixture Git server so the suite does not depend on network egress.
|
|
*/
|
|
import { test, expect, Page } from '@playwright/test';
|
|
import { loginAs } from './helpers';
|
|
import { gitAvailable, buildFixtureRepo, serveRepos } from './gitServer.helper';
|
|
|
|
async function jsonRequest<T>(
|
|
page: Page,
|
|
url: string,
|
|
init: { method?: string; body?: unknown } = {},
|
|
): Promise<{ status: number; body: T }> {
|
|
return page.evaluate(async ({ url, method, payload }) => {
|
|
const res = await fetch(url, {
|
|
method,
|
|
credentials: 'include',
|
|
...(payload === null
|
|
? {}
|
|
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }),
|
|
});
|
|
return { status: res.status, body: await res.json() as T };
|
|
}, { url, method: init.method ?? 'GET', payload: init.body === undefined ? null : init.body });
|
|
}
|
|
|
|
test.describe('Blueprint Git-managed content binding', () => {
|
|
test.skip(!gitAvailable(), 'system git binary is not available');
|
|
|
|
let server: { url: string; close: () => void };
|
|
let stackName: string;
|
|
let blueprintName: string;
|
|
let blueprintId: number | null = null;
|
|
|
|
test.beforeAll(async () => {
|
|
server = await serveRepos({
|
|
app: buildFixtureRepo({ 'compose.yaml': 'services:\n web:\n image: nginx:alpine\n' }),
|
|
});
|
|
});
|
|
|
|
test.afterAll(() => {
|
|
server?.close();
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
const stamp = Date.now();
|
|
stackName = `e2e-bp-src-${stamp}`;
|
|
blueprintName = `e2e-bp-bind-${stamp}`;
|
|
blueprintId = null;
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
await page.evaluate(async ({ bpId, stack }) => {
|
|
if (bpId !== null) {
|
|
await fetch(`/api/blueprints/${bpId}/content-binding`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
}).catch(() => {});
|
|
await fetch(`/api/blueprints/${bpId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
}).catch(() => {});
|
|
}
|
|
await fetch(`/api/stacks/${stack}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
|
|
await fetch(`/api/stacks/${stack}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
|
|
}, { bpId: blueprintId, stack: stackName });
|
|
});
|
|
|
|
async function seedDirectSourceAndBlueprint(page: Page): Promise<{ applicationId: string; blueprintId: number }> {
|
|
const createStack = await jsonRequest(page, '/api/stacks', {
|
|
method: 'POST',
|
|
body: { stackName },
|
|
});
|
|
expect(createStack.status).toBe(200);
|
|
|
|
const saveStatus = await jsonRequest(page, `/api/stacks/${stackName}/git-source`, {
|
|
method: 'PUT',
|
|
body: {
|
|
repo_url: `${server.url}/app.git`,
|
|
branch: 'main',
|
|
compose_paths: ['compose.yaml'],
|
|
sync_env: false,
|
|
auth_type: 'none',
|
|
auto_apply_on_webhook: false,
|
|
auto_deploy_on_apply: false,
|
|
},
|
|
});
|
|
expect(saveStatus.status, JSON.stringify(saveStatus.body)).toBe(200);
|
|
|
|
let applicationId: string | null = null;
|
|
await expect.poll(async () => {
|
|
const revision = await jsonRequest<{ gitopsRevision?: { applicationId?: string | null } }>(
|
|
page,
|
|
`/api/stacks/${stackName}/git-source`,
|
|
);
|
|
applicationId = revision.body.gitopsRevision?.applicationId ?? null;
|
|
return applicationId;
|
|
}, { timeout: 2_500, intervals: [250] }).toBeTruthy();
|
|
if (!applicationId) {
|
|
throw new Error('Direct GitOps application should exist after save');
|
|
}
|
|
|
|
const created = await jsonRequest<{ id?: number; error?: string }>(page, '/api/blueprints', {
|
|
method: 'POST',
|
|
body: {
|
|
name: blueprintName,
|
|
compose_content: 'services:\n app:\n image: nginx:alpine\n',
|
|
selector: { type: 'labels', any: [], all: [] },
|
|
drift_mode: 'observe',
|
|
},
|
|
});
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.id).toEqual(expect.any(Number));
|
|
return { applicationId, blueprintId: created.body.id as number };
|
|
}
|
|
|
|
async function openBlueprintSheet(page: Page, name: string) {
|
|
await page.getByRole('button', { name: 'Fleet', exact: true }).click();
|
|
await page.getByRole('tab', { name: /Blueprints/i }).click();
|
|
const card = page.getByRole('button', { name: new RegExp(name) });
|
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
|
await card.click();
|
|
await expect(page.getByRole('heading', { name })).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
test('convert makes the editor read-only and blocked, detach restores Inline editing', async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
await loginAs(page);
|
|
const seeded = await seedDirectSourceAndBlueprint(page);
|
|
blueprintId = seeded.blueprintId;
|
|
|
|
const convert = await jsonRequest(page, `/api/blueprints/${seeded.blueprintId}/content-binding`, {
|
|
method: 'PUT',
|
|
body: { applicationId: seeded.applicationId },
|
|
});
|
|
expect(convert.status, JSON.stringify(convert.body)).toBe(200);
|
|
|
|
await openBlueprintSheet(page, blueprintName);
|
|
await expect(page.getByText('Git-managed', { exact: true }).first()).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Apply now' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Detach Git' })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Edit' }).click();
|
|
await expect(page.getByText('Git-managed source')).toBeVisible();
|
|
await expect(page.getByText("This Blueprint's content is Git-managed, so it cannot deploy from the stored snapshot.")).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
await page.getByRole('button', { name: 'Detach Git' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Detach Git-managed content' })).toBeVisible();
|
|
await expect(page.getByText('Detach restores the frozen Inline snapshot')).toBeVisible({ timeout: 10_000 });
|
|
await page.getByRole('button', { name: 'Detach', exact: true }).click();
|
|
|
|
await expect(page.getByText('Inline', { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByRole('button', { name: 'Convert to Git' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Apply now' })).toBeVisible();
|
|
});
|
|
});
|