Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(laboratory): scope environment variables to target #6500

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
20 changes: 20 additions & 0 deletions .changeset/rich-terms-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'hive': minor
---

Laboratory Environment Variables are now scoped to Target.

Previously, we stored environment variables as a static key in your browser's local storage. This meant that any changes to the environment variables would affect all targets' Laboratory.

Now when you use Laboratory, any changes to the environment variables will not affect the environment variables of other targets' Laboratory.

## Migration Details (TL;DR: You Won't Notice Anything!)

For an indefinite period of time we will support the following migration when you load Laboratory on any target. If this holds true:

1. Your browser's localStorage has a key for the global environment variables;
2. Your browser's localStorage does NOT have a key for scoped environment variables for the Target Laboratory being loaded;

Then we will initialize the scoped environment variables for the Target Laboratory being loaded with the global ones.

Laboratory will _never_ write to the global environment variables again, so this should give you a seamless migration to scoped environment variables for all your targets.
12 changes: 10 additions & 2 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from 'node:fs';
// eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency
import { defineConfig } from 'cypress';
import cypressPluginLocalStorageCommands from 'cypress-localstorage-commands/plugin';
import { initSeed } from './integration-tests/testkit/seed';

if (!process.env.RUN_AGAINST_LOCAL_SERVICES) {
Expand All @@ -23,16 +24,23 @@ export default defineConfig({
video: isCI,
screenshotOnRunFailure: isCI,
defaultCommandTimeout: 15_000, // sometimes the app takes longer to load, especially in the CI
retries: 2,
retries: isCI ? 2 : 0,
e2e: {
setupNodeEvents(on) {
setupNodeEvents(on, config) {
cypressPluginLocalStorageCommands(on, config);

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 {
targets: {
production: project.targets.find(_ => _.name === 'production'),
staging: project.targets.find(_ => _.name === 'staging'),
development: project.targets.find(_ => _.name === 'development'),
},
slug,
refreshToken: owner.ownerRefreshToken,
email: owner.ownerEmail,
Expand Down
123 changes: 123 additions & 0 deletions cypress/e2e/laboratory-environment-variables.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
environmentVariablesStorageKey,
persistAuthenticationCookies,
selectors,
type Target,
} from '../support/testkit';

const data = {
globalEnvars: { foo: '123' },
globalEnvarsJson: '{"foo":"123"}',
scopedEnvars: { bar: '456' },
targetEnvarsJson: '{"bar":"456"}',
};

interface Ctx {
targetDevelopment: Target;
targetProduction: Target;
cookies: Cypress.Cookie[];
}
const ctx = {
cookies: [],
} as Ctx;

before(() => {
cy.task('seedTarget').then(({ refreshToken, targets }: any) => {
cy.setCookie('sRefreshToken', refreshToken);
ctx.targetDevelopment = targets.development;
ctx.targetProduction = targets.production;
});
});

persistAuthenticationCookies();

const openPreflightTab = () => cy.get(selectors.buttonGraphiQLPreflight).click();
const openPreflightModal = () => cy.dataCy(selectors.buttonModalCy).click();

const storageGlobalGet = () => cy.getLocalStorage(environmentVariablesStorageKey.global);
const storageGlobalSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.global, value); // prettier-ignore
const storageGlobalRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.global);

const visitTargetDevelopment = () => cy.visit(`${ctx.targetDevelopment.path}/laboratory`);
const storageTargetDevelopmentGet = () => cy.getLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id)); // prettier-ignore
const storageTargetDevelopmentSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id), value); // prettier-ignore
const storageTargetDevelopmentRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id)); // prettier-ignore

const visitTargetProduction = () => cy.visit(`${ctx.targetProduction.path}/laboratory`);
// const storageTargetProductionGet = () => cy.getLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id)); // prettier-ignore
// const storageTargetProductionSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id), value); // prettier-ignore
const storageTargetProductionRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id)); // prettier-ignore

beforeEach(() => {
storageGlobalRemove();
storageTargetDevelopmentRemove();
storageTargetProductionRemove();
});

describe('tab editor', () => {
it('if state empty, is null', () => {
visitTargetDevelopment();
openPreflightTab();
storageTargetDevelopmentGet().should('equal', null);
storageGlobalGet().should('equal', null);
});

it('if storage just has target-scope value, value used', () => {
storageTargetDevelopmentSet(data.targetEnvarsJson);
visitTargetDevelopment();
openPreflightTab();
cy.contains(data.targetEnvarsJson);
});

it('if storage just has global-scope value, copied to new target-scope value, used', () => {
storageGlobalSet(data.globalEnvarsJson);
visitTargetDevelopment();
openPreflightTab();
cy.contains(data.globalEnvarsJson);
storageTargetDevelopmentGet().should('equal', data.globalEnvarsJson);
});

it('if storage has global-scope AND target-scope values, target-scope value used', () => {
storageTargetDevelopmentSet(data.targetEnvarsJson);
storageGlobalSet(data.globalEnvarsJson);
visitTargetDevelopment();
openPreflightTab();
cy.contains(data.targetEnvarsJson);
});
});

describe('modal', () => {
it('changing environment variables persists to target-scope', () => {
storageGlobalSet(data.globalEnvarsJson);
visitTargetDevelopment();
openPreflightTab();
openPreflightModal();
cy.contains(data.globalEnvarsJson);
setMonacoEditorContents('env-editor', data.targetEnvarsJson);
storageTargetDevelopmentGet().should('equal', data.targetEnvarsJson);
cy.contains(data.targetEnvarsJson);
visitTargetProduction();
openPreflightTab();
cy.contains(data.globalEnvarsJson);
});
});

// todo: in another PR this utility is factored out into a shared file
/** 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 => {
// 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]');
}
});
}
10 changes: 5 additions & 5 deletions cypress/e2e/laboratory-preflight.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,23 @@ const selectors = {
},
};

const data: { slug: string } = {
slug: '',
const ctx = {
targetSlug: '',
};

beforeEach(() => {
cy.clearLocalStorage().then(async () => {
cy.task('seedTarget').then(({ slug, refreshToken }: any) => {
cy.setCookie('sRefreshToken', refreshToken);
data.slug = slug;
ctx.targetSlug = slug;
cy.visit(`/${slug}/laboratory`);
cy.get(selectors.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) {
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 => {
Expand All @@ -59,7 +59,7 @@ describe('Laboratory > Preflight Script', () => {
// 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.visit(`/${ctx.targetSlug}/laboratory`);
cy.get(selectors.buttonGraphiQLPreflight).click();
});
it('mini script editor is read only', () => {
Expand Down
1 change: 1 addition & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import './commands';
import 'cypress-localstorage-commands';

Check failure on line 2 in cypress/support/e2e.ts

View workflow job for this annotation

GitHub Actions / code-style / eslint-and-prettier

'cypress-localstorage-commands' should be listed in the project's dependencies, not devDependencies

Cypress.on('uncaught:exception', (_err, _runnable) => {
return false;
Expand Down
73 changes: 73 additions & 0 deletions cypress/support/testkit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,76 @@
export const as = <$Type>() => undefined as $Type;

export type { Target } from '../../integration-tests/testkit/seed';

// todo: instead of copying this, import it from core utility lib.
export const environmentVariablesStorageKey = {
// todo: optional target effectively gives this the possibility of being silently global
// which feels subtle and thus likely to introduce hard to trace defects. Should we abort instead?
scoped: (targetId?: string) =>
`hive/targetId:${targetId ?? '__null__'}/laboratory/environment-variables`,
global: 'hive:laboratory:environment',
};

// todo: Once other PRs are merged these selectors will be scoped to a place for laboratory.
export const selectors = {
editorEnvironmentVariables: '[data-cy="preflight-editor-mini"]',
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',
},
};

export function persistAuthenticationCookies() {
const ctx = {
cookies: [] as Cypress.Cookie[],
};

before(() => {
cy.getCookie('sRefreshToken').should('exist');
cy.visit('/');
cy.wait(2000);

cy.getCookie('sAccessToken').should('exist');
cy.getCookie('sFrontToken').should('exist');
cy.getCookie('st-last-access-token-update').should('exist');

cy.getCookie('sAccessToken').then(sAccessToken => {
ctx.cookies.push(sAccessToken);
});
cy.getCookie('sFrontToken').then(sFrontToken => {
ctx.cookies.push(sFrontToken);
});
cy.getCookie('sRefreshToken').then(sRefreshToken => {
ctx.cookies.push(sRefreshToken);
});

cy.getCookie('st-last-access-token-update').then(stLastAccessTokenUpdate => {
ctx.cookies.push(stLastAccessTokenUpdate);
});

cy.clearCookie('st-last-access-token-update');
cy.clearCookie('sRefreshToken');
cy.clearCookie('sAccessToken');
cy.clearCookie('sFrontToken');
});

beforeEach(() => {
ctx.cookies.forEach(cookie => {
cy.setCookie(cookie.name, cookie.value, cookie);
});
});
}

export function generateRandomSlug() {
return Math.random().toString(36).substring(2);
}
Expand Down
15 changes: 11 additions & 4 deletions integration-tests/testkit/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ import {
import * as GraphQLSchema from './gql/graphql';
import {
BreakingChangeFormula,
OrganizationAccessScope,
ProjectAccessScope,
ProjectType,
SchemaPolicyInput,
TargetAccessScope,
Expand All @@ -62,6 +60,12 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from
import { collect, CollectedOperation, legacyCollect } from './usage';
import { generateUnique } from './utils';

export interface Target {
id: string;
path: string;
slug: string;
}

export function initSeed() {
function createConnectionPool() {
const pg = {
Expand Down Expand Up @@ -210,9 +214,12 @@ export function initSeed() {
ownerToken,
).then(r => r.expectNoGraphQLErrors());

const targets = projectResult.createProject.ok!.createdTargets;
const target = targets[0];
const project = projectResult.createProject.ok!.createdProject;
const targets = projectResult.createProject.ok!.createdTargets.map(target => ({
...target,
path: `/${organization.slug}/${project.slug}/${target.slug}`,
}));
const target = targets[0];

return {
project,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@types/node": "22.10.5",
"bob-the-bundler": "7.0.1",
"cypress": "13.17.0",
"cypress-localstorage-commands": "^2.2.7",
"dotenv": "16.4.7",
"eslint": "8.57.1",
"eslint-plugin-cypress": "4.1.0",
Expand Down
Loading
Loading