Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#6065: fix rendering of user-defined bricks in the sidebar #6067

Merged
merged 1 commit into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/blocks/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { BusinessError, CancelError } from "@/errors/businessErrors";
import { type MessageContext } from "@/types/loggerTypes";
import { type RegistryId } from "@/types/registryTypes";
import { type Schema } from "@/types/schemaTypes";
import { type BrickArgs, type BrickArgsContext } from "@/types/runtimeTypes";

export class PipelineConfigurationError extends BusinessError {
override name = "PipelineConfigurationError";
Expand All @@ -45,16 +46,16 @@ export class HeadlessModeError extends Error {

public readonly blockId: RegistryId;

public readonly args: unknown;
public readonly args: BrickArgs;

public readonly ctxt: unknown;
public readonly ctxt: BrickArgsContext;

public readonly loggerContext: MessageContext;

constructor(
blockId: RegistryId,
args: unknown, // BlockArg
ctxt: unknown, // BrickArgsContext
args: BrickArgs,
ctxt: BrickArgsContext,
loggerContext: MessageContext
) {
super(`${blockId} is a renderer`);
Expand Down
3 changes: 3 additions & 0 deletions src/blocks/transformers/brickFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import Run from "@/blocks/transformers/controlFlow/Run";
import { GetPageState } from "@/blocks/effects/pageState";
import { cloneDeep } from "lodash";
import { extraEmptyModStateContext } from "@/runtime/extendModVariableContext";
import { setContext } from "@/testUtils/detectPageMock";

setContext("contentScript");

beforeEach(() => {
blockRegistry.clear();
Expand Down
115 changes: 90 additions & 25 deletions src/blocks/transformers/brickFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { BrickABC, type Brick } from "@/types/brickTypes";
import { type Brick, BrickABC } from "@/types/brickTypes";
import { readerFactory } from "@/blocks/readers/factory";
import {
type Schema as ValidatorSchema,
Expand All @@ -35,25 +35,47 @@ import {
type ApiVersion,
type BrickArgs,
type BrickOptions,
validateBrickArgsContext,
} from "@/types/runtimeTypes";
import { type Schema, type UiSchema } from "@/types/schemaTypes";
import {
type Metadata,
type RegistryId,
type SemVerString,
} from "@/types/registryTypes";
import { type Metadata, type SemVerString } from "@/types/registryTypes";
import { type UnknownObject } from "@/types/objectTypes";
import { inputProperties } from "@/helpers";

import { isPipelineExpression } from "@/utils/expressionUtils";
import { isContentScript } from "webext-detect-page";
import { getTopLevelFrame } from "webext-messenger";
import { uuidv4 } from "@/types/helpers";
import { isSpecificError } from "@/errors/errorHelpers";
import { HeadlessModeError } from "@/blocks/errors";
import BackgroundLogger from "@/telemetry/BackgroundLogger";
import { runHeadlessPipeline } from "@/contentScript/messenger/api";

type BrickDefinition = {
/**
* The runtime version to use when running the Brick.
*/
apiVersion?: ApiVersion;

/**
* User-defined brick kind.
*/
kind: "component";

/**
* Registry package metadata.
*/
metadata: Metadata;
defaultOptions: Record<string, string>;

/**
* The wrapped bricks.
*/
pipeline: BrickConfig | BrickPipeline;

/**
* JSON Schema for brick inputs.
*/
inputSchema: Schema;

/**
* An optional RJSF uiSchema for inputs.
*
Expand All @@ -63,18 +85,21 @@ type BrickDefinition = {
*/
uiSchema?: UiSchema;

/**
* An optional JSON Output Schema for the brick.
*/
outputSchema?: Schema;

/**
* The default output key to use for the brick
* @since 1.7.34
*/
defaultOutputKey?: string;

// Mapping from `key` -> `serviceId`
services?: Record<string, RegistryId>;
};

/**
* Throw an error if the brick definition is invalid with respect to the brick meta-schema.
*/
function validateBrickDefinition(
component: unknown
): asserts component is BrickDefinition {
Expand All @@ -97,9 +122,7 @@ function validateBrickDefinition(
/**
* A non-native (i.e., non-JS) Brick. Typically defined in YAML/JSON.
*/
class ExternalBlock extends BrickABC {
public readonly component: BrickDefinition;

class UserDefinedBrick extends BrickABC {
readonly apiVersion: ApiVersion;

readonly inputSchema: Schema;
Expand All @@ -108,9 +131,10 @@ class ExternalBlock extends BrickABC {

readonly version: SemVerString;

constructor(component: BrickDefinition) {
constructor(public readonly component: BrickDefinition) {
const { id, name, description, icon, version } = component.metadata;
super(id, name, description, icon);
// Fall back to v1 for backward compatability
this.apiVersion = component.apiVersion ?? "v1";
this.component = component;
this.inputSchema = this.component.inputSchema;
Expand Down Expand Up @@ -240,21 +264,62 @@ class ExternalBlock extends BrickABC {
// Blocks only have inputs, they can't pick up free variables from the environment
const initialValues: InitialValues = {
input: argsWithClosures,
// OptionsArgs are set at the blueprint level. For composite bricks, the options should be passed in
// as part of the brick inputs
// OptionsArgs are set at the blueprint level. For user-defined bricks, are passed via brick inputs
optionsArgs: undefined,
// Services are passed as inputs to the brick
serviceContext: undefined,
root: options.root,
};

return reducePipeline(this.component.pipeline, initialValues, {
logger: options.logger,
headless: options.headless,
// The component uses its declared version of the runtime API, regardless of what version of the runtime
// is used to call the component
...apiVersionOptions(this.component.apiVersion),
});
if (isContentScript()) {
// Already in the contentScript, run the pipeline directly.
return reducePipeline(this.component.pipeline, initialValues, {
logger: options.logger,
headless: options.headless,
// The component uses its declared version of the runtime API, regardless of what version of the runtime
// is used to call the component
...apiVersionOptions(this.component.apiVersion),
});
}

// The brick is being run as a renderer from a modal or a sidebar. Run the logic in the contentScript and return the
// renderer. The caller can't run the whole brick in the contentScript because renderers can return React
// Components which can't be serialized across messenger boundaries.

// TODO: call top-level contentScript directly after https://github.com/pixiebrix/webext-messenger/issues/72
const topLevelFrame = await getTopLevelFrame();

try {
return await runHeadlessPipeline(topLevelFrame, {
nonce: uuidv4(),
// OptionsArgs are set at the blueprint level. For user-defined bricks, are passed via brick inputs
context: validateBrickArgsContext({
"@input": argsWithClosures,
"@options": {},
}),
pipeline: castArray(this.component.pipeline),
options: apiVersionOptions(this.apiVersion),
messageContext: options.logger.context,
meta: {
// Don't trace within user-defined bricks
extensionId: options.logger.context.extensionId,
branches: [],
runId: null,
},
});
} catch (error) {
if (isSpecificError(error, HeadlessModeError)) {
const continuation = error;
const renderer = await blockRegistry.lookup(continuation.blockId);
return renderer.run(continuation.args, {
...options,
ctxt: continuation.ctxt,
logger: new BackgroundLogger(continuation.loggerContext),
});
}

throw error;
}
}
}

Expand All @@ -271,5 +336,5 @@ export function fromJS(component: UnknownObject): Brick {
}

validateBrickDefinition(component);
return new ExternalBlock(component);
return new UserDefinedBrick(component);
}
4 changes: 2 additions & 2 deletions src/components/documentBuilder/render/ButtonElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import React, { useContext, useState } from "react";
import { type BrickPipeline } from "@/blocks/types";
import AsyncButton, { type AsyncButtonProps } from "@/components/AsyncButton";
import { runEffectPipeline } from "@/contentScript/messenger/api";
import { runHeadlessPipeline } from "@/contentScript/messenger/api";
import { uuidv4 } from "@/types/helpers";
import DocumentContext from "@/components/documentBuilder/render/DocumentContext";
import { type Except } from "type-fest";
Expand Down Expand Up @@ -68,7 +68,7 @@ const ButtonElement: React.FC<ButtonElementProps> = ({
const topLevelFrame = await getTopLevelFrame();

try {
await runEffectPipeline(topLevelFrame, {
await runHeadlessPipeline(topLevelFrame, {
nonce: uuidv4(),
context: ctxt,
pipeline: onClick,
Expand Down
2 changes: 1 addition & 1 deletion src/contentScript/messenger/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const cancelSelect = getMethod("CANCEL_SELECT_ELEMENT");
export const selectElement = getMethod("SELECT_ELEMENT");

export const runRendererPipeline = getMethod("RUN_RENDERER_PIPELINE");
export const runEffectPipeline = getMethod("RUN_EFFECT_PIPELINE");
export const runHeadlessPipeline = getMethod("RUN_HEADLESS_PIPELINE");
export const runMapArgs = getMethod("RUN_MAP_ARGS");

export const getPageState = getMethod("GET_PAGE_STATE");
Expand Down
6 changes: 3 additions & 3 deletions src/contentScript/messenger/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
selectElement,
} from "@/contentScript/pageEditor/elementPicker";
import {
runEffectPipeline,
runHeadlessPipeline,
runMapArgs,
runRendererPipeline,
} from "@/contentScript/pipelineProtocol";
Expand Down Expand Up @@ -130,7 +130,7 @@ declare global {
SELECT_ELEMENT: typeof selectElement;

RUN_RENDERER_PIPELINE: typeof runRendererPipeline;
RUN_EFFECT_PIPELINE: typeof runEffectPipeline;
RUN_HEADLESS_PIPELINE: typeof runHeadlessPipeline;
RUN_MAP_ARGS: typeof runMapArgs;

NOTIFY_INFO: typeof notify.info;
Expand Down Expand Up @@ -195,7 +195,7 @@ export default function registerMessenger(): void {
SELECT_ELEMENT: selectElement,

RUN_RENDERER_PIPELINE: runRendererPipeline,
RUN_EFFECT_PIPELINE: runEffectPipeline,
RUN_HEADLESS_PIPELINE: runHeadlessPipeline,
RUN_MAP_ARGS: runMapArgs,

NOTIFY_INFO: notify.info,
Expand Down
11 changes: 7 additions & 4 deletions src/contentScript/pipelineProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,23 @@ export async function runRendererPipeline({
throw new BusinessError("Pipeline does not include a renderer");
}

export async function runEffectPipeline({
/**
* Run a pipeline in headless mode.
*/
export async function runHeadlessPipeline({
pipeline,
context,
options,
meta,
messageContext,
}: RunPipelineParams): Promise<void> {
}: RunPipelineParams): Promise<unknown> {
expectContext("contentScript");

if (meta.extensionId == null) {
throw new Error("runEffectPipeline requires meta.extensionId");
throw new Error("runHeadlessPipeline requires meta.extensionId");
}

await reducePipeline(
return reducePipeline(
pipeline,
{
input: context["@input"] ?? {},
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/pipelineTests/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {

import { fromJS } from "@/blocks/transformers/brickFactory";
import { validateSemVerString } from "@/types/helpers";
import { setContext } from "@/testUtils/detectPageMock";

setContext("contentScript");

beforeEach(() => {
blockRegistry.clear();
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/reducePipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ import {
throwIfInvalidInput,
} from "@/runtime/runtimeUtils";
import ConsoleLogger from "@/utils/ConsoleLogger";
import { type ResolvedBrickConfig } from "@/runtime/runtimeTypes";
import {
type ResolvedBrickConfig,
unsafeAssumeValidArg,
} from "@/runtime/runtimeTypes";
import { type RunBlock } from "@/contentScript/runBlockTypes";
import { resolveBlockConfig } from "@/blocks/registry";
import { isObject } from "@/utils";
Expand Down Expand Up @@ -570,7 +573,8 @@ export async function runBlock(

throw new HeadlessModeError(
block.id,
props.args,
// Call to throwIfInvalidInput above ensures args are valid for the brick
unsafeAssumeValidArg(props.args),
props.context,
logger.context
);
Expand Down
3 changes: 1 addition & 2 deletions src/sidebar/PanelBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,7 @@ const PanelBody: React.FunctionComponent<{
console.debug("Running panel body for panel payload", payload);

const block = await blockRegistry.lookup(blockId);
// In the future, the renderer brick should run in the contentScript, not the panel frame
// TODO: https://github.com/pixiebrix/pixiebrix-extension/issues/1939
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This issue should be safe to close once this PR is merged, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can close #1939 once we merge in this PR


const body = await block.run(unsafeAssumeValidArg(args), {
ctxt,
root: null,
Expand Down
14 changes: 8 additions & 6 deletions src/sidebar/RendererComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,29 @@ import { screen } from "shadow-dom-testing-library";
import { act } from "@testing-library/react";
import { SubmitPanelAction } from "@/blocks/errors";
import ConsoleLogger from "@/utils/ConsoleLogger";
import { runEffectPipeline } from "@/contentScript/messenger/api";
import { runHeadlessPipeline } from "@/contentScript/messenger/api";

jest.mock("@/contentScript/messenger/api", () => ({
runEffectPipeline: jest.fn().mockRejectedValue(new Error("not implemented")),
runHeadlessPipeline: jest
.fn()
.mockRejectedValue(new Error("not implemented")),
}));

const runEffectPipelineMock = runEffectPipeline as jest.MockedFunction<
typeof runEffectPipeline
const runHeadlessPipelineMock = runHeadlessPipeline as jest.MockedFunction<
typeof runHeadlessPipeline
>;

describe("RendererComponent", () => {
beforeEach(() => {
runEffectPipelineMock.mockReset();
runHeadlessPipelineMock.mockReset();
});

test("provide onAction to document renderer", async () => {
const runId = uuidv4();
const extensionId = uuidv4();
const onAction = jest.fn();

runEffectPipelineMock.mockRejectedValue(
runHeadlessPipelineMock.mockRejectedValue(
new SubmitPanelAction("submit", { foo: "bar" })
);

Expand Down
Loading