diff --git a/cypress/e2e/laboratory/_cy.ts b/cypress/e2e/laboratory/_cy.ts new file mode 100644 index 0000000000..61ce189fd3 --- /dev/null +++ b/cypress/e2e/laboratory/_cy.ts @@ -0,0 +1,95 @@ +import { setMonacoEditorContents } from '../../support/monaco'; + +export namespace cyLaboratory { + /** + * Updates the value of the graphiql editor + */ + export function updateEditorValue(value: string) { + cy.get('.graphiql-query-editor .cm-s-graphiql').then($editor => { + const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance + editor.setValue(value); + }); + } + + /** + * Returns the value of the graphiql editor as Chainable + */ + export function getEditorValue() { + return cy.get('.graphiql-query-editor .cm-s-graphiql').then($editor => { + const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance + return editor.getValue(); + }); + } + + /** + * Opens a new tab + */ + export function openNewTab() { + cy.get('button[aria-label="New tab"]').click(); + // tab's title should be "untitled" as it's a default name + cy.contains('button[aria-controls="graphiql-session"]', 'untitled').should('exist'); + } + + /** + * Asserts that the tab with the given name is active + */ + export function assertActiveTab(name: string) { + cy.contains('li.graphiql-tab-active > button[aria-controls="graphiql-session"]', name).should( + 'exist', + ); + } + + /** + * Closes the active tab + */ + export function closeActiveTab() { + cy.get('li.graphiql-tab-active > button.graphiql-tab-close').click(); + } + + /** + * Closes all tabs until one is left + */ + export function closeTabsUntilOneLeft() { + cy.get('li.graphiql-tab').then($tabs => { + if ($tabs.length > 1) { + closeActiveTab(); + // Recurse until there's only one tab left + return closeTabsUntilOneLeft(); + } + }); + } + + export namespace preflight { + export const selectors = { + buttonGraphiQLPreflight: '[aria-label*="Preflight Script"]', + buttonModalCy: 'preflight-modal-button', + buttonToggleCy: 'toggle-preflight', + buttonHeaders: '[data-name="headers"]', + headersEditor: { + textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea', + }, + graphiql: { + buttonExecute: '.graphiql-execute-button', + }, + + modal: { + buttonSubmitCy: 'preflight-modal-submit', + editorCy: 'preflight-editor', + variablesEditorCy: 'env-editor', + }, + }; + /** + * Sets the content of the preflight editor + */ + export const setEditorContent = (value: string) => { + setMonacoEditorContents(selectors.modal.editorCy, value); + }; + + /** + * Sets the content of the variables editor + */ + export const setEnvironmentEditorContent = (value: string) => { + setMonacoEditorContents(selectors.modal.variablesEditorCy, value); + }; + } +} diff --git a/cypress/e2e/laboratory-collections.cy.ts b/cypress/e2e/laboratory/collections.cy.ts similarity index 91% rename from cypress/e2e/laboratory-collections.cy.ts rename to cypress/e2e/laboratory/collections.cy.ts index 265ef18d4e..086aa410b5 100644 --- a/cypress/e2e/laboratory-collections.cy.ts +++ b/cypress/e2e/laboratory/collections.cy.ts @@ -1,4 +1,4 @@ -import { laboratory } from '../support/testkit'; +import { cyLaboratory } from './_cy'; beforeEach(() => { cy.clearAllLocalStorage().then(() => { @@ -16,7 +16,7 @@ beforeEach(() => { .first() .click(); cy.get('[aria-label="Show Operation Collections"]').click(); - laboratory.closeTabsUntilOneLeft(); + cyLaboratory.closeTabsUntilOneLeft(); }); }); }); @@ -90,7 +90,7 @@ describe('Laboratory > Collections', () => { name: 'collection-1', description: 'Description 1', }); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', @@ -103,7 +103,7 @@ describe('Laboratory > Collections', () => { name: 'collection-1', description: 'Description 1', }); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', @@ -127,7 +127,7 @@ describe('Laboratory > Collections', () => { name: 'collection-1', description: 'Description 1', }); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', @@ -151,7 +151,7 @@ describe('Laboratory > Collections', () => { name: 'collection-1', description: 'Description 1', }); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', @@ -173,14 +173,14 @@ describe('Laboratory > Collections', () => { name: 'collection-1', description: 'Description 1', }); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', }); - laboratory.openNewTab(); - laboratory.updateEditorValue(`query op2 { test }`); + cyLaboratory.openNewTab(); + cyLaboratory.updateEditorValue(`query op2 { test }`); collections.saveCurrentOperationAs({ name: 'operation-2', collectionName: 'collection-1', @@ -206,14 +206,14 @@ describe('Laboratory > Collections', () => { description: 'Description 2', }); collections.clickCollectionButton('collection-1'); - laboratory.updateEditorValue(`query op1 { test }`); + cyLaboratory.updateEditorValue(`query op1 { test }`); collections.saveCurrentOperationAs({ name: 'operation-1', collectionName: 'collection-1', }); - laboratory.openNewTab(); - laboratory.updateEditorValue(`query op2 { test }`); + cyLaboratory.openNewTab(); + cyLaboratory.updateEditorValue(`query op2 { test }`); collections.saveCurrentOperationAs({ name: 'operation-2', collectionName: 'collection-2', @@ -243,7 +243,7 @@ describe('Laboratory > Collections', () => { return cy.visit(copiedUrl); }); - laboratory.assertActiveTab('operation-1'); - laboratory.getEditorValue().should('contain', 'op1'); + cyLaboratory.assertActiveTab('operation-1'); + cyLaboratory.getEditorValue().should('contain', 'op1'); }); }); diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory/preflight.cy.ts similarity index 78% rename from cypress/e2e/laboratory-preflight.cy.ts rename to cypress/e2e/laboratory/preflight.cy.ts index 645dd3931f..2518df23a4 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory/preflight.cy.ts @@ -1,21 +1,9 @@ -import { dedent } from '../support/testkit'; - -const selectors = { - buttonGraphiQLPreflight: '[aria-label*="Preflight Script"]', - buttonModalCy: 'preflight-modal-button', - buttonToggleCy: 'toggle-preflight', - buttonHeaders: '[data-name="headers"]', - headersEditor: { - textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea', - }, - graphiql: { - buttonExecute: '.graphiql-execute-button', - }, - - modal: { - buttonSubmitCy: 'preflight-modal-submit', - }, -}; +import { dedent } from '../../support/dedent'; +import { cyLaboratory } from './_cy'; + +const s = cyLaboratory.preflight.selectors; + +const cyp = cyLaboratory.preflight; const data: { slug: string } = { slug: '', @@ -27,40 +15,17 @@ beforeEach(() => { cy.setCookie('sRefreshToken', refreshToken); data.slug = slug; cy.visit(`/${slug}/laboratory`); - cy.get(selectors.buttonGraphiQLPreflight).click(); + cy.get(s.buttonGraphiQLPreflight).click(); }); }); }); -/** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ -function setMonacoEditorContents(editorCyName: string, text: string) { - // wait for textarea appearing which indicates monaco is loaded - cy.dataCy(editorCyName).find('textarea'); - cy.window().then(win => { - // First, check if monaco is available on the main window - const editor = (win as any).monaco.editor - .getEditors() - .find(e => e.getContainerDomNode().parentElement.getAttribute('data-cy') === editorCyName); - - // If Monaco instance is found - if (editor) { - editor.setValue(text); - } else { - throw new Error('Monaco editor not found on the window or frames[0]'); - } - }); -} - -function setEditorScript(script: string) { - setMonacoEditorContents('preflight-editor', script); -} - -describe('Laboratory > Preflight Script', () => { +describe('Preflight Tab', () => { // https://github.com/graphql-hive/console/pull/6450 it('regression: loads even if local storage is set to {}', () => { window.localStorage.setItem('hive:laboratory:environment', '{}'); cy.visit(`/${data.slug}/laboratory`); - cy.get(selectors.buttonGraphiQLPreflight).click(); + cy.get(s.buttonGraphiQLPreflight).click(); }); it('mini script editor is read only', () => { cy.dataCy('toggle-preflight').click(); @@ -76,17 +41,17 @@ describe('Laboratory > Preflight Script', () => { }); }); -describe('Preflight Script Modal', () => { +describe('Preflight Modal', () => { const script = 'console.log("Hello_world")'; const env = '{"foo":123}'; beforeEach(() => { cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents('env-editor', env); + cyp.setEnvironmentEditorContent(env); }); it('save script and environment variables when submitting', () => { - setEditorScript(script); + cyp.setEditorContent(script); cy.dataCy('preflight-modal-submit').click(); cy.dataCy('env-editor-mini').should('have.text', env); cy.dataCy('toggle-preflight').click(); @@ -98,11 +63,11 @@ describe('Preflight Script Modal', () => { }); it('logs show console/error information', () => { - setEditorScript(script); + cyp.setEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyp.setEditorContent( `console.info(1) console.warn(true) console.error('Fatal') @@ -120,12 +85,12 @@ throw new TypeError('Test')`, }); it('prompt and pass the awaited response', () => { - setEditorScript(script); + cyp.setEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyp.setEditorContent( dedent` const username = await lab.prompt('Enter your username'); console.info(username); @@ -148,12 +113,12 @@ throw new TypeError('Test')`, }); it('prompt and cancel', () => { - setEditorScript(script); + cyp.setEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyp.setEditorContent( dedent` const username = await lab.prompt('Enter your username'); console.info(username); @@ -176,8 +141,7 @@ throw new TypeError('Test')`, }); it('script execution updates environment variables', () => { - setEditorScript(`lab.environment.set('my-test', "TROLOLOL")`); - + cyp.setEditorContent(`lab.environment.set('my-test', "TROLOLOL")`); cy.dataCy('run-preflight').click(); cy.dataCy('env-editor').should( 'include.text', @@ -187,7 +151,7 @@ throw new TypeError('Test')`, }); it('`crypto-js` can be used for generating hashes', () => { - setEditorScript('console.log(lab.CryptoJS.SHA256("🐝"))'); + cyp.setEditorContent('console.log(lab.CryptoJS.SHA256("🐝"))'); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'info: Using crypto-js version:'); cy.dataCy('console-output').should( @@ -197,13 +161,13 @@ throw new TypeError('Test')`, }); it('scripts can not use `eval`', () => { - setEditorScript('eval()'); + cyp.setEditorContent('eval()'); cy.dataCy('preflight-modal-submit').click(); cy.get('body').contains('Usage of dangerous statement like eval() or Function("").'); }); it('invalid code is rejected and can not be saved', () => { - setEditorScript('🐝'); + cyp.setEditorContent('🐝'); cy.dataCy('preflight-modal-submit').click(); cy.get('body').contains("[1:1]: Illegal character '}"); }); @@ -215,13 +179,13 @@ describe('Execution', () => { const preflightHeaders = { foo: 'bar', }; - cy.dataCy(selectors.buttonToggleCy).click(); - cy.dataCy(selectors.buttonModalCy).click(); - setEditorScript(`lab.request.headers.append('foo', '${preflightHeaders.foo}')`); - cy.dataCy(selectors.modal.buttonSubmitCy).click(); + cy.dataCy(s.buttonToggleCy).click(); + cy.dataCy(s.buttonModalCy).click(); + cyp.setEditorContent(`lab.request.headers.append('foo', '${preflightHeaders.foo}')`); + cy.dataCy(s.modal.buttonSubmitCy).click(); // Run GraphiQL cy.intercept({ headers: preflightHeaders }).as('request'); - cy.get(selectors.graphiql.buttonExecute).click(); + cy.get(s.graphiql.buttonExecute).click(); cy.wait('@request'); }); @@ -233,19 +197,19 @@ describe('Execution', () => { accept: 'application/json, multipart/mixed', }; cy.intercept({ headers: baseHeaders }).as('integrityCheck'); - cy.get(selectors.graphiql.buttonExecute).click(); + cy.get(s.graphiql.buttonExecute).click(); cy.wait('@integrityCheck'); // Setup Preflight Script const preflightHeaders = { accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', }; - cy.dataCy(selectors.buttonToggleCy).click(); - cy.dataCy(selectors.buttonModalCy).click(); - setEditorScript(`lab.request.headers.append('accept', '${preflightHeaders.accept}')`); - cy.dataCy(selectors.modal.buttonSubmitCy).click(); + cy.dataCy(s.buttonToggleCy).click(); + cy.dataCy(s.buttonModalCy).click(); + cyp.setEditorContent(`lab.request.headers.append('accept', '${preflightHeaders.accept}')`); + cy.dataCy(s.modal.buttonSubmitCy).click(); // Run GraphiQL cy.intercept({ headers: preflightHeaders }).as('request'); - cy.get(selectors.graphiql.buttonExecute).click(); + cy.get(s.graphiql.buttonExecute).click(); cy.wait('@request'); }); @@ -255,8 +219,8 @@ describe('Execution', () => { const staticHeaders = { foo_static: barEnVarInterpolation, }; - cy.get(selectors.buttonHeaders).click(); - cy.get(selectors.headersEditor.textArea).type(JSON.stringify(staticHeaders), { + cy.get(s.buttonHeaders).click(); + cy.get(s.headersEditor.textArea).type(JSON.stringify(staticHeaders), { force: true, parseSpecialCharSequences: false, }); @@ -267,13 +231,13 @@ describe('Execution', () => { const preflightHeaders = { foo_preflight: barEnVarInterpolation, }; - cy.dataCy(selectors.buttonToggleCy).click(); - cy.dataCy(selectors.buttonModalCy).click(); - setEditorScript(` + cy.dataCy(s.buttonToggleCy).click(); + cy.dataCy(s.buttonModalCy).click(); + cyp.setEditorContent(` lab.environment.set('bar', '${environmentVariables.bar}') lab.request.headers.append('foo_preflight', '${preflightHeaders.foo_preflight}') `); - cy.dataCy(selectors.modal.buttonSubmitCy).click(); + cy.dataCy(s.modal.buttonSubmitCy).click(); // Run GraphiQL cy.intercept({ headers: { @@ -281,7 +245,7 @@ describe('Execution', () => { foo_static: environmentVariables.bar, }, }).as('request'); - cy.get(selectors.graphiql.buttonExecute).click(); + cy.get(s.graphiql.buttonExecute).click(); cy.wait('@request'); }); @@ -323,7 +287,7 @@ describe('Execution', () => { }, ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents('preflight-editor', `lab.environment.set('foo', '92')`); + cyp.setEditorContent(`lab.environment.set('foo', '92')`); cy.dataCy('preflight-modal-submit').click(); cy.intercept({ @@ -350,8 +314,7 @@ describe('Execution', () => { ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyp.setEditorContent( dedent` const username = await lab.prompt('Enter your username'); lab.environment.set('username', username); @@ -383,8 +346,8 @@ describe('Execution', () => { }, ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents('preflight-editor', `lab.environment.set('foo', 92)`); - setMonacoEditorContents('env-editor', `{"foo":10}`); + cyp.setEditorContent(`lab.environment.set('foo', 92)`); + cyp.setEnvironmentEditorContent(`{"foo":10}`); cy.dataCy('preflight-modal-submit').click(); @@ -402,8 +365,7 @@ describe('Execution', () => { cy.dataCy('toggle-preflight').click(); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyp.setEditorContent( dedent` console.info(1) console.warn(true) @@ -447,8 +409,7 @@ describe('Execution', () => { cy.dataCy('toggle-preflight').click(); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyp.setEditorContent( dedent` console.info(1) console.warn(true) diff --git a/cypress/e2e/laboratory-tabs.cy.ts b/cypress/e2e/laboratory/tabs.cy.ts similarity index 56% rename from cypress/e2e/laboratory-tabs.cy.ts rename to cypress/e2e/laboratory/tabs.cy.ts index 1f68000cc5..ea063e0c63 100644 --- a/cypress/e2e/laboratory-tabs.cy.ts +++ b/cypress/e2e/laboratory/tabs.cy.ts @@ -1,4 +1,4 @@ -import { laboratory } from '../support/testkit'; +import { cyLaboratory } from './_cy'; beforeEach(() => { cy.clearAllLocalStorage().then(() => { @@ -16,21 +16,21 @@ describe('Laboratory > Tabs', () => { const op2 = 'query { tab2 }'; // make sure there's only one tab - laboratory.closeTabsUntilOneLeft(); - laboratory.updateEditorValue(op1); - laboratory.getEditorValue().should('eq', op1); + cyLaboratory.closeTabsUntilOneLeft(); + cyLaboratory.updateEditorValue(op1); + cyLaboratory.getEditorValue().should('eq', op1); // open a new tab and update its value - laboratory.openNewTab(); - laboratory.updateEditorValue(op2); - laboratory.getEditorValue().should('eq', op2); + cyLaboratory.openNewTab(); + cyLaboratory.updateEditorValue(op2); + cyLaboratory.getEditorValue().should('eq', op2); // close the second tab - laboratory.closeActiveTab(); - laboratory.getEditorValue().should('eq', op1); + cyLaboratory.closeActiveTab(); + cyLaboratory.getEditorValue().should('eq', op1); // close the first tab - laboratory.closeActiveTab(); + cyLaboratory.closeActiveTab(); // it should reset the editor to its default state - laboratory.getEditorValue().should('not.eq', op1); + cyLaboratory.getEditorValue().should('not.eq', op1); }); }); diff --git a/cypress/support/dedent.ts b/cypress/support/dedent.ts new file mode 100644 index 0000000000..52ae660c88 --- /dev/null +++ b/cypress/support/dedent.ts @@ -0,0 +1,52 @@ +export function dedent(strings: TemplateStringsArray, ...values: unknown[]): string { + // Took from https://github.com/dmnd/dedent + // Couldn't use the package because I had some issues with moduleResolution. + const raw = strings.raw; + + // first, perform interpolation + let result = ''; + for (let i = 0; i < raw.length; i++) { + let next = raw[i]; + + // handle escaped newlines, backticks, and interpolation characters + next = next + .replace(/\\\n[ \t]*/g, '') + .replace(/\\`/g, '`') + .replace(/\\\$/g, '$') + .replace(/\\\{/g, '{'); + + result += next; + + if (i < values.length) { + result += values[i]; + } + } + + // now strip indentation + const lines = result.split('\n'); + let mindent: null | number = null; + for (const l of lines) { + const m = l.match(/^(\s+)\S+/); + if (m) { + const indent = m[1].length; + if (!mindent) { + // this is the first indented line + mindent = indent; + } else { + mindent = Math.min(mindent, indent); + } + } + } + + if (mindent !== null) { + const m = mindent; // appease TypeScript + result = lines.map(l => (l[0] === ' ' || l[0] === '\t' ? l.slice(m) : l)).join('\n'); + } + + // dedent eats leading and trailing whitespace too + result = result.trim(); + // handle escaped newlines at the end to ensure they don't get stripped too + result = result.replace(/\\n/g, '\n'); + + return result; +} diff --git a/cypress/support/monaco.ts b/cypress/support/monaco.ts new file mode 100644 index 0000000000..475c3fb28f --- /dev/null +++ b/cypress/support/monaco.ts @@ -0,0 +1,22 @@ +import type * as Monaco from 'monaco-editor'; + +/** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ +export function setMonacoEditorContents(editorCyName: string, text: string) { + // wait for textarea appearing which indicates monaco is loaded + cy.dataCy(editorCyName).find('textarea'); + cy.window().then((win: Window & typeof globalThis & { monaco: typeof Monaco }) => { + // First, check if monaco is available on the main window + const editor = win.monaco.editor + .getEditors() + .find(e => e.getContainerDomNode().parentElement.getAttribute('data-cy') === editorCyName); + + // If Monaco instance is found + if (editor) { + editor.setValue(text); + // Force a change event to be triggered + cy.dataCy(editorCyName).find('textarea').type(' {backspace}', { force: true }); + } else { + throw new Error('Monaco editor not found on the window or frames[0]'); + } + }); +} diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index 44f20faae2..10e09ca0d6 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -37,102 +37,3 @@ export function createProject(projectSlug: string) { cy.get('form[data-cy="create-project-form"] [data-cy="slug"]').type(projectSlug); cy.get('form[data-cy="create-project-form"] [data-cy="submit"]').click(); } - -export const laboratory = { - /** - * Updates the value of the graphiql editor - */ - updateEditorValue(value: string) { - cy.get('.graphiql-query-editor .cm-s-graphiql').then($editor => { - const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance - editor.setValue(value); - }); - }, - /** - * Returns the value of the graphiql editor as Chainable - */ - getEditorValue() { - return cy.get('.graphiql-query-editor .cm-s-graphiql').then($editor => { - const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance - return editor.getValue(); - }); - }, - openNewTab() { - cy.get('button[aria-label="New tab"]').click(); - // tab's title should be "untitled" as it's a default name - cy.contains('button[aria-controls="graphiql-session"]', 'untitled').should('exist'); - }, - /** - * Asserts that the tab with the given name is active - */ - assertActiveTab(name: string) { - cy.contains('li.graphiql-tab-active > button[aria-controls="graphiql-session"]', name).should( - 'exist', - ); - }, - closeActiveTab() { - cy.get('li.graphiql-tab-active > button.graphiql-tab-close').click(); - }, - closeTabsUntilOneLeft() { - cy.get('li.graphiql-tab').then($tabs => { - if ($tabs.length > 1) { - laboratory.closeActiveTab(); - // Recurse until there's only one tab left - return laboratory.closeTabsUntilOneLeft(); - } - }); - }, -}; - -export function dedent(strings: TemplateStringsArray, ...values: unknown[]): string { - // Took from https://github.com/dmnd/dedent - // Couldn't use the package because I had some issues with moduleResolution. - const raw = strings.raw; - - // first, perform interpolation - let result = ''; - for (let i = 0; i < raw.length; i++) { - let next = raw[i]; - - // handle escaped newlines, backticks, and interpolation characters - next = next - .replace(/\\\n[ \t]*/g, '') - .replace(/\\`/g, '`') - .replace(/\\\$/g, '$') - .replace(/\\\{/g, '{'); - - result += next; - - if (i < values.length) { - result += values[i]; - } - } - - // now strip indentation - const lines = result.split('\n'); - let mindent: null | number = null; - for (const l of lines) { - const m = l.match(/^(\s+)\S+/); - if (m) { - const indent = m[1].length; - if (!mindent) { - // this is the first indented line - mindent = indent; - } else { - mindent = Math.min(mindent, indent); - } - } - } - - if (mindent !== null) { - const m = mindent; // appease TypeScript - result = lines.map(l => (l[0] === ' ' || l[0] === '\t' ? l.slice(m) : l)).join('\n'); - } - - // dedent eats leading and trailing whitespace too - result = result.trim(); - // handle escaped newlines at the end to ensure they don't get stripped too - result = result.replace(/\\n/g, '\n'); - - return result; -} diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index f545e65eae..f14244b41a 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "target": "es2021", "lib": ["es2021", "dom"], - "types": ["node", "cypress"] + "moduleResolution": "node", + "types": ["node", "cypress", "monaco-editor"] }, "include": ["**/*.ts", "../integration-tests/testkit/**/*.ts"] } diff --git a/package.json b/package.json index 3773cbd104..0de006a786 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "graphql": "16.9.0", "gray-matter": "4.0.3", "jest-snapshot-serializer-raw": "2.0.0", + "monaco-editor": "0.52.2", "pg": "8.13.1", "prettier": "3.4.2", "prettier-plugin-sql": "0.18.1", diff --git a/packages/web/app/src/lib/MonacoEditorReact/index.ts b/packages/web/app/src/lib/MonacoEditorReact/index.ts new file mode 100644 index 0000000000..f8adcb7bde --- /dev/null +++ b/packages/web/app/src/lib/MonacoEditorReact/index.ts @@ -0,0 +1,5 @@ +import * as MonacoEditorReact from '@monaco-editor/react'; + +// Story book does not support re-export namespace syntax. +// eslint-disable-next-line unicorn/prefer-export-from +export { MonacoEditorReact }; diff --git a/packages/web/app/src/lib/preflight/components/EditorTitle.tsx b/packages/web/app/src/lib/preflight/components/EditorTitle.tsx new file mode 100644 index 0000000000..3525462904 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/EditorTitle.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export function EditorTitle(props: { children: ReactNode; className?: string }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/web/app/src/lib/preflight/components/EnvironmentEditor.tsx b/packages/web/app/src/lib/preflight/components/EnvironmentEditor.tsx new file mode 100644 index 0000000000..05f3d24626 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/EnvironmentEditor.tsx @@ -0,0 +1,25 @@ +import { Editor, EditorProps } from '@monaco-editor/react'; +import { defaultEditorProps } from './_defaultEditorProps'; + +export const defaultProps: Readonly = { + ...defaultEditorProps, + defaultLanguage: 'json', + options: { + ...defaultEditorProps.options, + lineNumbers: 'off', + tabSize: 2, + }, +}; + +export const EnvironmentEditor: React.FC = props => { + return ( + + ); +}; diff --git a/packages/web/app/src/lib/preflight/components/LogLine.tsx b/packages/web/app/src/lib/preflight/components/LogLine.tsx new file mode 100644 index 0000000000..54c46252a9 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/LogLine.tsx @@ -0,0 +1,29 @@ +import { captureException } from '@sentry/react'; +import { LogRecord } from '../shared-types'; + +export function LogLine({ log }: { log: LogRecord }) { + if ('type' in log && log.type === 'separator') { + return
; + } + + if ('level' in log && log.level in LOG_COLORS) { + return ( +
+ {log.level}: {log.message} + {log.line && log.column ? ` (${log.line}:${log.column})` : ''} +
+ ); + } + + captureException(new Error('Unexpected log type in Preflight Script output'), { + extra: { log }, + }); + return null; +} + +const LOG_COLORS = { + error: 'text-red-400', + info: 'text-emerald-400', + warn: 'text-yellow-400', + log: 'text-gray-400', +}; diff --git a/packages/web/app/src/lib/preflight/components/PreflightModal.tsx b/packages/web/app/src/lib/preflight/components/PreflightModal.tsx new file mode 100644 index 0000000000..ac8921ecd6 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/PreflightModal.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Cross2Icon, InfoCircledIcon, TriangleRightIcon } from '@radix-ui/react-icons'; +import { LogRecord, PreflightWorkerState } from '../shared-types'; +import { EditorTitle } from './EditorTitle'; +import { EnvironmentEditor } from './EnvironmentEditor'; +import { LogLine } from './LogLine'; +import { ScriptEditor } from './ScriptEditor'; + +export interface PreflightModalEditorValue { + script: string; + environmentVariables: string; +} + +export function PreflightModal({ + isOpen, + toggle, + onSubmit, + state, + execute, + abortExecution, + logs, + clearLogs, + value, + onChange, +}: { + onSubmit?: (values: PreflightModalEditorValue) => void; + isOpen: boolean; + toggle: () => void; + state: PreflightWorkerState; + execute: (script: string) => void; + abortExecution: () => void; + logs: Array; + clearLogs: () => void; + value: PreflightModalEditorValue; + onChange?: (value: PreflightModalEditorValue) => void; +}) { + const [scriptValue, setScriptValue] = useState(value.script); + const [environmentVariablesValue, setEnvironmentVariablesValue] = useState(value.environmentVariables); // prettier-ignore + const consoleRef = useRef(null); + + // It is possible for the script, upon running, to change the environment variables. + // We need to update the local state of the environment variables value when this happens. + useEffect(() => { + setEnvironmentVariablesValue(value.environmentVariables); + }, [value.environmentVariables]); + + const handleSave = () => { + onSubmit?.({ + script: scriptValue, + environmentVariables: environmentVariablesValue, + }); + toggle(); + }; + + useEffect(() => { + const consoleEl = consoleRef.current; + consoleEl?.scroll({ top: consoleEl.scrollHeight, behavior: 'smooth' }); + }, [logs]); + + return ( + { + if (!open) { + abortExecution(); + } + toggle(); + }} + > + { + // prevent pressing escape in monaco to close the modal + if (ev.target instanceof HTMLTextAreaElement) { + ev.preventDefault(); + } + }} + > + + Edit your Preflight Script + + This script will run in each user's browser and be stored in plain text on our servers. + Don't share any secrets here. +
+ All team members can view the script and toggle it off when they need to. +
+
+
+
+
+ + Script Editor + + JavaScript + + + +
+ { + const value_ = value ?? ''; + setScriptValue(value_); + onChange?.({ + script: value_, + environmentVariables: environmentVariablesValue, + }); + }} + options={{ + wordWrap: 'wordWrapColumn', + }} + wrapperProps={{ + ['data-cy']: 'preflight-editor', + }} + /> +
+
+
+ Console Output + +
+
+ {logs.map((log, index) => ( + + ))} +
+ + Environment Variables + + JSON + + + { + const value_ = value ?? ''; + setEnvironmentVariablesValue(value_); + onChange?.({ + script: scriptValue, + environmentVariables: value_, + }); + }} + options={{ + wordWrap: 'wordWrapColumn', + }} + wrapperProps={{ + ['data-cy']: 'env-editor', + }} + /> +
+
+ +

+ + Changes made to this Preflight Script will apply to all users on your team using this + target. +

+ + +
+
+
+ ); +} diff --git a/packages/web/app/src/lib/preflight/components/ScriptEditor.tsx b/packages/web/app/src/lib/preflight/components/ScriptEditor.tsx new file mode 100644 index 0000000000..bd297f0340 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/ScriptEditor.tsx @@ -0,0 +1,42 @@ +import { MonacoEditorReact } from '@/lib/MonacoEditorReact'; +import { Editor, EditorProps } from '@monaco-editor/react'; +import labApiDefinitionRaw from '../lab-api-declaration?raw'; +import { defaultEditorProps } from './_defaultEditorProps'; + +export const defaultProps: Readonly = { + ...defaultEditorProps, + beforeMount: (monaco: MonacoEditorReact.Monaco) => { + // Add custom typings for globalThis + monaco.languages.typescript.javascriptDefaults.addExtraLib( + ` + ${labApiDefinitionRaw} + declare const lab: LabAPI; + `, + 'global.d.ts', + ); + }, + defaultLanguage: 'javascript', + language: 'javascript', + options: { + ...defaultEditorProps.options, + quickSuggestions: true, + suggestOnTriggerCharacters: true, + acceptSuggestionOnEnter: 'on', + tabCompletion: 'on', + folding: true, + foldingStrategy: 'indentation', + }, +}; + +export const ScriptEditor: React.FC = props => { + return ( + + ); +}; diff --git a/packages/web/app/src/lib/preflight/components/_defaultEditorProps.ts b/packages/web/app/src/lib/preflight/components/_defaultEditorProps.ts new file mode 100644 index 0000000000..15fa444236 --- /dev/null +++ b/packages/web/app/src/lib/preflight/components/_defaultEditorProps.ts @@ -0,0 +1,17 @@ +import clsx from 'clsx'; +import { MonacoEditorReact } from '@/lib/MonacoEditorReact'; + +export const defaultEditorProps: Readonly = { + theme: 'vs-dark', + className: clsx('*:bg-[#10151f]'), + options: { + minimap: { enabled: false }, + padding: { + top: 10, + }, + scrollbar: { + horizontalScrollbarSize: 6, + verticalScrollbarSize: 6, + }, + }, +}; diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index 30fb4f595a..23675fa7ec 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -1,44 +1,25 @@ -import { - ComponentPropsWithoutRef, - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; import { clsx } from 'clsx'; import { PowerIcon } from 'lucide-react'; -import type { editor } from 'monaco-editor'; import { useMutation } from 'urql'; -import { z } from 'zod'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Subtitle } from '@/components/ui/page'; -import { usePromptManager } from '@/components/ui/prompt'; import { useToast } from '@/components/ui/use-toast'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { useLocalStorage, useLocalStorageJson, useToggle } from '@/lib/hooks'; +import { graphql } from '@/gql'; +import { useToggle } from '@/lib/hooks'; import { GraphiQLPlugin } from '@graphiql/react'; -import { Editor as MonacoEditor, OnMount, type Monaco } from '@monaco-editor/react'; -import { Cross2Icon, InfoCircledIcon, Pencil1Icon, TriangleRightIcon } from '@radix-ui/react-icons'; -import { captureException } from '@sentry/react'; +import { Pencil1Icon } from '@radix-ui/react-icons'; import { useParams } from '@tanstack/react-router'; -import { Kit } from '../kit'; import { cn } from '../utils'; -import labApiDefinitionRaw from './lab-api-declaration?raw'; -import { IFrameEvents, LogMessage } from './shared-types'; +import { EditorTitle } from './components/EditorTitle'; +import { EnvironmentEditor } from './components/EnvironmentEditor'; +import { PreflightModal, PreflightModalEditorValue } from './components/PreflightModal'; +import { ScriptEditor } from './components/ScriptEditor'; +import { usePreflightContext } from './hooks/usePreflightContext'; -export type PreflightResultData = Omit; +const classes = { + monacoMini: clsx('h-32 *:rounded-md *:bg-[#10151f]'), +}; export const preflightPlugin: GraphiQLPlugin = { icon: () => ( @@ -56,58 +37,9 @@ export const preflightPlugin: GraphiQLPlugin = { ), title: 'Preflight Script', - content: PreflightContent, -}; - -const classes = { - monaco: clsx('*:bg-[#10151f]'), - monacoMini: clsx('h-32 *:rounded-md *:bg-[#10151f]'), - icon: clsx('absolute -left-5 top-px'), + content, }; -function EditorTitle(props: { children: ReactNode; className?: string }) { - return ( -
- {props.children} -
- ); -} - -const sharedMonacoProps = { - theme: 'vs-dark', - className: classes.monaco, - options: { - minimap: { enabled: false }, - padding: { - top: 10, - }, - scrollbar: { - horizontalScrollbarSize: 6, - verticalScrollbarSize: 6, - }, - }, -} satisfies ComponentPropsWithoutRef; - -const monacoProps = { - env: { - ...sharedMonacoProps, - defaultLanguage: 'json', - options: { - ...sharedMonacoProps.options, - lineNumbers: 'off', - tabSize: 2, - }, - }, - script: { - ...sharedMonacoProps, - theme: 'vs-dark', - defaultLanguage: 'javascript', - options: { - ...sharedMonacoProps.options, - }, - }, -} satisfies Record<'script' | 'env', ComponentPropsWithoutRef>; - const UpdatePreflightScriptMutation = graphql(` mutation UpdatePreflightScript($input: UpdatePreflightScriptInput!) { updatePreflightScript(input: $input) { @@ -127,350 +59,25 @@ const UpdatePreflightScriptMutation = graphql(` } `); -const PreflightScript_TargetFragment = graphql(` - fragment PreflightScript_TargetFragment on Target { - id - preflightScript { - id - sourceCode - } - } -`); - -export type LogRecord = LogMessage | { type: 'separator' }; - -export const enum PreflightWorkerState { - running, - ready, -} - -export function usePreflight(args: { - target: FragmentType | null; -}) { - const iframeRef = useRef(null); - const prompt = usePromptManager(); - - const target = useFragment(PreflightScript_TargetFragment, args.target); - const [isEnabled, setIsEnabled] = useLocalStorageJson( - // todo: ability to pass historical keys for seamless gradual migration to new key names. - // 'hive:laboratory:isPreflightEnabled', - 'hive:laboratory:isPreflightScriptEnabled', - z.boolean().default(false), - ); - const [environmentVariables, setEnvironmentVariables] = useLocalStorage( - 'hive:laboratory:environment', - '', - ); - const latestEnvironmentVariablesRef = useRef(environmentVariables); - useEffect(() => { - latestEnvironmentVariablesRef.current = environmentVariables; - }); - - const [state, setState] = useState(PreflightWorkerState.ready); - const [logs, setLogs] = useState([]); - - const abortExecutionRef = useRef void)>(null); - - async function execute( - script = target?.preflightScript?.sourceCode ?? '', - isPreview = false, - ): Promise { - const resultEnvironmentVariablesDecoded: PreflightResultData['environmentVariables'] = - Kit.tryOr( - () => JSON.parse(latestEnvironmentVariablesRef.current), - // todo: find a better solution than blowing away the user's - // invalid localStorage state. - // - // For example if the user has: - // - // { "foo": "bar } - // - // Then when they "Run Script" it will be replaced with: - // - // {} - // - () => ({}), - ); - const result: PreflightResultData = { - request: { - headers: [], - }, - environmentVariables: resultEnvironmentVariablesDecoded, - }; - - if (isPreview === false && !isEnabled) { - return result; - } - - const id = crypto.randomUUID(); - setState(PreflightWorkerState.running); - const now = Date.now(); - setLogs(prev => [ - ...prev, - { - level: 'log', - message: 'Running script...', - }, - ]); - - try { - const contentWindow = iframeRef.current?.contentWindow; - - if (!contentWindow) { - throw new Error('Could not load iframe embed.'); - } - - contentWindow.postMessage( - { - type: IFrameEvents.Incoming.Event.run, - id, - script, - // Preflight has read/write relationship with environment variables. - environmentVariables: result.environmentVariables, - } satisfies IFrameEvents.Incoming.EventData, - '*', - ); - - let isFinished = false; - const isFinishedD = Promise.withResolvers(); - const openedPromptIds = new Set(); - - // eslint-disable-next-line no-inner-declarations - function setFinished() { - isFinished = true; - isFinishedD.resolve(); - } - - // eslint-disable-next-line no-inner-declarations - function closedOpenedPrompts() { - if (openedPromptIds.size) { - for (const promptId of openedPromptIds) { - prompt.closePrompt(promptId, null); - } - } - } - - // eslint-disable-next-line no-inner-declarations - async function eventHandler(ev: IFrameEvents.Outgoing.MessageEvent) { - if (ev.data.type === IFrameEvents.Outgoing.Event.prompt) { - const promptId = ev.data.promptId; - openedPromptIds.add(promptId); - await prompt - .openPrompt({ - id: promptId, - title: ev.data.message, - defaultValue: ev.data.defaultValue, - }) - .then(value => { - if (isFinished) { - // ignore prompt response if the script has already finished - return; - } - - openedPromptIds.delete(promptId); - contentWindow?.postMessage( - { - type: IFrameEvents.Incoming.Event.promptResponse, - id, - promptId, - value, - } satisfies IFrameEvents.Incoming.EventData, - '*', - ); - }); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.result) { - const mergedEnvironmentVariables = { - ...result.environmentVariables, - ...ev.data.environmentVariables, - }; - result.environmentVariables = mergedEnvironmentVariables; - result.request.headers = ev.data.request.headers; - - // Cause the new state of environment variables to be - // written back to local storage. - - const mergedEnvironmentVariablesEncoded = JSON.stringify( - result.environmentVariables, - null, - 2, - ); - setEnvironmentVariables(mergedEnvironmentVariablesEncoded); - latestEnvironmentVariablesRef.current = mergedEnvironmentVariablesEncoded; - - setLogs(logs => [ - ...logs, - { - level: 'log', - message: `Done in ${(Date.now() - now) / 1000}s`, - }, - { - type: 'separator' as const, - }, - ]); - setFinished(); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.error) { - const error = ev.data.error; - setLogs(logs => [ - ...logs, - { - level: 'error', - message: error.message, - line: error.line, - column: error.column, - }, - { - level: 'log', - message: 'Script failed', - }, - { - type: 'separator' as const, - }, - ]); - setFinished(); - closedOpenedPrompts(); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.log) { - const log = ev.data.log; - setLogs(logs => [...logs, log]); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.ready) { - console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.start) { - console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); - return; - } - - // Window message events can be emitted from unknowable sources. - // For example when our e2e tests runs within Cypress GUI, we see a `MessageEvent` with `.data` of `{ vscodeScheduleAsyncWork: 3 }`. - // Since we cannot know if the event source is Preflight, we cannot perform an exhaustive check. - // - // Kit.neverCase(ev.data); - // - console.debug( - 'preflight sandbox graphiql plugin: An unknown window message event received. Ignoring.', - ev, - ); - } - - window.addEventListener('message', eventHandler); - abortExecutionRef.current = () => { - contentWindow.postMessage({ - type: IFrameEvents.Incoming.Event.abort, - id, - } satisfies IFrameEvents.Incoming.EventData); - - closedOpenedPrompts(); - - abortExecutionRef.current = null; - }; - - await isFinishedD.promise; - window.removeEventListener('message', eventHandler); - - setState(PreflightWorkerState.ready); - - return result; - } catch (err) { - if (err instanceof Error) { - setLogs(prev => [ - ...prev, - { - level: 'error', - message: err.message, - }, - { - level: 'log', - message: 'Script failed', - }, - { - type: 'separator' as const, - }, - ]); - setState(PreflightWorkerState.ready); - return result; - } - throw err; - } - } - - function abortExecution() { - abortExecutionRef.current?.(); - } - - // terminate worker when leaving laboratory - useEffect( - () => () => { - abortExecutionRef.current?.(); - }, - [], - ); - - return { - execute, - abortExecution, - isEnabled, - setIsEnabled, - content: target?.preflightScript?.sourceCode ?? '', - environmentVariables, - setEnvironmentVariables, - state, - logs, - clearLogs: () => setLogs([]), - iframeElement: ( -