Skip to content

Commit 22bb5fb

Browse files
authored
Add Output Language Handling to Sequence Editor (#1931)
* Refactor output format controls into separate OutputToolbar component - Add OutputToolbar component with output format selection, copy, download, and preview toggle buttons * Only show output panel when editing a file matching the adaptation's input extension * Use output language extensions when editing non-input sequence files - Add `isInputFile` prop to SequenceEditor to determine if the current file matches the adaptation's input extension - Apply input editor extensions only when editing an input file; otherwise, find and use the matching output format's extensions - Show output panel only when editing an input file with available output formats - Compute `activeFileIsInputSequence` in workspace page to distinguish input files from output files= * add missing declarations for variables * fix SequenceEditor to clear existing diagnostics when switching between languages * add e2e test data for sequence adaptation with multiple output languages * Add test for sequence adaptation with multiple output languages * Refactor e2e test to dynamically test all output languages from sequence adaptation file * Refactor conditional logic for command panel visibility and update empty state message to clarify adaptation requirement * Add support for .cjs file extension in dictionary upload for sequence adaptations * Update empty state message in command panel to distinguish between missing dictionary and missing adaptation command mapping
1 parent 7b14f95 commit 22bb5fb

12 files changed

Lines changed: 519 additions & 147 deletions

File tree

e2e-tests/data/sequence-adaptation-minimal.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
const minimalInputLanguage = {
1818
fileExtension: '.txt',
1919
name: 'Minimal Test Language',
20-
extension: [],
20+
getEditorExtension: () => {},
2121
};
2222

2323
// Minimal output language
2424
const minimalOutputLanguage = {
2525
fileExtension: '.json',
2626
name: 'JSON Output',
27-
extension: [],
27+
getEditorExtension: () => {},
2828

2929
// Convert output back to input format
3030
toInputFormat: function (output) {
@@ -37,7 +37,7 @@ const minimalOutputLanguage = {
3737
},
3838

3939
// Convert input to output format
40-
toOutputFormat: function (input, context, name) {
40+
toOutputFormat: function (input, _context, name) {
4141
// Uncomment the line below to test runtime errors:
4242
// throw new Error('Intentional adaptation error for testing');
4343

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
/**
4+
* Minimal Sequence Adaptation for Testing
5+
*
6+
* This is a bare-bones adaptation useful for testing adaptation loading,
7+
* error handling, and the workspace console.
8+
*
9+
* To test different error scenarios, you can:
10+
* 1. Remove `exports.adaptation` - tests "No adaptation export found" error
11+
* 2. Set `exports.adaptation = null` - tests null adaptation error
12+
* 3. Remove `input` property - tests invalid adaptation structure
13+
* 4. Throw an error in toOutputFormat - tests runtime errors
14+
*/
15+
16+
// Minimal input language - no grammar, just basic structure
17+
const minimalInputLanguage = {
18+
fileExtension: '.seqN.txt',
19+
name: 'Minimal Test Input Language',
20+
getEditorExtension: (_context, resources) => [
21+
resources.linter(() => {
22+
// Pass the syntax node as undefined as it's optional in the implementation
23+
return [];
24+
}),
25+
],
26+
};
27+
28+
// Minimal output language 1
29+
const minimalOutputLanguage1 = {
30+
fileExtension: '.rml',
31+
name: 'Language 1 Output',
32+
getEditorExtension: (_context, resources) => {
33+
return [
34+
resources.linter(() => {
35+
// Pass the syntax node as undefined as it's optional in the implementation
36+
return [{
37+
from: 0,
38+
to: 10,
39+
severity: "warning",
40+
message: "This is a warning lint for output language 1"
41+
}];
42+
}),
43+
];
44+
},
45+
// Convert output back to input format
46+
toInputFormat: function () {
47+
return 'This is the converted input for language 1'
48+
},
49+
50+
// Convert input to output format
51+
toOutputFormat: function () {
52+
return 'This is the output for language 1'
53+
},
54+
};
55+
56+
// Minimal output language 2
57+
const minimalOutputLanguage2 = {
58+
fileExtension: '.vml',
59+
name: 'Language 2 Output',
60+
getEditorExtension: (_context, resources) => {
61+
return [
62+
resources.linter(() => {
63+
// Pass the syntax node as undefined as it's optional in the implementation
64+
return [{
65+
from: 0,
66+
to: 10,
67+
severity: "warning",
68+
message: "This is a warning lint for output language 2"
69+
}];
70+
}),
71+
];
72+
},
73+
74+
// Convert output back to input format
75+
toInputFormat: function () {
76+
return 'This is the converted input for language 2';
77+
},
78+
79+
// Convert input to output format
80+
toOutputFormat: function () {
81+
return 'This is the output for language 2'
82+
},
83+
};
84+
85+
// The main adaptation export
86+
const adaptation = {
87+
input: minimalInputLanguage,
88+
outputs: [minimalOutputLanguage1, minimalOutputLanguage2],
89+
};
90+
91+
// Export the adaptation (required)
92+
exports.adaptation = adaptation;

e2e-tests/fixtures/Parcels.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@ import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-
33
import { filterAgGridTable } from '../utilities/helpers.js';
44

55
export class Parcels {
6-
closeButton: Locator;
7-
confirmModal: Locator;
8-
confirmModalDeleteButton: Locator;
9-
createButton: Locator;
10-
nameField: Locator;
11-
newButton: Locator;
12-
pageLoadingLocator: Locator;
6+
closeButton!: Locator;
7+
confirmModal!: Locator;
8+
confirmModalDeleteButton!: Locator;
9+
createButton!: Locator;
10+
nameField!: Locator;
11+
newButton!: Locator;
12+
pageLoadingLocator!: Locator;
1313
parcelName: string;
14-
table: Locator;
15-
tableRow: (parcelName: string) => Locator;
16-
tableRowDeleteButton: (parcelName: string) => Locator;
17-
tableRowParcelId: (parcelName: string) => Locator;
14+
table!: Locator;
15+
tableRow!: (parcelName: string) => Locator;
16+
tableRowDeleteButton!: (parcelName: string) => Locator;
17+
tableRowParcelId!: (parcelName: string) => Locator;
1818

1919
constructor(public page: Page) {
2020
this.parcelName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] });
@@ -95,6 +95,94 @@ export class Parcels {
9595
await this.pageLoadingLocator.waitFor({ state: 'detached' });
9696
}
9797

98+
async updateDictionarySelections({
99+
channelDictionaryName,
100+
commandDictionaryName,
101+
parameterDictionaryName,
102+
sequenceAdaptationName,
103+
}: {
104+
channelDictionaryName?: string;
105+
commandDictionaryName?: string;
106+
parameterDictionaryName?: string;
107+
sequenceAdaptationName?: string;
108+
}) {
109+
const parcelTableRow = this.page.locator(`.ag-row:has-text("${this.parcelName}")`);
110+
const parcelTableRowOpenButton = await this.page.locator(
111+
`.ag-row:has-text("${this.parcelName}") >> button[aria-label="Open Parcel"]`,
112+
);
113+
114+
parcelTableRow.hover();
115+
await parcelTableRowOpenButton.waitFor({ state: 'attached' });
116+
await parcelTableRowOpenButton.waitFor({ state: 'visible' });
117+
await expect(parcelTableRowOpenButton).toBeVisible();
118+
parcelTableRowOpenButton.click();
119+
120+
this.updatePage(this.page);
121+
await expect(this.page.getByText('Edit Parcel')).toBeVisible();
122+
123+
// Select command dictionary if provided
124+
if (commandDictionaryName) {
125+
const commandDictionaryTable = await this.page
126+
.getByRole('tabpanel')
127+
.filter({ hasText: 'Command Dictionaries' })
128+
.getByRole('treegrid');
129+
130+
await filterAgGridTable(this.page, commandDictionaryTable, commandDictionaryName);
131+
await commandDictionaryTable
132+
.getByRole('row')
133+
.filter({ hasText: commandDictionaryName })
134+
.getByRole('checkbox')
135+
.click();
136+
}
137+
138+
// Select channel dictionary if provided
139+
if (channelDictionaryName) {
140+
const channelDictionaryTable = await this.page
141+
.getByRole('tabpanel')
142+
.filter({ hasText: 'Channel Dictionaries' })
143+
.getByRole('treegrid');
144+
145+
await filterAgGridTable(this.page, channelDictionaryTable, channelDictionaryName);
146+
await channelDictionaryTable
147+
.getByRole('row')
148+
.filter({ hasText: channelDictionaryName })
149+
.getByRole('checkbox')
150+
.click();
151+
}
152+
153+
// Select parameter dictionary if provided
154+
if (parameterDictionaryName) {
155+
const parameterDictionaryTable = await this.page
156+
.getByRole('tabpanel')
157+
.filter({ hasText: 'Parameter Dictionaries' })
158+
.getByRole('treegrid');
159+
160+
await filterAgGridTable(this.page, parameterDictionaryTable, parameterDictionaryName);
161+
await parameterDictionaryTable
162+
.getByRole('row')
163+
.filter({ hasText: parameterDictionaryName })
164+
.getByRole('checkbox')
165+
.click();
166+
}
167+
168+
// Select sequence adaptation if provided
169+
if (sequenceAdaptationName) {
170+
const sequenceAdaptationTable = await this.page
171+
.getByRole('tabpanel')
172+
.filter({ hasText: 'Sequence Adaptations' })
173+
.getByRole('treegrid');
174+
175+
await filterAgGridTable(this.page, sequenceAdaptationTable, sequenceAdaptationName);
176+
await sequenceAdaptationTable
177+
.getByRole('row')
178+
.filter({ hasText: sequenceAdaptationName })
179+
.getByRole('checkbox')
180+
.click();
181+
}
182+
183+
await this.page.getByRole('button', { name: 'Save' }).click();
184+
}
185+
98186
updatePage(page: Page): void {
99187
this.page = page;
100188

e2e-tests/fixtures/Workspace.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class Workspace {
1717
metadataTabButton!: Locator;
1818
navButtonSequences!: Locator;
1919
navButtonSequencesMenu!: Locator;
20+
outputPanelCollapseButton!: Locator;
2021
pageLoadingLocatorWithData!: Locator;
2122
readOnlyCheckbox!: Locator;
2223
rightPanelCollapseButton!: Locator;
@@ -237,6 +238,17 @@ export class Workspace {
237238
await this.page.getByText('User metadata', { exact: true }).waitFor({ state: 'visible', timeout: 5000 });
238239
}
239240

241+
async openOutputPanel(): Promise<void> {
242+
const initialText = await this.outputPanelCollapseButton.textContent();
243+
await this.outputPanelCollapseButton.click();
244+
245+
if (initialText?.includes('Collapse Editor')) {
246+
await expect(this.outputPanelCollapseButton).toHaveText(/Expand Editor/);
247+
} else if (initialText?.includes('Expand Editor')) {
248+
await expect(this.outputPanelCollapseButton).toHaveText(/Collapse Editor/);
249+
}
250+
}
251+
240252
async openWorkspaceContextMenu(): Promise<void> {
241253
await this.workspaceContextMenuButton.click();
242254
await this.workspaceHeaderMenu.waitFor({ state: 'attached' });
@@ -289,6 +301,7 @@ export class Workspace {
289301
this.navButtonSequences = page.locator('.nav-button:has-text("Sequences")');
290302
this.navButtonSequencesMenu = this.navButtonSequences.getByRole('menu');
291303
this.page = page;
304+
this.outputPanelCollapseButton = page.getByRole('button', { name: /Collapse Editor|Expand Editor/ });
292305
this.pageLoadingLocatorWithData = page.getByText('Loading workspace').first();
293306
this.readOnlyCheckbox = page.locator('#read-only');
294307
this.rightPanelCollapseButton = page.getByRole('button', { name: /Collapse panel|Expand panel/ }).last();
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import test, { expect } from '@playwright/test';
2+
import { getWorkspacesUrl } from '../../src/utilities/routes.js';
3+
import { Dictionaries } from '../fixtures/Dictionaries.js';
4+
import { Parcels } from '../fixtures/Parcels.js';
5+
import { Workspace } from '../fixtures/Workspace.js';
6+
import { Workspaces } from '../fixtures/Workspaces.js';
7+
import { setupTest, teardownTest, type BrowserSetupResult } from '../utilities/api.js';
8+
import { generateRandomName } from '../utilities/helpers.js';
9+
10+
// Load the CommonJS module dynamically
11+
const adaptationModule = await import('../data/sequence-adaptation-multiple-output.cjs');
12+
const adaptation = adaptationModule.default.adaptation;
13+
14+
// Main setup (uses default 'test' user)
15+
let setup: BrowserSetupResult;
16+
let dictionaries: Dictionaries;
17+
let parcels: Parcels;
18+
let sequence: { sequenceName: string; sequencePath: string };
19+
let workspace: Workspace;
20+
let workspaces: Workspaces;
21+
let workspaceId: string;
22+
let workspaceName: string;
23+
24+
test.beforeAll(async ({ baseURL, browser }) => {
25+
// Increase global timeout to prevent early test termination
26+
test.setTimeout(60000); // 60 seconds
27+
28+
setup = await setupTest(browser, { model: false });
29+
30+
dictionaries = new Dictionaries(setup.page);
31+
parcels = new Parcels(setup.page);
32+
workspaces = new Workspaces(setup.page, parcels, baseURL);
33+
34+
// Setup dependencies: dictionary and parcel
35+
await dictionaries.goto();
36+
await dictionaries.createCommandDictionary();
37+
await dictionaries.createSequenceAdaptation(undefined, 'e2e-tests/data/sequence-adaptation-multiple-output.cjs');
38+
await parcels.goto();
39+
await parcels.createParcel(dictionaries.commandDictionaryName, baseURL);
40+
await parcels.updateDictionarySelections({
41+
sequenceAdaptationName: dictionaries.sequenceAdaptationName,
42+
});
43+
44+
// Create a workspace for testing
45+
await workspaces.goto();
46+
workspaceId = await workspaces.createWorkspace();
47+
workspaceName = workspaces.workspaceName;
48+
49+
// Initialize workspace fixture
50+
workspace = new Workspace(setup.page, workspaceId, workspaceName, baseURL);
51+
workspace.updatePage(setup.page);
52+
await workspace.goto();
53+
54+
// Setup workspace
55+
sequence = await workspace.createSequence(undefined, `${generateRandomName()}${adaptation.input.fileExtension}`);
56+
await workspace.searchForFileAndWait(sequence.sequenceName);
57+
await workspace.clickFile(sequence.sequenceName);
58+
59+
await expect(setup.page).toHaveURL(
60+
getWorkspacesUrl(
61+
workspace.baseURL,
62+
parseInt(workspace.workspaceId),
63+
`${sequence.sequencePath}/${sequence.sequenceName}`,
64+
),
65+
);
66+
67+
// Make changes
68+
await workspace.fillSequenceContent('// New content');
69+
70+
// Verify save button is now enabled (unsaved changes detected)
71+
await expect(workspace.saveSequenceButton).toBeEnabled();
72+
73+
// Save and verify button is disabled again
74+
await workspace.saveSequence();
75+
76+
await workspace.openOutputPanel();
77+
});
78+
79+
test.afterAll(async () => {
80+
// Cleanup: delete workspace, parcel, and dictionary
81+
await workspaces.goto();
82+
await workspaces.deleteWorkspace(workspaceName);
83+
await parcels.goto();
84+
await parcels.deleteParcel();
85+
86+
await teardownTest(setup);
87+
});
88+
89+
for (let i = 0; i < adaptation.outputs.length; i++) {
90+
const output = adaptation.outputs[i];
91+
const languageOutput = output.toOutputFormat();
92+
93+
test.describe.serial('Workspace with sequence adaptation with multiple output languages', () => {
94+
test(`Convert input to ${output.name}`, async () => {
95+
const outputEditor = workspace.page.getByTestId('output-editor');
96+
97+
await workspace.page.getByRole('combobox', { name: 'Output Format' }).selectOption({ index: i });
98+
99+
// Validate that the output editor contains the correct output for the selected adaptation output language
100+
await expect(outputEditor).toContainText(languageOutput);
101+
});
102+
103+
test(`Copy the output for ${output.name}`, async () => {
104+
// Grant clipboard permissions for this test
105+
await setup.context.grantPermissions(['clipboard-read', 'clipboard-write']);
106+
107+
await workspace.page.getByRole('button', { name: 'Copy as' }).click();
108+
109+
// Read from clipboard and verify
110+
const clipboardText = await setup.page.evaluate(() => navigator.clipboard.readText());
111+
112+
// Validate that the clipboard contains the correct output for the selected adaptation output language
113+
expect(clipboardText).toContain(languageOutput);
114+
});
115+
});
116+
}

0 commit comments

Comments
 (0)