Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5672593
run preflight prompts during operation runs, not just Test
jonathanawesome Aug 11, 2026
05d2020
restore preflight prompt title, placeholder and description
jonathanawesome Aug 11, 2026
1e35662
add cancel to the preflight prompt and keep the dialog in the lab's v…
jonathanawesome Aug 11, 2026
681c932
give preflight runs a single cleanup path
jonathanawesome Aug 11, 2026
28ec283
let preflight runs stream logs and be stopped
jonathanawesome Aug 11, 2026
b4bd71f
seed the dev harness with a preflight demo script and make clear empt…
jonathanawesome Aug 11, 2026
ed9d113
kill preflight scripts that run longer than 30 seconds
jonathanawesome Aug 11, 2026
284b0ae
keep non-scalar values out of the preflight environment
jonathanawesome Aug 11, 2026
9e3d99c
warn that preflight scripts are readable and stored as plain text
jonathanawesome Aug 11, 2026
90598b8
report script line and column on preflight logs and errors
jonathanawesome Aug 11, 2026
eab5bd1
add changeset
jonathanawesome Aug 11, 2026
d768795
Merge branch 'main' into fix-laboratory-preflight-prompt-during-opera…
jonathanawesome Aug 11, 2026
a18f046
remove unused eslint-disable directive
jonathanawesome Aug 11, 2026
99ccb14
run the new laboratory specs in happy-dom like the rest
jonathanawesome Aug 11, 2026
26373e7
remove old plan file
jonathanawesome Aug 11, 2026
f7f7415
simplify changeset
jonathanawesome Aug 11, 2026
0884cdd
Merge branch 'main' into fix-laboratory-preflight-prompt-during-opera…
jonathanawesome Aug 11, 2026
ab73210
keep host-specific claims out of the preflight warning
jonathanawesome Aug 11, 2026
099b815
update changeset with new prop notice
jonathanawesome Aug 11, 2026
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
13 changes: 13 additions & 0 deletions .changeset/plenty-donuts-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@graphql-hive/laboratory': patch
---

Laboratory: restore preflight behaviour that was lost when the lab moved into this package, and
stop a script from being able to wedge a run.

- `lab.prompt(title, defaultValue, { placeholder, description })`. **The first argument is now
the field label rather than the input's placeholder**.
- `lab.environment.set()` accepts strings, numbers, booleans and `null`; anything else is
dropped with a warning, since environment values are interpolated into headers as text.
- New `preflightNotice` prop. The preflight editor warns that scripts run in the reader's
browser; hosts that share one script between people should say so here.
23 changes: 23 additions & 0 deletions packages/libraries/laboratory/dev/preflight.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { devPreflight, devPreflightScenarios } from './preflight';

describe('dev preflight', () => {
// The seed is only ever parsed inside the worker, so a syntax error in it would first
// show up as a broken dev harness in the browser.
it('parses in the same wrapper the worker uses', () => {
const AsyncFunction = async function () {}.constructor as new (...args: string[]) => unknown;

expect(
() => new AsyncFunction('lab', 'CryptoJS', `with(lab){${devPreflight.script}}`),
).not.toThrow();
});

it.each(devPreflightScenarios)('handles the %s scenario it offers', scenario => {
expect(devPreflight.script).toContain(`'${scenario}'`);
});

// Seeded off, the Query tab's Run would skip preflight entirely and the path this branch
// fixes would be unreachable in the harness.
it('is seeded enabled', () => {
expect(devPreflight.enabled).toBe(true);
});
});
56 changes: 56 additions & 0 deletions packages/libraries/laboratory/dev/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Seed preflight for the dev harness, applied by src/main.tsx on every load. One script
* covers the behaviours that are otherwise fiddly to reach by hand: the prompt and its
* metadata, cancelling, streaming logs, stopping a run, the execution timeout, and the
* warning for environment values that cannot be interpolated.
*
* It is seeded enabled, so running an operation from the Query tab prompts too — that path
* is the one that silently did nothing before this branch.
*/
import type { LaboratoryPreflight } from '../src/lib/preflight';

export const devPreflightScenarios = ['logs', 'slow', 'loop', 'env', 'headers', 'error'] as const;

const script = `const scenario = await lab.prompt('Demo scenario', 'logs', {
placeholder: '${devPreflightScenarios.join(' | ')}',
description: 'Pick which preflight behaviour to exercise. Cancel to skip the run.',
});

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

if (scenario === null) {
console.info('Prompt cancelled, so the script stops here and the request still goes out.');
} else if (scenario === 'logs') {
// Watch the log pane fill in while this runs, rather than all at once at the end.
for (let step = 1; step <= 10; step++) {
console.log('step ' + step + ' of 10');
await sleep(300);
}

console.info('Done.');
} else if (scenario === 'slow') {
console.warn('Waiting a minute. Press Stop, or leave it to hit the execution timeout.');
await sleep(60000);
console.log('Only reached if nothing stops the run.');
} else if (scenario === 'loop') {
// Blocks the worker thread outright, so nothing but Stop or the timeout ends it.
console.warn('Blocking the worker. Stop or the timeout has to end this one.');
while (true) {}
} else if (scenario === 'env') {
lab.environment.set('kept', 'a string survives');
lab.environment.set('dropped', { nope: true });
console.log('Check the Env tab: only "kept" should be there.');
} else if (scenario === 'headers') {
lab.request.headers.set('x-demo-token', 'abc123');
console.log('Header set; the run reports the headers it produced.');
} else if (scenario === 'error') {
console.log('This log should report the line it was written on, and so should the throw.');
throw new Error('boom from the error scenario');
} else {
console.error('Unknown scenario: ' + scenario);
}`;

export const devPreflight: LaboratoryPreflight = {
enabled: true,
script,
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useContext } from 'react';
import { createContext, useContext, type ReactNode } from 'react';
import { IntrospectionQuery } from 'graphql';
import {
type LaboratoryCollection,
Expand Down Expand Up @@ -27,6 +27,7 @@ import {
import type {
LaboratoryPreflight,
LaboratoryPreflightActions,
LaboratoryPreflightPromptRequest,
LaboratoryPreflightState,
} from '../../lib/preflight';
import type {
Expand All @@ -51,6 +52,7 @@ type LaboratoryContextState = LaboratoryCollectionsState &
isFullScreen?: boolean;
enableFullScreen?: boolean;
enableDocs?: boolean;
preflightNotice?: ReactNode;
theme?: 'light' | 'dark';
};
type LaboratoryContextActions = LaboratoryCollectionsActions &
Expand All @@ -67,11 +69,7 @@ type LaboratoryContextActions = LaboratoryCollectionsActions &
openAddCollectionDialog?: () => void;
openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => void;
openPreflightPromptModal?: (request: LaboratoryPreflightPromptRequest) => void;
goToFullScreen?: () => void;
exitFullScreen?: () => void;
checkPermissions?: (
Expand Down Expand Up @@ -140,11 +138,7 @@ export interface LaboratoryApi {
openAddCollectionDialog?: () => void;
openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => void;
openPreflightPromptModal?: (request: LaboratoryPreflightPromptRequest) => void;
isFullScreen?: boolean;
/** Show the full screen control. Off for hosts that already fill the viewport. */
enableFullScreen?: boolean;
Expand All @@ -154,6 +148,8 @@ export interface LaboratoryApi {
* `defaultSchemaIntrospection` must build that with descriptions itself.
*/
enableDocs?: boolean;
/** Appended to the preflight warning. For hosts that share scripts between people. */
preflightNotice?: ReactNode;
goToFullScreen?: () => void;
exitFullScreen?: () => void;
defaultPreflight?: LaboratoryPreflight | null;
Expand Down
136 changes: 19 additions & 117 deletions packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useHistory } from '../../lib/history';
import { keepEditorMouseMovesInShadowRoot } from '../../lib/monaco-shadow-dom';
import { useOperations } from '../../lib/operations';
import { LaboratoryPluginTab, usePlugins } from '../../lib/plugins';
import { usePreflight } from '../../lib/preflight';
import { usePreflight, usePreflightPrompt } from '../../lib/preflight';
import { useSettings } from '../../lib/settings';
import { LaboratoryTabCustom, useTabs } from '../../lib/tabs';
import { useTests } from '../../lib/tests';
Expand Down Expand Up @@ -76,6 +76,7 @@ import { History } from './history';
import { HistoryItem } from './history-item';
import { Operation } from './operation';
import { Preflight } from './preflight';
import { PreflightPromptModal } from './preflight-prompt-modal';
import { Settings } from './settings';
import { Tabs } from './tabs';

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

const PreflightPromptModal = (props: {
open: boolean;
onOpenChange: (open: boolean) => void;
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
const form = useForm({
defaultValues: {
value: props.defaultValue || null,
},
validators: {
onSubmit: z.object({
value: z.string().min(1, 'Value is required').nullable(),
}),
},
onSubmit: ({ value }) => {
props.onSubmit?.(value.value || null);
props.onOpenChange(false);
form.reset();
},
});

return (
<Dialog
open={props.open}
onOpenChange={open => {
if (!form.state.isSubmitted) {
void form.handleSubmit();
}

props.onOpenChange(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Preflight prompt</DialogTitle>
</DialogHeader>
<DialogDescription>Enter values for the preflight script.</DialogDescription>
<form
id="preflight-prompt-form"
onSubmit={e => {
e.preventDefault();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="value">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
id={field.name}
name={field.name}
value={field.state.value || ''}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder={props.placeholder}
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
</FieldGroup>
</form>
<DialogFooter>
<Button
type="submit"
form="preflight-prompt-form"
onClick={() => {
void form.handleSubmit();
}}
>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

const LaboratoryContent = () => {
const {
activeTab,
Expand Down Expand Up @@ -572,13 +488,30 @@ export const Laboratory = (
[props.permissions],
);

// Called before the API hooks so `usePreflight` can answer `lab.prompt()` calls from
// scripts running as part of an operation, not just from the preflight Test button.
const {
isPreflightPromptModalOpen,
setIsPreflightPromptModalOpen,
preflightPromptModalProps,
openPreflightPromptModal,
closePreflightPromptModal,
} = usePreflightPrompt();

const settingsApi = useSettings(props);
const envApi = useEnv(props);
const preflightApi = usePreflight({
...props,
envApi,
openPreflightPromptModal,
closePreflightPromptModal,
});

const { abortPreflight } = preflightApi;

// A run outlives the lab otherwise: the worker keeps going after the host unmounts.
useEffect(() => abortPreflight, [abortPreflight]);

const pluginsApi = usePlugins(props);
const testsApi = useTests(props);
const tabsApi = useTabs(props);
Expand Down Expand Up @@ -669,37 +602,6 @@ export const Laboratory = (
},
});

const [isPreflightPromptModalOpen, setIsPreflightPromptModalOpen] = useState(false);

const [preflightPromptModalProps, setPreflightPromptModalProps] = useState<{
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}>({
placeholder: '',
defaultValue: undefined,
onSubmit: undefined,
});

const openPreflightPromptModal = useCallback(
(props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
setPreflightPromptModalProps({
placeholder: props.placeholder,
defaultValue: props.defaultValue,
onSubmit: props.onSubmit,
});

setTimeout(() => {
setIsPreflightPromptModalOpen(true);
}, 200);
},
[],
);

const [container, setContainer] = useState<HTMLDivElement | null>(null);

const [isFullScreen, setIsFullScreen] = useState(false);
Expand Down
Loading
Loading