Skip to content

Commit ee824bd

Browse files
Laboratory preflight updates (#8357)
1 parent b8b4499 commit ee824bd

13 files changed

Lines changed: 1574 additions & 222 deletions

File tree

.changeset/plenty-donuts-shave.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@graphql-hive/laboratory': patch
3+
---
4+
5+
Laboratory: restore preflight behaviour that was lost when the lab moved into this package, and
6+
stop a script from being able to wedge a run.
7+
8+
- `lab.prompt(title, defaultValue, { placeholder, description })`. **The first argument is now
9+
the field label rather than the input's placeholder**.
10+
- `lab.environment.set()` accepts strings, numbers, booleans and `null`; anything else is
11+
dropped with a warning, since environment values are interpolated into headers as text.
12+
- New `preflightNotice` prop. The preflight editor warns that scripts run in the reader's
13+
browser; hosts that share one script between people should say so here.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { devPreflight, devPreflightScenarios } from './preflight';
2+
3+
describe('dev preflight', () => {
4+
// The seed is only ever parsed inside the worker, so a syntax error in it would first
5+
// show up as a broken dev harness in the browser.
6+
it('parses in the same wrapper the worker uses', () => {
7+
const AsyncFunction = async function () {}.constructor as new (...args: string[]) => unknown;
8+
9+
expect(
10+
() => new AsyncFunction('lab', 'CryptoJS', `with(lab){${devPreflight.script}}`),
11+
).not.toThrow();
12+
});
13+
14+
it.each(devPreflightScenarios)('handles the %s scenario it offers', scenario => {
15+
expect(devPreflight.script).toContain(`'${scenario}'`);
16+
});
17+
18+
// Seeded off, the Query tab's Run would skip preflight entirely and the path this branch
19+
// fixes would be unreachable in the harness.
20+
it('is seeded enabled', () => {
21+
expect(devPreflight.enabled).toBe(true);
22+
});
23+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Seed preflight for the dev harness, applied by src/main.tsx on every load. One script
3+
* covers the behaviours that are otherwise fiddly to reach by hand: the prompt and its
4+
* metadata, cancelling, streaming logs, stopping a run, the execution timeout, and the
5+
* warning for environment values that cannot be interpolated.
6+
*
7+
* It is seeded enabled, so running an operation from the Query tab prompts too — that path
8+
* is the one that silently did nothing before this branch.
9+
*/
10+
import type { LaboratoryPreflight } from '../src/lib/preflight';
11+
12+
export const devPreflightScenarios = ['logs', 'slow', 'loop', 'env', 'headers', 'error'] as const;
13+
14+
const script = `const scenario = await lab.prompt('Demo scenario', 'logs', {
15+
placeholder: '${devPreflightScenarios.join(' | ')}',
16+
description: 'Pick which preflight behaviour to exercise. Cancel to skip the run.',
17+
});
18+
19+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
20+
21+
if (scenario === null) {
22+
console.info('Prompt cancelled, so the script stops here and the request still goes out.');
23+
} else if (scenario === 'logs') {
24+
// Watch the log pane fill in while this runs, rather than all at once at the end.
25+
for (let step = 1; step <= 10; step++) {
26+
console.log('step ' + step + ' of 10');
27+
await sleep(300);
28+
}
29+
30+
console.info('Done.');
31+
} else if (scenario === 'slow') {
32+
console.warn('Waiting a minute. Press Stop, or leave it to hit the execution timeout.');
33+
await sleep(60000);
34+
console.log('Only reached if nothing stops the run.');
35+
} else if (scenario === 'loop') {
36+
// Blocks the worker thread outright, so nothing but Stop or the timeout ends it.
37+
console.warn('Blocking the worker. Stop or the timeout has to end this one.');
38+
while (true) {}
39+
} else if (scenario === 'env') {
40+
lab.environment.set('kept', 'a string survives');
41+
lab.environment.set('dropped', { nope: true });
42+
console.log('Check the Env tab: only "kept" should be there.');
43+
} else if (scenario === 'headers') {
44+
lab.request.headers.set('x-demo-token', 'abc123');
45+
console.log('Header set; the run reports the headers it produced.');
46+
} else if (scenario === 'error') {
47+
console.log('This log should report the line it was written on, and so should the throw.');
48+
throw new Error('boom from the error scenario');
49+
} else {
50+
console.error('Unknown scenario: ' + scenario);
51+
}`;
52+
53+
export const devPreflight: LaboratoryPreflight = {
54+
enabled: true,
55+
script,
56+
};

packages/libraries/laboratory/src/components/laboratory/context.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createContext, useContext } from 'react';
1+
import { createContext, useContext, type ReactNode } from 'react';
22
import { IntrospectionQuery } from 'graphql';
33
import {
44
type LaboratoryCollection,
@@ -27,6 +27,7 @@ import {
2727
import type {
2828
LaboratoryPreflight,
2929
LaboratoryPreflightActions,
30+
LaboratoryPreflightPromptRequest,
3031
LaboratoryPreflightState,
3132
} from '../../lib/preflight';
3233
import type {
@@ -51,6 +52,7 @@ type LaboratoryContextState = LaboratoryCollectionsState &
5152
isFullScreen?: boolean;
5253
enableFullScreen?: boolean;
5354
enableDocs?: boolean;
55+
preflightNotice?: ReactNode;
5456
theme?: 'light' | 'dark';
5557
};
5658
type LaboratoryContextActions = LaboratoryCollectionsActions &
@@ -67,11 +69,7 @@ type LaboratoryContextActions = LaboratoryCollectionsActions &
6769
openAddCollectionDialog?: () => void;
6870
openUpdateEndpointDialog?: () => void;
6971
openAddTestDialog?: () => void;
70-
openPreflightPromptModal?: (props: {
71-
placeholder: string;
72-
defaultValue?: string;
73-
onSubmit?: (value: string | null) => void;
74-
}) => void;
72+
openPreflightPromptModal?: (request: LaboratoryPreflightPromptRequest) => void;
7573
goToFullScreen?: () => void;
7674
exitFullScreen?: () => void;
7775
checkPermissions?: (
@@ -140,11 +138,7 @@ export interface LaboratoryApi {
140138
openAddCollectionDialog?: () => void;
141139
openUpdateEndpointDialog?: () => void;
142140
openAddTestDialog?: () => void;
143-
openPreflightPromptModal?: (props: {
144-
placeholder: string;
145-
defaultValue?: string;
146-
onSubmit?: (value: string | null) => void;
147-
}) => void;
141+
openPreflightPromptModal?: (request: LaboratoryPreflightPromptRequest) => void;
148142
isFullScreen?: boolean;
149143
/** Show the full screen control. Off for hosts that already fill the viewport. */
150144
enableFullScreen?: boolean;
@@ -154,6 +148,8 @@ export interface LaboratoryApi {
154148
* `defaultSchemaIntrospection` must build that with descriptions itself.
155149
*/
156150
enableDocs?: boolean;
151+
/** Appended to the preflight warning. For hosts that share scripts between people. */
152+
preflightNotice?: ReactNode;
157153
goToFullScreen?: () => void;
158154
exitFullScreen?: () => void;
159155
defaultPreflight?: LaboratoryPreflight | null;

packages/libraries/laboratory/src/components/laboratory/laboratory.tsx

Lines changed: 19 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { useHistory } from '../../lib/history';
2424
import { keepEditorMouseMovesInShadowRoot } from '../../lib/monaco-shadow-dom';
2525
import { useOperations } from '../../lib/operations';
2626
import { LaboratoryPluginTab, usePlugins } from '../../lib/plugins';
27-
import { usePreflight } from '../../lib/preflight';
27+
import { usePreflight, usePreflightPrompt } from '../../lib/preflight';
2828
import { useSettings } from '../../lib/settings';
2929
import { LaboratoryTabCustom, useTabs } from '../../lib/tabs';
3030
import { useTests } from '../../lib/tests';
@@ -76,6 +76,7 @@ import { History } from './history';
7676
import { HistoryItem } from './history-item';
7777
import { Operation } from './operation';
7878
import { Preflight } from './preflight';
79+
import { PreflightPromptModal } from './preflight-prompt-modal';
7980
import { Settings } from './settings';
8081
import { Tabs } from './tabs';
8182

@@ -130,91 +131,6 @@ const addTestFormSchema = z.object({
130131
name: z.string().min(1, 'Name is required'),
131132
});
132133

133-
const PreflightPromptModal = (props: {
134-
open: boolean;
135-
onOpenChange: (open: boolean) => void;
136-
placeholder: string;
137-
defaultValue?: string;
138-
onSubmit?: (value: string | null) => void;
139-
}) => {
140-
const form = useForm({
141-
defaultValues: {
142-
value: props.defaultValue || null,
143-
},
144-
validators: {
145-
onSubmit: z.object({
146-
value: z.string().min(1, 'Value is required').nullable(),
147-
}),
148-
},
149-
onSubmit: ({ value }) => {
150-
props.onSubmit?.(value.value || null);
151-
props.onOpenChange(false);
152-
form.reset();
153-
},
154-
});
155-
156-
return (
157-
<Dialog
158-
open={props.open}
159-
onOpenChange={open => {
160-
if (!form.state.isSubmitted) {
161-
void form.handleSubmit();
162-
}
163-
164-
props.onOpenChange(open);
165-
}}
166-
>
167-
<DialogContent>
168-
<DialogHeader>
169-
<DialogTitle>Preflight prompt</DialogTitle>
170-
</DialogHeader>
171-
<DialogDescription>Enter values for the preflight script.</DialogDescription>
172-
<form
173-
id="preflight-prompt-form"
174-
onSubmit={e => {
175-
e.preventDefault();
176-
void form.handleSubmit();
177-
}}
178-
>
179-
<FieldGroup>
180-
<form.Field name="value">
181-
{field => {
182-
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
183-
return (
184-
<Field data-invalid={isInvalid}>
185-
<Input
186-
id={field.name}
187-
name={field.name}
188-
value={field.state.value || ''}
189-
onBlur={field.handleBlur}
190-
onChange={e => field.handleChange(e.target.value)}
191-
aria-invalid={isInvalid}
192-
placeholder={props.placeholder}
193-
autoComplete="off"
194-
/>
195-
{isInvalid && <FieldError errors={field.state.meta.errors} />}
196-
</Field>
197-
);
198-
}}
199-
</form.Field>
200-
</FieldGroup>
201-
</form>
202-
<DialogFooter>
203-
<Button
204-
type="submit"
205-
form="preflight-prompt-form"
206-
onClick={() => {
207-
void form.handleSubmit();
208-
}}
209-
>
210-
Submit
211-
</Button>
212-
</DialogFooter>
213-
</DialogContent>
214-
</Dialog>
215-
);
216-
};
217-
218134
const LaboratoryContent = () => {
219135
const {
220136
activeTab,
@@ -572,13 +488,30 @@ export const Laboratory = (
572488
[props.permissions],
573489
);
574490

491+
// Called before the API hooks so `usePreflight` can answer `lab.prompt()` calls from
492+
// scripts running as part of an operation, not just from the preflight Test button.
493+
const {
494+
isPreflightPromptModalOpen,
495+
setIsPreflightPromptModalOpen,
496+
preflightPromptModalProps,
497+
openPreflightPromptModal,
498+
closePreflightPromptModal,
499+
} = usePreflightPrompt();
500+
575501
const settingsApi = useSettings(props);
576502
const envApi = useEnv(props);
577503
const preflightApi = usePreflight({
578504
...props,
579505
envApi,
506+
openPreflightPromptModal,
507+
closePreflightPromptModal,
580508
});
581509

510+
const { abortPreflight } = preflightApi;
511+
512+
// A run outlives the lab otherwise: the worker keeps going after the host unmounts.
513+
useEffect(() => abortPreflight, [abortPreflight]);
514+
582515
const pluginsApi = usePlugins(props);
583516
const testsApi = useTests(props);
584517
const tabsApi = useTabs(props);
@@ -669,37 +602,6 @@ export const Laboratory = (
669602
},
670603
});
671604

672-
const [isPreflightPromptModalOpen, setIsPreflightPromptModalOpen] = useState(false);
673-
674-
const [preflightPromptModalProps, setPreflightPromptModalProps] = useState<{
675-
placeholder: string;
676-
defaultValue?: string;
677-
onSubmit?: (value: string | null) => void;
678-
}>({
679-
placeholder: '',
680-
defaultValue: undefined,
681-
onSubmit: undefined,
682-
});
683-
684-
const openPreflightPromptModal = useCallback(
685-
(props: {
686-
placeholder: string;
687-
defaultValue?: string;
688-
onSubmit?: (value: string | null) => void;
689-
}) => {
690-
setPreflightPromptModalProps({
691-
placeholder: props.placeholder,
692-
defaultValue: props.defaultValue,
693-
onSubmit: props.onSubmit,
694-
});
695-
696-
setTimeout(() => {
697-
setIsPreflightPromptModalOpen(true);
698-
}, 200);
699-
},
700-
[],
701-
);
702-
703605
const [container, setContainer] = useState<HTMLDivElement | null>(null);
704606

705607
const [isFullScreen, setIsFullScreen] = useState(false);

0 commit comments

Comments
 (0)