diff --git a/.changeset/funny-poets-hang.md b/.changeset/funny-poets-hang.md new file mode 100644 index 0000000000..f9172dbca4 --- /dev/null +++ b/.changeset/funny-poets-hang.md @@ -0,0 +1,36 @@ +--- +'hive': minor +--- + +Laboratory Preflight now validates your script with TypeScript. Also, the `WebWorker` runtime types are applied giving you confidence about what globals are available to you in your script. + +## Backwards Incompatible Notes + +This change is backwards incompatible in the sense that invalid or problematic Script code which would have previously not statically errored will now. However at this time we do not prevent script saving because of static type errors. Therefore your workflow should only at worst be visually impacted. + +## About WebWorker Runtime & Types + +To learn more about what the WebWorker runtime and types are, you can review the following: + +1. https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API +2. https://www.typescriptlang.org/tsconfig/#lib (see "WebWorker") + + +## Leveraging TypeScript in JavaScript + +If you are not familiar with TypeScript, here is a tip for you if you find yourself with a TypeScript error that you cannot or do not want to fix. You can silence them by using comments: + +```js +let a = 1; +let b = ''; +// @ts-ignore +a = b; +// @ts-expect-error +a = b; +``` + +The advantage of `@ts-expect-error` is that if there is no error to ignore, then the comment itself becomes an error whereas `@ts-ignore` sits there quietly whether it has an effect or not. + +There is more you can do with TypeScript in JavaScript, such as providing type annotations via JSDoc. Learn more about it all here: + +https://www.typescriptlang.org/docs/handbook/intro-to-js-ts.html diff --git a/cypress/e2e/laboratory/__cypress__.ts b/cypress/e2e/laboratory/__cypress__.ts new file mode 100644 index 0000000000..360d65b824 --- /dev/null +++ b/cypress/e2e/laboratory/__cypress__.ts @@ -0,0 +1,90 @@ +import { cyMonaco } 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"]', + buttonModal: '[data-cy="preflight-modal-button"]', + buttonToggle: '[data-cy="toggle-preflight"]', + buttonHeaders: '[data-name="headers"]', + headersEditor: { + textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea', + }, + graphiql: { + buttonExecute: '.graphiql-execute-button', + }, + + modal: { + buttonSubmit: '[data-cy="preflight-modal-submit"]', + scriptEditor: '[data-cy="preflight-editor"]', + variablesEditor: '[data-cy="env-editor"]', + }, + }; + + export const setScriptEditorContent = (value: string) => { + cyMonaco.setContent(selectors.modal.scriptEditor, value); + }; + + export const setEnvironmentEditorContent = (value: string) => { + cyMonaco.setContent(selectors.modal.variablesEditor, 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..b8bea98548 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 './__cypress__'; 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 81% rename from cypress/e2e/laboratory-preflight.cy.ts rename to cypress/e2e/laboratory/preflight.cy.ts index 645dd3931f..5b635a86e2 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory/preflight.cy.ts @@ -1,21 +1,10 @@ -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 { cyMonaco } from '../../support/monaco'; +import { cyLaboratory } from './__cypress__'; + +const selectors = cyLaboratory.preflight.selectors; + +const cyPreflight = cyLaboratory.preflight; const data: { slug: string } = { slug: '', @@ -32,30 +21,7 @@ beforeEach(() => { }); }); -/** 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', '{}'); @@ -76,17 +42,35 @@ 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); + cyPreflight.setEnvironmentEditorContent(env); + }); + + it('script is validated with TypeScript', () => { + cyPreflight.setScriptEditorContent('let a = 1; a; a = ""'); + cyMonaco.nextProblemContains(selectors.modal.scriptEditor, "Type 'string' is not assignable to type 'number'."); // prettier-ignore + }); + + it('script cannot have TypeScript syntax', () => { + cyPreflight.setScriptEditorContent('const a:number = 1; a'); + cyMonaco.nextProblemContains(selectors.modal.scriptEditor, 'Type annotations can only be used in TypeScript files.'); // prettier-ignore + }); + + it('regression: saving and re-opening clears previous validation state', () => { + cyPreflight.setScriptEditorContent('const a = 1; a'); + cy.get(selectors.modal.buttonSubmit).click(); + cy.get(selectors.buttonModal).click(); + cyMonaco.goToNextProblem(selectors.modal.scriptEditor); + cy.contains('Cannot redeclare block-scoped variable').should('not.exist'); }); it('save script and environment variables when submitting', () => { - setEditorScript(script); + cyPreflight.setScriptEditorContent(script); cy.dataCy('preflight-modal-submit').click(); cy.dataCy('env-editor-mini').should('have.text', env); cy.dataCy('toggle-preflight').click(); @@ -98,11 +82,11 @@ describe('Preflight Script Modal', () => { }); it('logs show console/error information', () => { - setEditorScript(script); + cyPreflight.setScriptEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyPreflight.setScriptEditorContent( `console.info(1) console.warn(true) console.error('Fatal') @@ -120,12 +104,12 @@ throw new TypeError('Test')`, }); it('prompt and pass the awaited response', () => { - setEditorScript(script); + cyPreflight.setScriptEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyPreflight.setScriptEditorContent( dedent` const username = await lab.prompt('Enter your username'); console.info(username); @@ -148,12 +132,12 @@ throw new TypeError('Test')`, }); it('prompt and cancel', () => { - setEditorScript(script); + cyPreflight.setScriptEditorContent(script); cy.dataCy('run-preflight').click(); cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)'); - setEditorScript( + cyPreflight.setScriptEditorContent( dedent` const username = await lab.prompt('Enter your username'); console.info(username); @@ -176,7 +160,7 @@ throw new TypeError('Test')`, }); it('script execution updates environment variables', () => { - setEditorScript(`lab.environment.set('my-test', "TROLOLOL")`); + cyPreflight.setScriptEditorContent(`lab.environment.set('my-test', "TROLOLOL")`); cy.dataCy('run-preflight').click(); cy.dataCy('env-editor').should( @@ -187,7 +171,7 @@ throw new TypeError('Test')`, }); it('`crypto-js` can be used for generating hashes', () => { - setEditorScript('console.log(lab.CryptoJS.SHA256("🐝"))'); + cyPreflight.setScriptEditorContent('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 +181,13 @@ throw new TypeError('Test')`, }); it('scripts can not use `eval`', () => { - setEditorScript('eval()'); + cyPreflight.setScriptEditorContent('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('🐝'); + cyPreflight.setScriptEditorContent('🐝'); cy.dataCy('preflight-modal-submit').click(); cy.get('body').contains("[1:1]: Illegal character '}"); }); @@ -215,10 +199,12 @@ 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.get(selectors.buttonToggle).click(); + cy.get(selectors.buttonModal).click(); + cyPreflight.setScriptEditorContent( + `lab.request.headers.append('foo', '${preflightHeaders.foo}')`, + ); + cy.get(selectors.modal.buttonSubmit).click(); // Run GraphiQL cy.intercept({ headers: preflightHeaders }).as('request'); cy.get(selectors.graphiql.buttonExecute).click(); @@ -239,10 +225,12 @@ describe('Execution', () => { 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.get(selectors.buttonToggle).click(); + cy.get(selectors.buttonModal).click(); + cyPreflight.setScriptEditorContent( + `lab.request.headers.append('accept', '${preflightHeaders.accept}')`, + ); + cy.get(selectors.modal.buttonSubmit).click(); // Run GraphiQL cy.intercept({ headers: preflightHeaders }).as('request'); cy.get(selectors.graphiql.buttonExecute).click(); @@ -267,13 +255,13 @@ describe('Execution', () => { const preflightHeaders = { foo_preflight: barEnVarInterpolation, }; - cy.dataCy(selectors.buttonToggleCy).click(); - cy.dataCy(selectors.buttonModalCy).click(); - setEditorScript(` + cy.get(selectors.buttonToggle).click(); + cy.get(selectors.buttonModal).click(); + cyPreflight.setScriptEditorContent(` lab.environment.set('bar', '${environmentVariables.bar}') lab.request.headers.append('foo_preflight', '${preflightHeaders.foo_preflight}') `); - cy.dataCy(selectors.modal.buttonSubmitCy).click(); + cy.get(selectors.modal.buttonSubmit).click(); // Run GraphiQL cy.intercept({ headers: { @@ -323,7 +311,7 @@ describe('Execution', () => { }, ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents('preflight-editor', `lab.environment.set('foo', '92')`); + cyPreflight.setScriptEditorContent(`lab.environment.set('foo', '92')`); cy.dataCy('preflight-modal-submit').click(); cy.intercept({ @@ -350,8 +338,7 @@ describe('Execution', () => { ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyPreflight.setScriptEditorContent( dedent` const username = await lab.prompt('Enter your username'); lab.environment.set('username', username); @@ -383,8 +370,8 @@ describe('Execution', () => { }, ); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents('preflight-editor', `lab.environment.set('foo', 92)`); - setMonacoEditorContents('env-editor', `{"foo":10}`); + cyPreflight.setScriptEditorContent(`lab.environment.set('foo', 92)`); + cyPreflight.setEnvironmentEditorContent(`{"foo":10}`); cy.dataCy('preflight-modal-submit').click(); @@ -402,8 +389,7 @@ describe('Execution', () => { cy.dataCy('toggle-preflight').click(); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyPreflight.setScriptEditorContent( dedent` console.info(1) console.warn(true) @@ -447,8 +433,7 @@ describe('Execution', () => { cy.dataCy('toggle-preflight').click(); cy.dataCy('preflight-modal-button').click(); - setMonacoEditorContents( - 'preflight-editor', + cyPreflight.setScriptEditorContent( 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..43da53c44a 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 './__cypress__'; 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/e2e.ts b/cypress/support/e2e.ts index 7149352f73..7a8c2de2bb 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,4 +1,9 @@ import './commands'; +// Cypress does not support real events, arbitrary keyboard input. +// @see https://github.com/cypress-io/cypress/discussions/19790 +// We use this for pressing Alt+F8 in Preflight editor. +// eslint-disable-next-line import/no-extraneous-dependencies +import 'cypress-real-events'; Cypress.on('uncaught:exception', (_err, _runnable) => { return false; diff --git a/cypress/support/monaco.ts b/cypress/support/monaco.ts new file mode 100644 index 0000000000..76cf8869ae --- /dev/null +++ b/cypress/support/monaco.ts @@ -0,0 +1,34 @@ +import type * as Monaco from 'monaco-editor'; + +export namespace cyMonaco { + /** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ + export function setContent(editorSelector: string, text: string) { + // wait for textarea appearing which indicates monaco is loaded + cy.get(editorSelector).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 => { + const parentElement = e.getContainerDomNode().parentElement; + return Cypress.$(parentElement).is(editorSelector); + }); + + // If Monaco instance is found + if (editor) { + editor.setValue(text); + } else { + throw new Error('Monaco editor not found on the window or frames[0]'); + } + }); + } + + export function goToNextProblem(editorSelector: string, waitMs = 1000) { + // Hack: Seemingly only way to reliably interact with the monaco text area from Cypress. + if (waitMs) cy.wait(waitMs); + cy.get(editorSelector).find('textarea').focus().realPress(['Alt', 'F8']); + } + + export function nextProblemContains(editorSelector: string, problem: string, waitMs = 1000) { + goToNextProblem(editorSelector, waitMs); + cy.contains(problem); + } +} 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..f1dc7b5842 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", "cypress-real-events", "monaco-editor"] }, "include": ["**/*.ts", "../integration-tests/testkit/**/*.ts"] } diff --git a/package.json b/package.json index 3773cbd104..7de07404b4 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@types/node": "22.10.5", "bob-the-bundler": "7.0.1", "cypress": "13.17.0", + "cypress-real-events": "1.14.0", "dotenv": "16.4.7", "eslint": "8.57.1", "eslint-plugin-cypress": "4.1.0", @@ -86,6 +87,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/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index 30fb4f595a..a6be547377 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -639,6 +639,32 @@ function PreflightModal({ }, []); const handleMonacoEditorBeforeMount = useCallback((monaco: Monaco) => { + // Setup validation of JavaScript code. + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: false, + diagnosticCodesToIgnore: [], + }); + monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ + allowNonTsExtensions: true, + allowJs: true, + checkJs: true, + target: monaco.languages.typescript.ScriptTarget.ES2020, + lib: ['webworker'], + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noImplicitReturns: true, + noFallthroughCasesInSwitch: true, + // This is a workaround. + // 3 = 'force' + // + // Problem: https://github.com/graphql-hive/console/pull/6476#issuecomment-2654056957 + // Solution: https://github.com/microsoft/monaco-editor/issues/2976#issuecomment-2334468503 + // Reference: https://www.typescriptlang.org/tsconfig/#moduleDetection + moduleDetection: 3, + }); // Add custom typings for globalThis monaco.languages.typescript.javascriptDefaults.addExtraLib( ` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26a9451b34..6a24160deb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: cypress: specifier: 13.17.0 version: 13.17.0 + cypress-real-events: + specifier: 1.14.0 + version: 1.14.0(cypress@13.17.0) dotenv: specifier: 16.4.7 version: 16.4.7 @@ -164,6 +167,9 @@ importers: jest-snapshot-serializer-raw: specifier: 2.0.0 version: 2.0.0 + monaco-editor: + specifier: 0.52.2 + version: 0.52.2 pg: specifier: 8.13.1 version: 8.13.1 @@ -9397,6 +9403,11 @@ packages: csv-stringify@6.5.2: resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + cypress-real-events@1.14.0: + resolution: {integrity: sha512-XmI8y3OZLh6cjRroPalzzS++iv+pGCaD9G9kfIbtspgv7GVsDt30dkZvSXfgZb4rAN+3pOkMVB7e0j4oXydW7Q==} + peerDependencies: + cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x || ^14.x + cypress@13.17.0: resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} @@ -26078,6 +26089,10 @@ snapshots: csv-stringify@6.5.2: {} + cypress-real-events@1.14.0(cypress@13.17.0): + dependencies: + cypress: 13.17.0 + cypress@13.17.0: dependencies: '@cypress/request': 3.0.6