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
76 changes: 76 additions & 0 deletions e2e-tests/fixtures/WorkspaceSaveConflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';

/**
* Page Object Model for the workspace save-conflict modal (`WorkspaceSaveConflictModal`),
* shown when a save is rejected by the optimistic-concurrency check (`412`). Covers both
* the "conflict" variant (the file changed underneath) and the "deleted" variant (the file
* was deleted/moved underneath). Selectors are scoped to the modal root.
*/
export class WorkspaceSaveConflict {
cancelButton!: Locator;
conflictTitle!: Locator;
deletedTitle!: Locator;
discardButton!: Locator;
mineHeader!: Locator;
modal!: Locator;
recreateButton!: Locator;
takeMineButton!: Locator;
takeTheirsButton!: Locator;
theirsHeader!: Locator;

constructor(public page: Page) {
this.updatePage(page);
}

async cancel(): Promise<void> {
await this.cancelButton.click();
await expect(this.modal).toBeHidden();
}

async discardAndClose(): Promise<void> {
await this.discardButton.click();
await expect(this.modal).toBeHidden();
}

async recreate(): Promise<void> {
await this.recreateButton.click();
await expect(this.modal).toBeHidden();
}

async takeMine(): Promise<void> {
await this.takeMineButton.click();
await expect(this.modal).toBeHidden();
}

async takeTheirs(): Promise<void> {
await this.takeTheirsButton.click();
await expect(this.modal).toBeHidden();
}

updatePage(page: Page): void {
this.page = page;
this.modal = page.locator('#modal-container');
this.conflictTitle = this.modal.getByText('This file was changed by someone else');
this.deletedTitle = this.modal.getByText('File deleted or moved');
this.theirsHeader = this.modal.getByText('Theirs (server)');
this.mineHeader = this.modal.getByText('Mine (your edits)');
this.takeTheirsButton = this.modal.getByRole('button', { name: 'Keep theirs' });
this.takeMineButton = this.modal.getByRole('button', { name: 'Keep mine' });
this.cancelButton = this.modal.getByRole('button', { name: 'Keep editing' });
this.recreateButton = this.modal.getByRole('button', { name: 'Recreate file' });
this.discardButton = this.modal.getByRole('button', { name: 'Discard & close' });
}

/** Wait for the "conflict" variant (read-only diff of theirs vs mine). */
async waitForConflict(): Promise<void> {
await expect(this.conflictTitle).toBeVisible({ timeout: 15000 });
await expect(this.theirsHeader).toBeVisible();
await expect(this.mineHeader).toBeVisible();
}

/** Wait for the "deleted/moved" variant (single read-only pane of mine). */
async waitForDeleted(): Promise<void> {
await expect(this.deletedTitle).toBeVisible({ timeout: 15000 });
}
}
233 changes: 233 additions & 0 deletions e2e-tests/tests/workspace-edit-protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import test, { expect } from '@playwright/test';
import { Dictionaries } from '../fixtures/Dictionaries.js';
import { Parcels } from '../fixtures/Parcels.js';
import { Workspace } from '../fixtures/Workspace.js';
import { WorkspaceSaveConflict } from '../fixtures/WorkspaceSaveConflict.js';
import { Workspaces } from '../fixtures/Workspaces.js';
import { setupTest, teardownTest, type BrowserSetupResult } from '../utilities/api.js';
import { generateRandomName } from '../utilities/helpers.js';

// Simultaneous-edit protection: a save is rejected (412) when the file changed underneath the
// editor since it was opened. We simulate the "other editor" with a second browser context for
// the same 'test' user editing the same workspace file.

let setup: BrowserSetupResult; // primary editor — the one under test
let setupOther: BrowserSetupResult; // concurrent editor — changes the file first

let dictionaries: Dictionaries;
let parcels: Parcels;
let workspaces: Workspaces;
let workspace: Workspace; // primary page
let otherWorkspace: Workspace; // concurrent page (same workspace)
let conflict: WorkspaceSaveConflict;
let workspaceId: string;
let workspaceName: string;

test.beforeAll(async ({ baseURL, browser }) => {
test.setTimeout(120000);

setup = await setupTest(browser, { model: false });
dictionaries = new Dictionaries(setup.page);
parcels = new Parcels(setup.page);
workspaces = new Workspaces(setup.page, parcels, baseURL);

await dictionaries.goto();
await dictionaries.createCommandDictionary();
await parcels.goto();
await parcels.createParcel(dictionaries.commandDictionaryName, baseURL);

await workspaces.goto();
workspaceId = await workspaces.createWorkspace();
workspaceName = workspaces.workspaceName;
workspace = new Workspace(setup.page, workspaceId, workspaceName, baseURL);
conflict = new WorkspaceSaveConflict(setup.page);

// Second context (same 'test' user) editing the same workspace concurrently.
setupOther = await setupTest(browser, { model: false });
otherWorkspace = new Workspace(setupOther.page, workspaceId, workspaceName, baseURL);

await workspace.goto();
});

test.afterAll(async () => {
await workspaces.goto();
await workspaces.deleteWorkspace(workspaceName);
await parcels.goto();
await parcels.deleteParcel();
await dictionaries.goto();
await dictionaries.deleteCommandDictionary();

await teardownTest(setup);
await teardownTest(setupOther);
});

/** Create a sequence file on the primary page and open it (capturing its base version). */
async function createAndOpenSequence(): Promise<string> {
const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seq`);
await workspace.searchForFileAndWait(sequenceName);
await workspace.clickFile(sequenceName);
await expect(workspace.saveSequenceButton).toBeVisible({ timeout: 10000 });
return sequenceName;
}

/** The concurrent editor opens the same file and saves a change, advancing the server version. */
async function otherEditorSaves(sequenceName: string, content: string): Promise<void> {
await otherWorkspace.goto();
await otherWorkspace.searchForFileAndWait(sequenceName);
await otherWorkspace.clickFile(sequenceName);
await expect(otherWorkspace.saveSequenceButton).toBeVisible({ timeout: 10000 });
await otherWorkspace.fillSequenceContent(content);
await otherWorkspace.saveSequence();
}

/** Edit on the primary page and click Save, expecting the concurrency check to reject it. */
async function editAndSaveExpectingConflict(content: string): Promise<void> {
await workspace.fillSequenceContent(content);
await expect(workspace.saveSequenceButton).toBeEnabled();
await workspace.saveSequenceButton.click();
}

test.describe.serial('Workspace simultaneous-edit protection', () => {
test('consecutive saves of the same file succeed (token round-trips on PUT)', async () => {
// No concurrent editor here: this confirms a save advances the stored token so the next
// save against the same editor does not spuriously conflict.
await createAndOpenSequence();

await workspace.fillSequenceContent('// first change');
await workspace.saveSequence();
await expect(workspace.saveSequenceButton).toBeDisabled();

await workspace.fillSequenceContent('// second change');
await workspace.saveSequence();
await expect(workspace.saveSequenceButton).toBeDisabled();
});

test('a stale save shows the conflict modal with a diff', async () => {
const sequenceName = await createAndOpenSequence();
await otherEditorSaves(sequenceName, '// their change');

await editAndSaveExpectingConflict('// my change');

await conflict.waitForConflict();

// Cancel leaves the document dirty (Save still enabled) and keeps the stale token.
await conflict.cancel();
await expect(workspace.saveSequenceButton).toBeEnabled();

// Resolve the still-dirty document so the next test starts from a clean editor:
// saving again re-checks the (still stale) token, then take theirs to rebase clean.
await workspace.saveSequenceButton.click();
await conflict.waitForConflict();
await conflict.takeTheirs();
await expect(workspace.saveSequenceButton).toBeDisabled();
});

test('"Keep theirs" rebases the editor and the next save succeeds', async () => {
const sequenceName = await createAndOpenSequence();
await otherEditorSaves(sequenceName, '// their change');

await editAndSaveExpectingConflict('// my change');
await conflict.waitForConflict();
await conflict.takeTheirs();

// Editor now reflects the server version; saving a fresh edit succeeds (token advanced,
// no immediate re-conflict).
await workspace.fillSequenceContent('// after taking theirs');
await workspace.saveSequence();
await expect(workspace.saveSequenceButton).toBeDisabled();
});

test('"Keep theirs" is undoable — Ctrl/Cmd+Z restores the discarded edits', async () => {
const sequenceName = await createAndOpenSequence();
await otherEditorSaves(sequenceName, '// their change');

await editAndSaveExpectingConflict('// my change');
await conflict.waitForConflict();
await conflict.takeTheirs();

// Right after taking theirs the editor shows the server version.
const editor = setup.page.locator('.cm-content').first();
await expect(editor).toContainText('// their change');

// The rebase is recorded in the undo history, so a single undo restores the discarded edits
// and the document goes dirty again (restored content differs from the new server baseline).
await editor.click();
await setup.page.keyboard.press('ControlOrMeta+z');
await expect(editor).toContainText('// my change');
await expect(workspace.saveSequenceButton).toBeEnabled();

// Leave a clean editor for the next serial test. "Keep theirs" already advanced the stored
// token to the server version, so this save succeeds without re-conflicting. Without it the
// dirty doc leaks: the next test's createAndOpenSequence auto-selects its new file, which pops
// a "Navigate Away" modal whose backdrop blocks the file-row click.
await workspace.saveSequence();
await expect(workspace.saveSequenceButton).toBeDisabled();
});

test('"Keep mine" saves the local version and the next save succeeds', async () => {
const sequenceName = await createAndOpenSequence();
await otherEditorSaves(sequenceName, '// their change');

await editAndSaveExpectingConflict('// my change');
await conflict.waitForConflict();
await conflict.takeMine();

// Force-overwrite saved successfully and advanced the token.
await workspace.waitForToast('Workspace File Saved Successfully');
await expect(workspace.saveSequenceButton).toBeDisabled();

await workspace.fillSequenceContent('// after taking mine');
await workspace.saveSequence();
await expect(workspace.saveSequenceButton).toBeDisabled();
});

test('Ctrl/Cmd+S is ignored while the conflict modal is open (no stacked save)', async () => {
const sequenceName = await createAndOpenSequence();
await otherEditorSaves(sequenceName, '// their change');

await editAndSaveExpectingConflict('// my change');
await conflict.waitForConflict();

// The global save shortcut must not fire another save / stack a second modal.
await setup.page.keyboard.press('ControlOrMeta+s');
await expect(conflict.takeMineButton).toHaveCount(1);
await expect(conflict.conflictTitle).toBeVisible();

await conflict.takeTheirs();
});

test('a file deleted underneath shows the deleted/moved variant — Recreate restores it', async () => {
const sequenceName = await createAndOpenSequence();

// Concurrent editor deletes the file out from under the open editor.
await otherWorkspace.goto();
await otherWorkspace.searchForFileAndWait(sequenceName);
await otherWorkspace.deleteFile(sequenceName);

await editAndSaveExpectingConflict('// my change after delete');
await conflict.waitForDeleted();
await conflict.recreate();

await workspace.waitForToast('Workspace File Saved Successfully');
// The recreated file is back in the tree.
await workspace.searchForFileAndWait(sequenceName);
await expect(workspace.getFileRow(sequenceName)).toBeVisible();
});

test('the deleted/moved variant — "Discard & close" closes the document', async () => {
const sequenceName = await createAndOpenSequence();

await otherWorkspace.goto();
await otherWorkspace.searchForFileAndWait(sequenceName);
await otherWorkspace.deleteFile(sequenceName);

await editAndSaveExpectingConflict('// my change after delete');
await conflict.waitForDeleted();
await conflict.discardAndClose();

// The unsaved edits are discarded — the editor is no longer dirty (Save disabled) and
// the discarded content is gone.
await expect(workspace.saveSequenceButton).toBeDisabled();
await expect(setup.page.locator('.cm-content').first()).not.toContainText('my change after delete');
});
});
24 changes: 22 additions & 2 deletions e2e-tests/tests/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,17 +603,31 @@ test.describe.serial('Workspace', () => {
});

test('Toggle file read-only and verify editor is locked', async () => {
// Create a sequence file to test with
const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seq`);
// Use the adaptation's input-sequence extension so the file opens as a sequence with the
// Selected Command panel (a plain `.seq` would open as generic text — no command panel).
const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seqN.txt`);
await workspace.searchForFileAndWait(sequenceName);
await workspace.clickFile(sequenceName);

// Wait for the editor to load and the file metadata banner to appear
await expect(workspace.readOnlyCheckbox).toBeVisible({ timeout: 10000 });

// Add a command so the Selected Command panel renders editable argument inputs.
await workspace.fillSequenceContent('C FSW_CMD_0 "ON" true 0.5');

// The Selected Command panel (right side) shows the command's argument editors;
// float_arg_0 renders as a numeric input (spinbutton) labeled by its arg name.
const commandArgInput = setup.page.getByRole('spinbutton', { name: 'float_arg_0' });
await expect(commandArgInput).toBeVisible({ timeout: 10000 });

// Verify the file is initially editable — title should NOT contain "(Read only)"
await expect(setup.page.getByText('(Read only)')).not.toBeVisible();
await expect(workspace.saveSequenceButton).toBeVisible();
// ...and the command argument inputs are interactive.
await expect(commandArgInput).toBeEnabled();

// Persist so the document is clean before toggling read-only / deleting later.
await workspace.saveSequence();

// Toggle read-only ON
await workspace.readOnlyCheckbox.click();
Expand All @@ -625,6 +639,10 @@ test.describe.serial('Workspace', () => {
// Verify the Save button is hidden (read-only files can't be saved)
await expect(workspace.saveSequenceButton).not.toBeVisible();

// Verify the Selected Command panel argument inputs are disabled while read-only —
// `EditorState.readOnly` alone wouldn't stop the form-builder from editing the document.
await expect(commandArgInput).toBeDisabled();

// Verify the editor rejects input — type something and confirm content didn't change
await workspace.sequenceEditor.click();
await setup.page.keyboard.type('this should not appear');
Expand All @@ -639,6 +657,8 @@ test.describe.serial('Workspace', () => {

// Verify the Save button reappears
await expect(workspace.saveSequenceButton).toBeVisible();
// ...and the command argument inputs are interactive again.
await expect(commandArgInput).toBeEnabled();

// Cleanup
await workspace.searchForFileAndWait(sequenceName);
Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading