Skip to content

Commit

Permalink
feat: preflight scripts for laboratory (#5564)
Browse files Browse the repository at this point in the history
Co-authored-by: Kamil Kisiela <[email protected]>
Co-authored-by: Saihajpreet Singh <[email protected]>
Co-authored-by: Laurin Quast <[email protected]>
Co-authored-by: Dotan Simha <[email protected]>
  • Loading branch information
5 people authored Dec 27, 2024
1 parent 38c14e2 commit e0eb3bd
Show file tree
Hide file tree
Showing 60 changed files with 2,883 additions and 346 deletions.
8 changes: 8 additions & 0 deletions .changeset/dull-seas-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'hive': minor
---

Add preflight scripts for laboratory.

It is now possible to add a preflight script within the laboratory that executes before sending a GraphQL request.
[Learn more.](https://the-guild.dev/graphql/hive/product-updates/2024-12-27-preflight-script)
6 changes: 3 additions & 3 deletions .github/workflows/tests-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: setup environment
uses: ./.github/actions/setup
with:
codegen: false
codegen: true
actor: test-e2e
cacheTurbo: false

Expand All @@ -43,7 +43,7 @@ jobs:
timeout-minutes: 10
run: |
docker compose \
--env-file docker/.end2end.env \
--env-file integration-tests/.env \
-f docker/docker-compose.community.yml \
-f docker/docker-compose.end2end.yml \
up -d --wait
Expand All @@ -65,7 +65,7 @@ jobs:
docker --version
docker ps --format json | jq .
docker compose \
--env-file docker/.end2end.env \
--env-file integration-tests/.env \
-f docker/docker-compose.community.yml \
-f docker/docker-compose.end2end.yml \
logs
Expand Down
27 changes: 23 additions & 4 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import * as fs from 'node:fs';
// eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency
import fs from 'node:fs';
import { defineConfig } from 'cypress';
import { initSeed } from './integration-tests/testkit/seed';

if (!process.env.RUN_AGAINST_LOCAL_SERVICES) {
const dotenv = await import('dotenv');
dotenv.config({ path: import.meta.dirname + '/integration-tests/.env' });
}

const isCI = Boolean(process.env.CI);

export const seed = initSeed();

export default defineConfig({
video: isCI,
screenshotOnRunFailure: isCI,
defaultCommandTimeout: 15_000, // sometimes the app takes longer to load, especially in the CI
retries: 2,
env: {
POSTGRES_URL: 'postgresql://postgres:postgres@localhost:5432/registry',
},
e2e: {
setupNodeEvents(on) {
on('task', {
async seedTarget() {
const owner = await seed.createOwner();
const org = await owner.createOrg();
const project = await org.createProject();
const slug = `${org.organization.slug}/${project.project.slug}/${project.target.slug}`;
return {
slug,
refreshToken: owner.ownerRefreshToken,
email: owner.ownerEmail,
};
},
});

on('after:spec', (_, results) => {
if (results && results.video) {
// Do we have failures for any retry attempts?
Expand Down
196 changes: 196 additions & 0 deletions cypress/e2e/preflight-script.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
beforeEach(() => {
cy.clearLocalStorage().then(async () => {
cy.task('seedTarget').then(({ slug, refreshToken }: any) => {
cy.setCookie('sRefreshToken', refreshToken);

cy.visit(`/${slug}/laboratory`);
cy.get('[aria-label*="Preflight Script"]').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-script-editor', script);
}

describe('Preflight Script', () => {
it('mini script editor is read only', () => {
cy.dataCy('toggle-preflight-script').click();
// Wait loading disappears
cy.dataCy('preflight-script-editor-mini').should('not.contain', 'Loading');
// Click
cy.dataCy('preflight-script-editor-mini').click();
// And type
cy.dataCy('preflight-script-editor-mini').within(() => {
cy.get('textarea').type('🐝', { force: true });
});
cy.dataCy('preflight-script-editor-mini').should(
'have.text',
'Cannot edit in read-only editor',
);
});
});

describe('Preflight Script Modal', () => {
const script = 'console.log("Hello_world")';
const env = '{"foo":123}';

beforeEach(() => {
cy.dataCy('preflight-script-modal-button').click();
setMonacoEditorContents('env-editor', env);
});

it('save script and environment variables when submitting', () => {
setEditorScript(script);
cy.dataCy('preflight-script-modal-submit').click();
cy.dataCy('env-editor-mini').should('have.text', env);
cy.dataCy('toggle-preflight-script').click();
cy.dataCy('preflight-script-editor-mini').should('have.text', script);
cy.reload();
cy.get('[aria-label*="Preflight Script"]').click();
cy.dataCy('env-editor-mini').should('have.text', env);
cy.dataCy('preflight-script-editor-mini').should('have.text', script);
});

it('logs show console/error information', () => {
setEditorScript(script);
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');

setEditorScript(
`console.info(1)
console.warn(true)
console.error('Fatal')
throw new TypeError('Test')`,
);

cy.dataCy('run-preflight-script').click();
// First log previous log message
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
// After the new logs
cy.dataCy('console-output').should(
'contain',
[
'Info: 1 (Line: 1, Column: 1)',
'Warn: true (Line: 2, Column: 1)',
'Error: Fatal (Line: 3, Column: 1)',
'TypeError: Test (Line: 4, Column: 7)',
].join(''),
);
});

it('script execution updates environment variables', () => {
setEditorScript(`lab.environment.set('my-test', "TROLOLOL")`);

cy.dataCy('run-preflight-script').click();
cy.dataCy('env-editor').should(
'include.text',
// replace space with &nbsp;
'{ "foo": 123, "my-test": "TROLOLOL"}'.replaceAll(' ', '\xa0'),
);
});

it('`crypto-js` can be used for generating hashes', () => {
setEditorScript('console.log(lab.CryptoJS.SHA256("🐝"))');
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Info: Using crypto-js version:');
cy.dataCy('console-output').should(
'contain',
'Log: d5b51e79e4be0c4f4d6b9a14e16ca864de96afe68459e60a794e80393a4809e8',
);
});

it('scripts can not use `eval`', () => {
setEditorScript('eval()');
cy.dataCy('preflight-script-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('🐝');
cy.dataCy('preflight-script-modal-submit').click();
cy.get('body').contains("[1:1]: Illegal character '}");
});
});

describe('Execution', () => {
it('header placeholders are substituted with environment variables', () => {
cy.dataCy('toggle-preflight-script').click();
cy.get('[data-name="headers"]').click();
cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type(
'{ "__test": "{{foo}} bar {{nonExist}}" }',
{
force: true,
parseSpecialCharSequences: false,
},
);
cy.dataCy('env-editor-mini').within(() => {
cy.get('textarea').type('{"foo":"injected"}', {
force: true,
parseSpecialCharSequences: false,
});
});
cy.intercept('/api/lab/foo/my-new-project/development', req => {
expect(req.headers.__test).to.equal('injected bar {{nonExist}}');
});
cy.get('body').type('{ctrl}{enter}');
});

it('executed script updates update env editor and substitute headers', () => {
cy.dataCy('toggle-preflight-script').click();
cy.get('[data-name="headers"]').click();
cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type(
'{ "__test": "{{foo}}" }',
{
force: true,
parseSpecialCharSequences: false,
},
);
cy.dataCy('preflight-script-modal-button').click();
setMonacoEditorContents('preflight-script-editor', `lab.environment.set('foo', 92)`);
cy.dataCy('preflight-script-modal-submit').click();
cy.intercept('/api/lab/foo/my-new-project/development', req => {
expect(req.headers.__test).to.equal('92');
});
cy.get('.graphiql-execute-button').click();
});

it('disabled script is not executed', () => {
cy.get('[data-name="headers"]').click();
cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type(
'{ "__test": "{{foo}}" }',
{
force: true,
parseSpecialCharSequences: false,
},
);
cy.dataCy('preflight-script-modal-button').click();
setMonacoEditorContents('preflight-script-editor', `lab.environment.set('foo', 92)`);
setMonacoEditorContents('env-editor', `{"foo":10}`);

cy.dataCy('preflight-script-modal-submit').click();
cy.intercept('/api/lab/foo/my-new-project/development', req => {
expect(req.headers.__test).to.equal('10');
});
cy.get('.graphiql-execute-button').click();
});
});
2 changes: 1 addition & 1 deletion cypress/local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cd ..
docker buildx bake -f docker/docker.hcl build --load

echo "⬆️ Running all local containers..."
docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compose.end2end.yml --env-file ./integration-tests/.env --env-file ./docker/.end2end.env up -d --wait
docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compose.end2end.yml --env-file ./integration-tests/.env up -d --wait

echo "✅ E2E tests environment is ready. To run tests now, use:"
echo ""
Expand Down
6 changes: 3 additions & 3 deletions cypress/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"target": "es2021",
"lib": ["es2021", "dom"],
"types": ["node", "cypress"]
},
"include": ["**/*.ts"]
"include": ["**/*.ts", "../integration-tests/testkit/**/*.ts"]
}
4 changes: 1 addition & 3 deletions deployment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,10 @@ deployCloudFlareSecurityTransform({
// Staging
'staging.graphql-hive.com',
'app.staging.graphql-hive.com',
'lab-worker.staging.graphql-hive.com',
'cdn.staging.graphql-hive.com',
// Dev
'dev.graphql-hive.com',
'app.dev.graphql-hive.com',
'lab-worker.dev.graphql-hive.com',
'cdn.dev.graphql-hive.com',
],
});
Expand All @@ -353,4 +351,4 @@ export const schemaApiServiceId = schema.service.id;
export const webhooksApiServiceId = webhooks.service.id;

export const appId = app.deployment.id;
export const publicIp = proxy!.status.loadBalancer.ingress[0].ip;
export const publicIp = proxy.get()!.status.loadBalancer.ingress[0].ip;
1 change: 1 addition & 0 deletions deployment/services/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import * as pulumi from '@pulumi/pulumi';
import { serviceLocalEndpoint } from '../utils/local-endpoint';
import { ServiceDeployment } from '../utils/service-deployment';
Expand Down
3 changes: 0 additions & 3 deletions deployment/services/cloudflare-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export function deployCloudFlareSecurityTransform(options: {
)} } and not http.host in { ${toExpressionList(options.ignoredHosts)} }`;

// TODO: When Preflight PR is merged, we'll need to change this to build this host in a better way.
const labHost = `lab-worker.${options.environment.rootDns}`;
const monacoCdnDynamicBasePath: `https://${string}/` = `https://cdn.jsdelivr.net/npm/monaco-editor@${monacoEditorVersion}/`;
const monacoCdnStaticBasePath: `https://${string}/` = `https://cdn.jsdelivr.net/npm/[email protected]/`;
const crispHost = 'client.crisp.chat';
Expand All @@ -44,7 +43,6 @@ export function deployCloudFlareSecurityTransform(options: {
crispHost,
stripeHost,
gtmHost,
labHost,
'settings.crisp.chat',
'*.ingest.sentry.io',
'wss://client.relay.crisp.chat',
Expand All @@ -57,7 +55,6 @@ export function deployCloudFlareSecurityTransform(options: {
const contentSecurityPolicy = `
default-src 'self';
frame-src ${stripeHost} https://game.crisp.chat;
worker-src 'self' blob: ${labHost};
style-src 'self' 'unsafe-inline' ${crispHost} fonts.googleapis.com rsms.me ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath};
script-src 'self' 'unsafe-eval' 'unsafe-inline' {DYNAMIC_HOST_PLACEHOLDER} ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${cspHosts};
connect-src 'self' * {DYNAMIC_HOST_PLACEHOLDER} ${cspHosts};
Expand Down
3 changes: 1 addition & 2 deletions deployment/services/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,5 @@ export function deployProxy({
service: usage.service,
retriable: true,
},
])
.get();
]);
}
Loading

0 comments on commit e0eb3bd

Please sign in to comment.