Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
13 changes: 11 additions & 2 deletions integration-tests/testkit/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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 +216,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(_ => ({
..._,
path: `/${organization.slug}/${project.slug}/${_.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
17 changes: 11 additions & 6 deletions packages/web/app/src/lib/hooks/use-local-storage-json.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useCallback, useState } from 'react';
import { z } from 'zod';
import { Kit } from '../kit';
import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry';

export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInput<$Schema>) {
const [key, schema, manualDefaultValue] = args as any as Args<$Schema>;
const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key;

// The parameter types will force the user to give a manual default
// if their given Zod schema does not have default.
//
Expand All @@ -24,7 +27,7 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu
// because we manually pre-compute+return the default value, thus we don't
// rely on Zod's behaviour. If that changes this should have `?? undefined`
// added.
const storedValue = localStorage.getItem(key);
const storedValue = readVersionedEntryLocalStorage({ spec: versionedEntry });

if (!storedValue) {
return defaultValue;
Expand All @@ -49,27 +52,29 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu

const set = useCallback(
(value: z.infer<$Schema>) => {
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(versionedEntry[0].key, JSON.stringify(value));
setValue(value);
},
[key],
[versionedEntry.map(({ key }) => key).join('+')],
);

return [value, set] as const;
}

type ArgsInput<$Schema extends z.ZodType> =
$Schema extends z.ZodDefault<z.ZodType>
? [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>]
: [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>, defaultValue: z.infer<$Schema>];
? [key: KeyInput, schema: ArgsInputGuardZodJsonSchema<$Schema>]
: [key: KeyInput, schema: ArgsInputGuardZodJsonSchema<$Schema>, defaultValue: z.infer<$Schema>];

type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> =
z.infer<$Schema> extends Kit.Json.Value
? $Schema
: 'Error: Your Zod schema is or contains a type that is not valid JSON.';

type Args<$Schema extends z.ZodType> = [
key: string,
key: KeyInput,
schema: $Schema,
defaultValue?: z.infer<$Schema>,
];

type KeyInput = string | VersionedEntrySpec;
Loading
Loading