From dcdb72ea54181eec418b7db007123076ff843af1 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 11 Feb 2025 15:09:08 -0500 Subject: [PATCH 01/11] feat(laboratory): scope environment variables to target --- cypress/e2e/laboratory-preflight.cy.ts | 1 + .../src/lib/hooks/use-local-storage-json.ts | 17 +++-- .../app/src/lib/hooks/use-local-storage.ts | 9 ++- .../app/src/lib/preflight/graphiql-plugin.tsx | 7 +- packages/web/app/src/lib/versioned-entry.ts | 67 +++++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 packages/web/app/src/lib/versioned-entry.ts diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory-preflight.cy.ts index 645dd3931f..02b2cc3eca 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory-preflight.cy.ts @@ -58,6 +58,7 @@ function setEditorScript(script: string) { describe('Laboratory > Preflight Script', () => { // https://github.com/graphql-hive/console/pull/6450 it('regression: loads even if local storage is set to {}', () => { + // todo update to have a target id window.localStorage.setItem('hive:laboratory:environment', '{}'); cy.visit(`/${data.slug}/laboratory`); cy.get(selectors.buttonGraphiQLPreflight).click(); diff --git a/packages/web/app/src/lib/hooks/use-local-storage-json.ts b/packages/web/app/src/lib/hooks/use-local-storage-json.ts index 97bd5a4465..c963af2371 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage-json.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage-json.ts @@ -1,9 +1,12 @@ import { useCallback, useState } from 'react'; import { z } from 'zod'; import { Kit } from '../kit'; +import { readVersionedEntry, 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. // @@ -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 = readVersionedEntry(versionedEntry); if (!storedValue) { return defaultValue; @@ -49,10 +52,10 @@ 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; @@ -60,8 +63,8 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu type ArgsInput<$Schema extends z.ZodType> = $Schema extends z.ZodDefault - ? [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 @@ -69,7 +72,9 @@ type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> = : '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; diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index db38bae855..b7d075230a 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -1,14 +1,17 @@ import { useCallback, useState } from 'react'; +import { readVersionedEntry, VersionedEntrySpec } from '../versioned-entry'; + +export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) { + const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; -export function useLocalStorage(key: string, defaultValue: string) { const [value, setValue] = useState(() => { - const value = localStorage.getItem(key); + const value = readVersionedEntry(versionedEntry); return value ?? defaultValue; }); const set = useCallback( (value: string) => { - localStorage.setItem(key, value); + localStorage.setItem(versionedEntry[0].key, value); setValue(value); }, [setValue], diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index 30fb4f595a..d231900a21 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -152,13 +152,16 @@ 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. + // todo // 'hive:laboratory:isPreflightEnabled', 'hive:laboratory:isPreflightScriptEnabled', z.boolean().default(false), ); const [environmentVariables, setEnvironmentVariables] = useLocalStorage( - 'hive:laboratory:environment', + [ + { key: `hive/target:${target?.id ?? '__null__'}/laboratory/environment-variables` }, + { key: 'hive:laboratory:environment' }, + ], '', ); const latestEnvironmentVariablesRef = useRef(environmentVariables); diff --git a/packages/web/app/src/lib/versioned-entry.ts b/packages/web/app/src/lib/versioned-entry.ts new file mode 100644 index 0000000000..34bd44ed60 --- /dev/null +++ b/packages/web/app/src/lib/versioned-entry.ts @@ -0,0 +1,67 @@ +export type VersionedEntrySpec = readonly [EntrySpec, ...(readonly EntrySpec[])]; + +interface EntrySpec { + key: string; + // todo once we have use-case + // schema: + // fromPrevious: +} + +/** + * Read a versioned entry from local storage. + * + * Migrations are automatically applied to bring previous entries up to date with current. + * + * 1. The latest entry value is returned. + * 2. If the latest entry to have a value is NOT the current entry, then current entry is set to the latest value. + * 3. All entries prior the current that are present are deleted. + * + * Implied by the above but to make explicit: if multiple entries have values, only the + * latest entry with a value is considered. + * + * @remarks The hardcoding to localStorage is minimal. It could be easily parameterized to support for example Redis. + */ +export const readVersionedEntry = (versionedEntry: VersionedEntrySpec): string | null => { + type SearchResult = SearchResultHit | SearchResultMiss; + + interface SearchResultHit extends SearchResultEither { + value: string; + } + + interface SearchResultMiss extends SearchResultEither { + value: null; + } + + interface SearchResultEither { + value: string | null; + entry: EntrySpec; + index: number; + } + + // --- + + const searchResults: SearchResult[] = []; + + for (const { entry, index } of versionedEntry.map((entry, index) => ({ entry, index }))) { + const value = localStorage.getItem(entry.key); + searchResults.push({ entry, value, index }); + // remove previous entries + // Note: Once we have schemas, we should not remove here, wait until _after_ successful migration + if (index > 0) { + localStorage.removeItem(entry.key); + } + } + + const latestHit = searchResults.find(({ value }) => value !== null) as + | SearchResultHit + | undefined; + + if (!latestHit) return null; + + if (latestHit.index > 0) { + localStorage.setItem(versionedEntry[0].key, latestHit.value); + // Note: Once we have schemas, we will need to run the value through the migration pipeline. + } + + return latestHit.value; +}; From 47ee32f3dd49564f93e1482f9c8ee9f314f45b41 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 12 Feb 2025 15:02:09 -0500 Subject: [PATCH 02/11] unit tests --- .../src/lib/hooks/use-local-storage-json.ts | 4 +- .../app/src/lib/hooks/use-local-storage.ts | 4 +- .../web/app/src/lib/versioned-entry.spec.ts | 46 +++++++ packages/web/app/src/lib/versioned-entry.ts | 128 ++++++++++++------ 4 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 packages/web/app/src/lib/versioned-entry.spec.ts diff --git a/packages/web/app/src/lib/hooks/use-local-storage-json.ts b/packages/web/app/src/lib/hooks/use-local-storage-json.ts index c963af2371..cd3fd6621b 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage-json.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage-json.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { z } from 'zod'; import { Kit } from '../kit'; -import { readVersionedEntry, VersionedEntrySpec } from '../versioned-entry'; +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>; @@ -27,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 = readVersionedEntry(versionedEntry); + const storedValue = readVersionedEntryLocalStorage(versionedEntry); if (!storedValue) { return defaultValue; diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index b7d075230a..1ab738f536 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -1,11 +1,11 @@ import { useCallback, useState } from 'react'; -import { readVersionedEntry, VersionedEntrySpec } from '../versioned-entry'; +import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry'; export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) { const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; const [value, setValue] = useState(() => { - const value = readVersionedEntry(versionedEntry); + const value = readVersionedEntryLocalStorage(versionedEntry); return value ?? defaultValue; }); diff --git a/packages/web/app/src/lib/versioned-entry.spec.ts b/packages/web/app/src/lib/versioned-entry.spec.ts new file mode 100644 index 0000000000..ec4b66e1f1 --- /dev/null +++ b/packages/web/app/src/lib/versioned-entry.spec.ts @@ -0,0 +1,46 @@ +import { + createKeyValueStoreMemory, + KeyValueStore, + KeyValueStoreDatabase, + readVersionedEntry, + VersionedEntrySpec, +} from './versioned-entry'; + +interface TestCase { + databaseBefore: KeyValueStoreDatabase; + databaseAfter: KeyValueStoreDatabase; + spec: VersionedEntrySpec; + value: string | null; +} + +const a = 'a'; +const b = 'b'; +const c = 'c'; + +// prettier-ignore +test.for([ + // Returns null if spec key is missing in db + { spec: [{ key:a }], databaseBefore: {}, databaseAfter: {}, value: null }, + { spec: [{ key:a }], databaseBefore: {b}, databaseAfter: {b}, value: null }, + // Returns value if spec key is present in db + { spec: [{ key:a }], databaseBefore: {a}, databaseAfter: {a}, value: a }, + { spec: [{ key:a }], databaseBefore: {a,b}, databaseAfter: {a,b}, value: a }, + { spec: [{ key:a }, {key:b}], databaseBefore: {a}, databaseAfter: {a}, value: a }, + // Previous spec keys are removed from db + { spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a}, value: a }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a}, value: a }, + // Non-spec keys in db are not removed + { spec: [{ key:a }, {key:b}], databaseBefore: {a,b,c}, databaseAfter: {a,c}, value: a }, + // Latest found spec key is returned + migrated in db + { spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b}, value: b }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c}, value: c }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b}, value: b }, +])( + '%j', + (testCase) => { + const readVersionedEntryMemory = readVersionedEntry(createKeyValueStoreMemory(testCase.databaseBefore)); + const value = readVersionedEntryMemory(testCase.spec) + expect(testCase.databaseBefore).toEqual(testCase.databaseAfter) + expect(value).toEqual(testCase.value) + }, +); diff --git a/packages/web/app/src/lib/versioned-entry.ts b/packages/web/app/src/lib/versioned-entry.ts index 34bd44ed60..028079d42c 100644 --- a/packages/web/app/src/lib/versioned-entry.ts +++ b/packages/web/app/src/lib/versioned-entry.ts @@ -1,3 +1,19 @@ +// -------------------------------------------------------------------- +// KeyValueStore Interface +// -------------------------------------------------------------------- + +export interface KeyValueStore { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} + +export type KeyValueStoreDatabase = Record; + +// -------------------------------------------------------------------- +// Versioned Entry Data Types +// -------------------------------------------------------------------- + export type VersionedEntrySpec = readonly [EntrySpec, ...(readonly EntrySpec[])]; interface EntrySpec { @@ -7,6 +23,10 @@ interface EntrySpec { // fromPrevious: } +// -------------------------------------------------------------------- +// Versioned Entry Functions +// -------------------------------------------------------------------- + /** * Read a versioned entry from local storage. * @@ -21,47 +41,79 @@ interface EntrySpec { * * @remarks The hardcoding to localStorage is minimal. It could be easily parameterized to support for example Redis. */ -export const readVersionedEntry = (versionedEntry: VersionedEntrySpec): string | null => { - type SearchResult = SearchResultHit | SearchResultMiss; - - interface SearchResultHit extends SearchResultEither { - value: string; - } - - interface SearchResultMiss extends SearchResultEither { - value: null; - } - - interface SearchResultEither { - value: string | null; - entry: EntrySpec; - index: number; - } - - // --- - - const searchResults: SearchResult[] = []; - - for (const { entry, index } of versionedEntry.map((entry, index) => ({ entry, index }))) { - const value = localStorage.getItem(entry.key); - searchResults.push({ entry, value, index }); - // remove previous entries - // Note: Once we have schemas, we should not remove here, wait until _after_ successful migration - if (index > 0) { - localStorage.removeItem(entry.key); +export const readVersionedEntry = + (keyValueStore: KeyValueStore) => + (versionedEntry: VersionedEntrySpec): string | null => { + type SearchResult = SearchResultHit | SearchResultMiss; + + interface SearchResultHit extends SearchResultEither { + value: string; + } + + interface SearchResultMiss extends SearchResultEither { + value: null; + } + + interface SearchResultEither { + value: string | null; + entry: EntrySpec; + index: number; + } + + // --- + + const searchResults: SearchResult[] = []; + + for (const { entry, index } of versionedEntry.map((entry, index) => ({ entry, index }))) { + const value = keyValueStore.get(entry.key); + searchResults.push({ entry, value, index }); + // remove previous entries + // Note: Once we have schemas, we should not remove here, wait until _after_ successful migration + if (index > 0) { + keyValueStore.remove(entry.key); + } } - } - const latestHit = searchResults.find(({ value }) => value !== null) as - | SearchResultHit - | undefined; + const latestHit = searchResults.find(({ value }) => value !== null) as + | SearchResultHit + | undefined; - if (!latestHit) return null; + if (!latestHit) return null; - if (latestHit.index > 0) { - localStorage.setItem(versionedEntry[0].key, latestHit.value); - // Note: Once we have schemas, we will need to run the value through the migration pipeline. - } + if (latestHit.index > 0) { + keyValueStore.set(versionedEntry[0].key, latestHit.value); + // Note: Once we have schemas, we will need to run the value through the migration pipeline. + } + + return latestHit.value; + }; + +// -------------------------------------------------------------------- +// KeyValueStore Implementations +// -------------------------------------------------------------------- - return latestHit.value; +export const keyValueStoreLocalStorage: KeyValueStore = { + get(key) { + return localStorage.getItem(key); + }, + set(key, value) { + localStorage.setItem(key, value); + }, + remove(key) { + localStorage.removeItem(key); + }, }; + +export const readVersionedEntryLocalStorage = readVersionedEntry(keyValueStoreLocalStorage); + +export const createKeyValueStoreMemory = (database: KeyValueStoreDatabase): KeyValueStore => ({ + get(key) { + return database[key] ?? null; + }, + set(key, value) { + database[key] = value; + }, + remove(key) { + delete database[key]; + }, +}); From ce95ba65dbcbb882564bb0462e1feef1aac1b7b3 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 12 Feb 2025 15:16:56 -0500 Subject: [PATCH 03/11] changeset --- .changeset/rich-terms-knock.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/rich-terms-knock.md diff --git a/.changeset/rich-terms-knock.md b/.changeset/rich-terms-knock.md new file mode 100644 index 0000000000..86608869cf --- /dev/null +++ b/.changeset/rich-terms-knock.md @@ -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. From c166d97fa6ba0e16aa6323da1e0a983bf59fc6cb Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 12 Feb 2025 20:17:51 -0500 Subject: [PATCH 04/11] ignore --- .../src/lib/hooks/use-local-storage-json.ts | 2 +- .../app/src/lib/hooks/use-local-storage.ts | 2 +- .../web/app/src/lib/versioned-entry.spec.ts | 40 +++++++++++++------ packages/web/app/src/lib/versioned-entry.ts | 30 +++++++++----- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/web/app/src/lib/hooks/use-local-storage-json.ts b/packages/web/app/src/lib/hooks/use-local-storage-json.ts index cd3fd6621b..e0e9c78b3b 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage-json.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage-json.ts @@ -27,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 = readVersionedEntryLocalStorage(versionedEntry); + const storedValue = readVersionedEntryLocalStorage({ spec: versionedEntry }); if (!storedValue) { return defaultValue; diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index 1ab738f536..13acf8f95e 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -5,7 +5,7 @@ export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; const [value, setValue] = useState(() => { - const value = readVersionedEntryLocalStorage(versionedEntry); + const value = readVersionedEntryLocalStorage({ spec: versionedEntry }); return value ?? defaultValue; }); diff --git a/packages/web/app/src/lib/versioned-entry.spec.ts b/packages/web/app/src/lib/versioned-entry.spec.ts index ec4b66e1f1..1793127092 100644 --- a/packages/web/app/src/lib/versioned-entry.spec.ts +++ b/packages/web/app/src/lib/versioned-entry.spec.ts @@ -1,7 +1,7 @@ import { createKeyValueStoreMemory, - KeyValueStore, KeyValueStoreDatabase, + PreviousEntriesPolicy, readVersionedEntry, VersionedEntrySpec, } from './versioned-entry'; @@ -11,6 +11,7 @@ interface TestCase { databaseAfter: KeyValueStoreDatabase; spec: VersionedEntrySpec; value: string | null; + previousEntriesPolicy?: PreviousEntriesPolicy; } const a = 'a'; @@ -26,21 +27,34 @@ test.for([ { spec: [{ key:a }], databaseBefore: {a}, databaseAfter: {a}, value: a }, { spec: [{ key:a }], databaseBefore: {a,b}, databaseAfter: {a,b}, value: a }, { spec: [{ key:a }, {key:b}], databaseBefore: {a}, databaseAfter: {a}, value: a }, + // + // With previousEntriesPolicy = ignore (default) + // + // Previous spec keys are NOT removed from db + { spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a,b}, value: a }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a,b,c}, value: a }, + // Latest found spec key is returned + { spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b,b}, value: b }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c,c}, value: c }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b,b,c}, value: b }, + // + // With previousEntriesPolicy = remove + // // Previous spec keys are removed from db - { spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a}, value: a }, - { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a}, value: a }, + { spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a}, value: a, previousEntriesPolicy: 'remove' }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a}, value: a, previousEntriesPolicy: 'remove' }, + // Latest found spec key is returned AND removed from db if not current spec + { spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b}, value: b, previousEntriesPolicy: 'remove' }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c}, value: c, previousEntriesPolicy: 'remove' }, + { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b}, value: b, previousEntriesPolicy: 'remove' }, // Non-spec keys in db are not removed - { spec: [{ key:a }, {key:b}], databaseBefore: {a,b,c}, databaseAfter: {a,c}, value: a }, - // Latest found spec key is returned + migrated in db - { spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b}, value: b }, - { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c}, value: c }, - { spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b}, value: b }, + { spec: [{ key:a }, {key:b}], databaseBefore: {a,b,c}, databaseAfter: {a,c}, value: a, previousEntriesPolicy: 'remove' }, ])( '%j', - (testCase) => { - const readVersionedEntryMemory = readVersionedEntry(createKeyValueStoreMemory(testCase.databaseBefore)); - const value = readVersionedEntryMemory(testCase.spec) - expect(testCase.databaseBefore).toEqual(testCase.databaseAfter) - expect(value).toEqual(testCase.value) + ({ databaseBefore, databaseAfter, spec, value, previousEntriesPolicy }) => { + const readVersionedEntryMemory = readVersionedEntry(createKeyValueStoreMemory(databaseBefore)); + const valueActual = readVersionedEntryMemory({spec, previousEntriesPolicy}) + expect(databaseBefore).toEqual(databaseAfter) + expect(valueActual).toEqual(value) }, ); diff --git a/packages/web/app/src/lib/versioned-entry.ts b/packages/web/app/src/lib/versioned-entry.ts index 028079d42c..e68711bd08 100644 --- a/packages/web/app/src/lib/versioned-entry.ts +++ b/packages/web/app/src/lib/versioned-entry.ts @@ -34,16 +34,19 @@ interface EntrySpec { * * 1. The latest entry value is returned. * 2. If the latest entry to have a value is NOT the current entry, then current entry is set to the latest value. - * 3. All entries prior the current that are present are deleted. + * 3. All entries prior the current that are present are either deleted or ignored based on removalStrategy. * - * Implied by the above but to make explicit: if multiple entries have values, only the - * latest entry with a value is considered. - * - * @remarks The hardcoding to localStorage is minimal. It could be easily parameterized to support for example Redis. + * @param options.removalStrategy - Strategy for handling previous entries (RemovalStrategy.Remove or RemovalStrategy.Ignore, defaults to Ignore) */ export const readVersionedEntry = (keyValueStore: KeyValueStore) => - (versionedEntry: VersionedEntrySpec): string | null => { + (parameters: { + spec: VersionedEntrySpec; + /** + * @defaultValue 'ignore' + */ + previousEntriesPolicy?: PreviousEntriesPolicy; + }): string | null => { type SearchResult = SearchResultHit | SearchResultMiss; interface SearchResultHit extends SearchResultEither { @@ -61,15 +64,15 @@ export const readVersionedEntry = } // --- + const { spec, previousEntriesPolicy = PreviousEntriesPolicy.ignore } = parameters; const searchResults: SearchResult[] = []; - for (const { entry, index } of versionedEntry.map((entry, index) => ({ entry, index }))) { + for (const { entry, index } of spec.map((entry, index) => ({ entry, index }))) { const value = keyValueStore.get(entry.key); searchResults.push({ entry, value, index }); - // remove previous entries // Note: Once we have schemas, we should not remove here, wait until _after_ successful migration - if (index > 0) { + if (index > 0 && previousEntriesPolicy === PreviousEntriesPolicy.remove) { keyValueStore.remove(entry.key); } } @@ -81,13 +84,20 @@ export const readVersionedEntry = if (!latestHit) return null; if (latestHit.index > 0) { - keyValueStore.set(versionedEntry[0].key, latestHit.value); + keyValueStore.set(spec[0].key, latestHit.value); // Note: Once we have schemas, we will need to run the value through the migration pipeline. } return latestHit.value; }; +export const PreviousEntriesPolicy = { + remove: 'remove', + ignore: 'ignore', +} as const; + +export type PreviousEntriesPolicy = keyof typeof PreviousEntriesPolicy; + // -------------------------------------------------------------------- // KeyValueStore Implementations // -------------------------------------------------------------------- From c7b974cdc0d89447dd54f782e09eaad959db17ff Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 14 Feb 2025 13:20:34 -0500 Subject: [PATCH 05/11] fix useState --- cypress.config.ts | 7 +- .../laboratory-environment-variables.cy.ts | 84 +++++++++++++++++++ cypress/e2e/laboratory-preflight.cy.ts | 1 - integration-tests/testkit/seed.ts | 7 +- .../app/src/lib/hooks/use-local-storage.ts | 15 +++- .../app/src/lib/preflight/graphiql-plugin.tsx | 12 ++- 6 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 cypress/e2e/laboratory-environment-variables.cy.ts diff --git a/cypress.config.ts b/cypress.config.ts index 3b8523d2c7..41ac7a1225 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -23,7 +23,7 @@ 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) { on('task', { @@ -33,6 +33,11 @@ export default defineConfig({ 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, diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts new file mode 100644 index 0000000000..b65134d1da --- /dev/null +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -0,0 +1,84 @@ +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 as = <$Type>() => undefined as $Type; + +const ctx = { + // test export interfaces from testKit + targetDevelopment: as<{ id: string; slug: string; path: string }>(), + targetProduction: as<{ id: string; slug: string; path: string }>(), +}; + +before(() => { + cy.clearLocalStorage().then(async () => { + cy.task('seedTarget').then(({ refreshToken, targets }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + ctx.targetDevelopment = targets.development; + ctx.targetProduction = targets.production; + }); + }); +}); + +const visitTargetDevelopment = () => cy.visit(`/${ctx.targetDevelopment.path}/laboratory`); +const visitTargetProduction = () => cy.visit(`/${ctx.targetProduction.path}/laboratory`); +const openPreflightTab = () => cy.get(selectors.buttonGraphiQLPreflight).click(); + +describe('tab editor', () => { + it('if state empty, is null', () => { + visitTargetDevelopment(); + openPreflightTab(); + expect( + window.localStorage.getItem( + environmentVariablesStorageKey.scoped(ctx.targetDevelopment.slug), + ), + ).equals(null); + expect(window.localStorage.getItem(environmentVariablesStorageKey.global)).equals(null); + // cy.dataCy('preflight-editor-mini').should('have.text', ''); + }); + + it('if state just global value, shows that', () => { + window.localStorage.setItem(environmentVariablesStorageKey.global, data.envarsJson); + visitTargetDevelopment(); + openPreflightTab(); + cy.contains(data.envarsJson); + }); + + it.only('if state just scoped value, shows that', () => { + window.localStorage.setItem( + environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id), + data.envarsJson, + ); + visitTargetDevelopment(); + openPreflightTab(); + cy.contains(data.envarsJson); + }); +}); diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory-preflight.cy.ts index 02b2cc3eca..645dd3931f 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory-preflight.cy.ts @@ -58,7 +58,6 @@ function setEditorScript(script: string) { describe('Laboratory > Preflight Script', () => { // https://github.com/graphql-hive/console/pull/6450 it('regression: loads even if local storage is set to {}', () => { - // todo update to have a target id window.localStorage.setItem('hive:laboratory:environment', '{}'); cy.visit(`/${data.slug}/laboratory`); cy.get(selectors.buttonGraphiQLPreflight).click(); diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index d48dc568dc..364d0ec443 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -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, diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index 13acf8f95e..f7e3b49853 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -1,20 +1,27 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry'; export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) { const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; + const versionedEntrySerialized = versionedEntry.map(_ => _.key).join(','); - const [value, setValue] = useState(() => { + 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(versionedEntry[0].key, value); setValue(value); }, - [setValue], + [getInitialValue], ); return [value, set] as const; diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index d231900a21..4d7b3af1fd 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -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 | null; }) { @@ -159,8 +167,8 @@ export function usePreflight(args: { ); const [environmentVariables, setEnvironmentVariables] = useLocalStorage( [ - { key: `hive/target:${target?.id ?? '__null__'}/laboratory/environment-variables` }, - { key: 'hive:laboratory:environment' }, + { key: environmentVariablesStorageKey.scoped(target?.id) }, + { key: environmentVariablesStorageKey.global }, ], '', ); From f4bdfe6480abb9f5f1268fe4498b7e0f2410b990 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 14 Feb 2025 16:17:19 -0500 Subject: [PATCH 06/11] e2e tests that work --- cypress.config.ts | 5 +- .../laboratory-environment-variables.cy.ts | 64 +++++++++++-------- cypress/e2e/laboratory-preflight.cy.ts | 8 +-- cypress/support/e2e.ts | 1 + cypress/support/testkit.ts | 41 ++++++++++++ package.json | 1 + .../app/src/lib/preflight/graphiql-plugin.tsx | 2 - pnpm-lock.yaml | 13 ++++ 8 files changed, 103 insertions(+), 32 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index 41ac7a1225..3d20a57d9f 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -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) { @@ -25,7 +26,9 @@ export default defineConfig({ defaultCommandTimeout: 15_000, // sometimes the app takes longer to load, especially in the CI retries: isCI ? 2 : 0, e2e: { - setupNodeEvents(on) { + setupNodeEvents(on, config) { + cypressPluginLocalStorageCommands(on, config); + on('task', { async seedTarget() { const owner = await seed.createOwner(); diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts index b65134d1da..4f4796e3fe 100644 --- a/cypress/e2e/laboratory-environment-variables.cy.ts +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -1,3 +1,9 @@ +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"]', @@ -30,55 +36,63 @@ const data = { envarsJson: '{"foo":"123"}', }; -const as = <$Type>() => undefined as $Type; - const ctx = { - // test export interfaces from testKit + // 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.clearLocalStorage().then(async () => { - cy.task('seedTarget').then(({ refreshToken, targets }: any) => { - cy.setCookie('sRefreshToken', refreshToken); - ctx.targetDevelopment = targets.development; - ctx.targetProduction = targets.production; - }); + cy.task('seedTarget').then(({ refreshToken, targets }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + ctx.targetDevelopment = targets.development; + ctx.targetProduction = targets.production; }); }); -const visitTargetDevelopment = () => cy.visit(`/${ctx.targetDevelopment.path}/laboratory`); -const visitTargetProduction = () => cy.visit(`/${ctx.targetProduction.path}/laboratory`); +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(); - expect( - window.localStorage.getItem( - environmentVariablesStorageKey.scoped(ctx.targetDevelopment.slug), - ), - ).equals(null); - expect(window.localStorage.getItem(environmentVariablesStorageKey.global)).equals(null); - // cy.dataCy('preflight-editor-mini').should('have.text', ''); + storageTargetDevelopmentGet().should('equal', null); + storageGlobalGet().should('equal', null); }); - it('if state just global value, shows that', () => { - window.localStorage.setItem(environmentVariablesStorageKey.global, data.envarsJson); + it('if state just scoped value, shows that', () => { + storageTargetDevelopmentSet(data.envarsJson); visitTargetDevelopment(); openPreflightTab(); cy.contains(data.envarsJson); + storageGlobalGet().should('equal', null); }); - it.only('if state just scoped value, shows that', () => { - window.localStorage.setItem( - environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id), - data.envarsJson, - ); + 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); }); }); diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory-preflight.cy.ts index 645dd3931f..919d831bc7 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory-preflight.cy.ts @@ -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(); }); @@ -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', () => { diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 7149352f73..94986a1df5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,4 +1,5 @@ import './commands'; +import 'cypress-localstorage-commands'; Cypress.on('uncaught:exception', (_err, _runnable) => { return false; diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index 44f20faae2..7a00cb3307 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -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); } diff --git a/package.json b/package.json index 3773cbd104..dfb46f06a2 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-localstorage-commands": "^2.2.7", "dotenv": "16.4.7", "eslint": "8.57.1", "eslint-plugin-cypress": "4.1.0", diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index 4d7b3af1fd..1f96fe0c69 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -160,8 +160,6 @@ export function usePreflight(args: { const target = useFragment(PreflightScript_TargetFragment, args.target); const [isEnabled, setIsEnabled] = useLocalStorageJson( - // todo - // 'hive:laboratory:isPreflightEnabled', 'hive:laboratory:isPreflightScriptEnabled', z.boolean().default(false), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26a9451b34..576e6e6b8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: cypress: specifier: 13.17.0 version: 13.17.0 + cypress-localstorage-commands: + specifier: ^2.2.7 + version: 2.2.7(cypress@13.17.0) dotenv: specifier: 16.4.7 version: 16.4.7 @@ -9397,6 +9400,12 @@ packages: csv-stringify@6.5.2: resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + cypress-localstorage-commands@2.2.7: + resolution: {integrity: sha512-hUe6hz/3TD9Ph70CUHJLSiTzL0INikUQ4W3CRd7XmaGCDjwR6jGAlvTCGmxZ6yGc4Mq/WN6L8xJAu+dOrIvYCA==} + engines: {node: '>=14.0.0'} + peerDependencies: + cypress: '>=2.1.0' + cypress@13.17.0: resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} @@ -26078,6 +26087,10 @@ snapshots: csv-stringify@6.5.2: {} + cypress-localstorage-commands@2.2.7(cypress@13.17.0): + dependencies: + cypress: 13.17.0 + cypress@13.17.0: dependencies: '@cypress/request': 3.0.6 From b7729cc58cdbe93184b54c9cdbace9633e92e0d7 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 14 Feb 2025 16:19:11 -0500 Subject: [PATCH 07/11] factor out --- .../laboratory-environment-variables.cy.ts | 38 +++---------------- cypress/support/testkit.ts | 30 +++++++++++++++ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts index 4f4796e3fe..e95b9c948d 100644 --- a/cypress/e2e/laboratory-environment-variables.cy.ts +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -1,35 +1,9 @@ -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', -}; +import { + as, + environmentVariablesStorageKey, + persistAuthenticationCookies, + selectors, +} from '../support/testkit'; const data = { envars: { foo: '123' }, diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index 7a00cb3307..4d77ff00b3 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -1,3 +1,33 @@ +export const as = <$Type>() => undefined as $Type; + +// 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[], From b92f941eac8fa1cafada1222d0ee4f983aa18195 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 17 Feb 2025 09:49:07 -0500 Subject: [PATCH 08/11] type import --- cypress/e2e/laboratory-environment-variables.cy.ts | 14 ++++++++------ cypress/support/testkit.ts | 2 ++ integration-tests/testkit/seed.ts | 6 ++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts index e95b9c948d..91611176b6 100644 --- a/cypress/e2e/laboratory-environment-variables.cy.ts +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -1,8 +1,8 @@ import { - as, environmentVariablesStorageKey, persistAuthenticationCookies, selectors, + Target, } from '../support/testkit'; const data = { @@ -10,12 +10,14 @@ const data = { envarsJson: '{"foo":"123"}', }; +interface Ctx { + targetDevelopment: Target; + targetProduction: Target; + cookies: Cypress.Cookie[]; +} 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[], -}; + cookies: [], +} as Ctx; before(() => { cy.task('seedTarget').then(({ refreshToken, targets }: any) => { diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index 4d77ff00b3..fe3e302b4a 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -1,5 +1,7 @@ export const as = <$Type>() => undefined as $Type; +export { 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 diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 364d0ec443..4c5a6b09c5 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -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 = { From 84b7d52c66b8e10c0994d17ed3ced3236efa9610 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 17 Feb 2025 10:30:28 -0500 Subject: [PATCH 09/11] last e2e test --- .../laboratory-environment-variables.cy.ts | 87 +++++++++++++++---- cypress/e2e/laboratory-preflight.cy.ts | 2 +- cypress/support/testkit.ts | 2 +- pnpm-lock.yaml | 20 ++--- 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts index 91611176b6..d4a155a3a0 100644 --- a/cypress/e2e/laboratory-environment-variables.cy.ts +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -2,12 +2,14 @@ import { environmentVariablesStorageKey, persistAuthenticationCookies, selectors, - Target, + type Target, } from '../support/testkit'; const data = { - envars: { foo: '123' }, - envarsJson: '{"foo":"123"}', + globalEnvars: { foo: '123' }, + globalEnvarsJson: '{"foo":"123"}', + scopedEnvars: { bar: '456' }, + targetEnvarsJson: '{"bar":"456"}', }; interface Ctx { @@ -29,21 +31,27 @@ before(() => { 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 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(() => { - cy.removeLocalStorage(environmentVariablesStorageKey.global); - cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id)); - cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id)); + storageGlobalRemove(); + storageTargetDevelopmentRemove(); + storageTargetProductionRemove(); }); describe('tab editor', () => { @@ -54,21 +62,62 @@ describe('tab editor', () => { storageGlobalGet().should('equal', null); }); - it('if state just scoped value, shows that', () => { - storageTargetDevelopmentSet(data.envarsJson); + it('if storage just has target-scope value, value used', () => { + storageTargetDevelopmentSet(data.targetEnvarsJson); visitTargetDevelopment(); openPreflightTab(); - cy.contains(data.envarsJson); - storageGlobalGet().should('equal', null); + cy.contains(data.targetEnvarsJson); }); - it('if state just global value, copied to scoped, shows that', () => { - storageTargetDevelopmentGet().should('equal', null); - storageGlobalSet(data.envarsJson); + 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.envarsJson); - storageTargetDevelopmentGet().should('equal', data.envarsJson); - storageGlobalGet().should('equal', data.envarsJson); + 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]'); + } + }); +} diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory-preflight.cy.ts index 919d831bc7..50f69cf21e 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory-preflight.cy.ts @@ -33,7 +33,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) { +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 => { diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index fe3e302b4a..01827e4a1d 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -1,6 +1,6 @@ export const as = <$Type>() => undefined as $Type; -export { Target } from '../../integration-tests/testkit/seed'; +export type { Target } from '../../integration-tests/testkit/seed'; // todo: instead of copying this, import it from core utility lib. export const environmentVariablesStorageKey = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00372f8c66..9126297b15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16244,8 +16244,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16352,11 +16352,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16395,6 +16395,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16528,11 +16529,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16571,7 +16572,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -16685,7 +16685,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -16804,7 +16804,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -16979,7 +16979,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 895d22032204292cf2b17b4bfa81b218bba2e288 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 17 Feb 2025 10:37:25 -0500 Subject: [PATCH 10/11] optimize --- .../web/app/src/lib/hooks/use-local-storage.ts | 16 ++++++++++++---- packages/web/app/src/lib/versioned-entry.ts | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index f7e3b49853..75f71e1871 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -1,9 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; -import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry'; +import { + readVersionedEntryLocalStorage, + serializeEntrySpec, + serializeVersionedEntrySpec, + VersionedEntrySpec, +} from '../versioned-entry'; export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) { const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; - const versionedEntrySerialized = versionedEntry.map(_ => _.key).join(','); + const versionedEntrySerialized = serializeVersionedEntrySpec(versionedEntry); + + const versionedEntryLatest = versionedEntry[0]; + const versionedEntryLatestSerialized = serializeEntrySpec(versionedEntryLatest); const getInitialValue = useCallback(() => { const value = readVersionedEntryLocalStorage({ spec: versionedEntry }); @@ -18,10 +26,10 @@ export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: const set = useCallback( (value: string) => { - localStorage.setItem(versionedEntry[0].key, value); + localStorage.setItem(versionedEntryLatest.key, value); setValue(value); }, - [getInitialValue], + [versionedEntryLatestSerialized], ); return [value, set] as const; diff --git a/packages/web/app/src/lib/versioned-entry.ts b/packages/web/app/src/lib/versioned-entry.ts index e68711bd08..d131ad094f 100644 --- a/packages/web/app/src/lib/versioned-entry.ts +++ b/packages/web/app/src/lib/versioned-entry.ts @@ -16,6 +16,9 @@ export type KeyValueStoreDatabase = Record; export type VersionedEntrySpec = readonly [EntrySpec, ...(readonly EntrySpec[])]; +export const serializeVersionedEntrySpec = (versionedEntrySpec: VersionedEntrySpec) => + versionedEntrySpec.map(serializeEntrySpec).join(','); + interface EntrySpec { key: string; // todo once we have use-case @@ -23,6 +26,8 @@ interface EntrySpec { // fromPrevious: } +export const serializeEntrySpec = (entrySpec: EntrySpec) => entrySpec.key; + // -------------------------------------------------------------------- // Versioned Entry Functions // -------------------------------------------------------------------- From 8a1c77f9b1566ad7b9c083096f037f8d7016ccab Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 18 Feb 2025 09:24:30 -0500 Subject: [PATCH 11/11] thing --- integration-tests/testkit/seed.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 4c5a6b09c5..4335e61b35 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -51,8 +51,6 @@ import { import * as GraphQLSchema from './gql/graphql'; import { BreakingChangeFormula, - OrganizationAccessScope, - ProjectAccessScope, ProjectType, SchemaPolicyInput, TargetAccessScope, @@ -63,9 +61,9 @@ import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique } from './utils'; export interface Target { - id: string - path: string - slug: string + id: string; + path: string; + slug: string; } export function initSeed() { @@ -217,9 +215,9 @@ export function initSeed() { ).then(r => r.expectNoGraphQLErrors()); const project = projectResult.createProject.ok!.createdProject; - const targets = projectResult.createProject.ok!.createdTargets.map(_ => ({ - ..._, - path: `/${organization.slug}/${project.slug}/${_.slug}`, + const targets = projectResult.createProject.ok!.createdTargets.map(target => ({ + ...target, + path: `/${organization.slug}/${project.slug}/${target.slug}`, })); const target = targets[0];