Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
98 changes: 98 additions & 0 deletions cypress/e2e/laboratory-environment-variables.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { persistAuthenticationCookies } from '../support/testkit';

Cypress.Cookies.debug(true);

const as = <$Type>() => undefined as $Type;

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',
},
};

// 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',
};

const data = {
envars: { foo: '123' },
envarsJson: '{"foo":"123"}',
};

const ctx = {
// todo get an exported type from testKit
targetDevelopment: as<{ id: string; slug: string; path: string }>(),
targetProduction: as<{ id: string; slug: string; path: string }>(),
cookies: [] as Cypress.Cookie[],
};

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

persistAuthenticationCookies();

const visitTargetDevelopment = () => cy.visit(`${ctx.targetDevelopment.path}/laboratory`);
// const visitTargetProduction = () => cy.visit(`${ctx.targetProduction.path}/laboratory`);

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

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

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

beforeEach(() => {
cy.removeLocalStorage(environmentVariablesStorageKey.global);
cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id));
cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id));
});

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

it('if state just scoped value, shows that', () => {
storageTargetDevelopmentSet(data.envarsJson);
visitTargetDevelopment();
openPreflightTab();
cy.contains(data.envarsJson);
storageGlobalGet().should('equal', null);
});

it('if state just global value, copied to scoped, shows that', () => {
storageTargetDevelopmentGet().should('equal', null);
storageGlobalSet(data.envarsJson);
visitTargetDevelopment();
openPreflightTab();
cy.contains(data.envarsJson);
storageTargetDevelopmentGet().should('equal', data.envarsJson);
storageGlobalGet().should('equal', data.envarsJson);
});
});
8 changes: 4 additions & 4 deletions cypress/e2e/laboratory-preflight.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ 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();
});
Expand Down Expand Up @@ -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';

Cypress.on('uncaught:exception', (_err, _runnable) => {
return false;
Expand Down
41 changes: 41 additions & 0 deletions cypress/support/testkit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,44 @@
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
7 changes: 5 additions & 2 deletions integration-tests/testkit/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,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;
24 changes: 17 additions & 7 deletions packages/web/app/src/lib/hooks/use-local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry';

export function useLocalStorage(key: string, defaultValue: string) {
const [value, setValue] = useState<string>(() => {
const value = localStorage.getItem(key);
export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) {
const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key;
const versionedEntrySerialized = versionedEntry.map(_ => _.key).join(',');

const getInitialValue = useCallback(() => {
const value = readVersionedEntryLocalStorage({ spec: versionedEntry });
return value ?? defaultValue;
});
}, [versionedEntrySerialized, defaultValue]);

const [value, setValue] = useState(getInitialValue());

useEffect(() => {
setValue(getInitialValue());
}, [getInitialValue]);

const set = useCallback(
(value: string) => {
localStorage.setItem(key, value);
localStorage.setItem(versionedEntry[0].key, value);
setValue(value);
},
[setValue],
[getInitialValue],
);

return [value, set] as const;
Expand Down
15 changes: 12 additions & 3 deletions packages/web/app/src/lib/preflight/graphiql-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ export const enum PreflightWorkerState {
ready,
}

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',
};

export function usePreflight(args: {
target: FragmentType<typeof PreflightScript_TargetFragment> | null;
}) {
Expand All @@ -152,13 +160,14 @@ export function usePreflight(args: {

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',
[
{ key: environmentVariablesStorageKey.scoped(target?.id) },
{ key: environmentVariablesStorageKey.global },
],
'',
);
const latestEnvironmentVariablesRef = useRef(environmentVariables);
Expand Down
Loading
Loading