mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
a5109e7916
* feat(editor): enable mobile compose and env editing The mobile stack-detail Compose segment was read-only and told users to edit on desktop. Operators need to make small emergency edits from a phone, so the Compose segment now opens a full-screen editor for small, safe compose and .env changes. The editor is a lightweight monospace textarea rather than Monaco, sized for small corrections at common phone widths. It reuses the existing desktop save path from useStackActions and the global overlays, so every protection behaves the same: ETag conflict handling, diff preview when enabled, save-only, save-and-deploy, and the unsaved-changes guard. A compose/.env toggle appears when the stack has an env file, and the env-file picker is locked while edits are unsaved so switching files cannot drop them. Editing is gated by the same stack:edit permission as desktop. A footer note reminds users that mobile editing is for small changes and points large rewrites to desktop. The desktop Monaco editor is unchanged. * fix(editor): keep the mobile editor save target in sync with the shown buffer Two edge cases in the mobile compose/.env editor could silently drop an edit: - When the desktop editor was on the Files tab (or an env tab with no env file) and the viewport crossed into the mobile breakpoint, the editor showed the compose buffer while the shared active tab stayed on files, so a save quietly no-opped. Normalize the active tab to compose on the mobile surface so the visible edit always saves to the visible file. - The textarea stayed writable while an env-file switch was loading, so edits typed during the fetch were overwritten when it resolved. Make the textarea read-only while a file load is in flight. Adds unit tests for both normalizations and the read-only-during-load guard.
126 lines
5.3 KiB
TypeScript
126 lines
5.3 KiB
TypeScript
/**
|
|
* Mobile compose/.env editing (below the md breakpoint).
|
|
*
|
|
* Logs in and creates the stack at desktop width (the create dialog and stack
|
|
* list are desktop-driven), then resizes to a phone viewport so EditorView
|
|
* renders MobileStackDetail. Covers the acceptance-criteria flows: open on
|
|
* mobile, edit compose, save, save-and-deploy guard, and discard dirty changes.
|
|
* Phone widths exercised: 390px and 430px. The compose/.env toggle and env-file
|
|
* save share the same handlers and are covered by MobileStackDetail.test.tsx.
|
|
*/
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { loginAs, waitForStacksLoaded } from './helpers';
|
|
|
|
const TEST_STACK = 'e2e-mobile-edit-stack';
|
|
const PHONE_390 = { width: 390, height: 844 };
|
|
const PHONE_430 = { width: 430, height: 932 };
|
|
|
|
async function deleteTestStack(page: Page) {
|
|
await page.evaluate(async (name) => {
|
|
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => { });
|
|
}, TEST_STACK);
|
|
}
|
|
|
|
async function createTestStack(page: Page) {
|
|
await page.getByRole('button', { name: 'Create Stack' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
|
await page.locator('#create-stack-name').fill(TEST_STACK);
|
|
await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
|
|
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 });
|
|
}
|
|
|
|
// Open the freshly created stack in the editor (desktop), then drop to a phone
|
|
// viewport so the mobile detail surface renders, and select the Compose segment.
|
|
async function openComposeOnPhone(page: Page, viewport = PHONE_390) {
|
|
await page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true }).click();
|
|
await page.setViewportSize(viewport);
|
|
await page.getByRole('tab', { name: 'Compose' }).click();
|
|
}
|
|
|
|
async function openEditor(page: Page) {
|
|
await page.getByRole('button', { name: 'edit' }).click();
|
|
await expect(page.getByTestId('mobile-compose-editor')).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
test.describe('mobile stack editing', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page);
|
|
await waitForStacksLoaded(page);
|
|
await deleteTestStack(page);
|
|
await page.reload();
|
|
await loginAs(page);
|
|
await waitForStacksLoaded(page);
|
|
await createTestStack(page);
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await deleteTestStack(page);
|
|
});
|
|
|
|
test('edits and saves the compose file from a phone (390px)', async ({ page }) => {
|
|
await openComposeOnPhone(page);
|
|
await openEditor(page);
|
|
|
|
await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.27\n restart: always\n');
|
|
await page.getByTestId('mobile-editor-save').click();
|
|
|
|
await expect(page.getByText(/file saved successfully/i)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// The edit must actually reach disk, not just flash a toast.
|
|
const onDisk = await page.evaluate(async (name) => {
|
|
const res = await fetch(`/api/stacks/${name}`, { credentials: 'include' });
|
|
return res.text();
|
|
}, TEST_STACK);
|
|
expect(onDisk).toContain('nginx:1.27');
|
|
});
|
|
|
|
test('does not deploy when the save PUT fails', async ({ page }) => {
|
|
await page.route(`**/api/stacks/${TEST_STACK}`, async (route, req) => {
|
|
if (req.method() === 'PUT') {
|
|
await route.fulfill({ status: 500, body: 'forced save failure' });
|
|
return;
|
|
}
|
|
await route.continue();
|
|
});
|
|
let deployAttempts = 0;
|
|
await page.route(`**/api/stacks/${TEST_STACK}/deploy*`, async (route, req) => {
|
|
if (req.method() === 'POST') deployAttempts += 1;
|
|
await route.continue();
|
|
});
|
|
|
|
await openComposeOnPhone(page);
|
|
await openEditor(page);
|
|
await page.getByTestId('mobile-editor-save-deploy').click();
|
|
|
|
await expect(page.getByText(/failed to save file/i)).toBeVisible({ timeout: 5_000 });
|
|
await page.waitForTimeout(1_000);
|
|
expect(deployAttempts).toBe(0);
|
|
});
|
|
|
|
test('guards a dirty close and discards on confirm', async ({ page }) => {
|
|
await openComposeOnPhone(page);
|
|
await openEditor(page);
|
|
|
|
await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.28\n');
|
|
await page.getByTestId('mobile-editor-close').click();
|
|
|
|
const dialog = page.getByRole('alertdialog', { name: /discard unsaved changes/i });
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
await page.getByRole('button', { name: 'Discard changes' }).click();
|
|
|
|
// The editor closes back to the read-only Compose segment.
|
|
await expect(page.getByTestId('mobile-compose-editor')).toBeHidden();
|
|
await expect(page.getByRole('button', { name: 'edit' })).toBeVisible();
|
|
});
|
|
|
|
test('opens a usable editor at 430px', async ({ page }) => {
|
|
await openComposeOnPhone(page, PHONE_430);
|
|
await openEditor(page);
|
|
|
|
await expect(page.getByTestId('mobile-compose-editor')).toBeVisible();
|
|
await expect(page.getByTestId('mobile-editor-save')).toBeVisible();
|
|
await expect(page.getByTestId('mobile-editor-save-deploy')).toBeVisible();
|
|
});
|
|
});
|