Skip to content
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
64 changes: 47 additions & 17 deletions otp/js/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
PopcornOpts,
PopcornEvent,
PopcornSendOpts,
BeamTarget,
SerializedError,
} from "@swmansion/popcorn-otp";

Expand All @@ -30,6 +31,26 @@ export type Result<T> =
| { ok: true; data: T }
| { ok: false; error: SerializedError };

export function trimLeft(text: string): string {
const leadingBlanks = /^(?:[ \t]*\n)+/;
const trailingBlanks = /(?:\n[ \t]*)+$/;
const trimmedText = text
.replace(leadingBlanks, "")
.replace(trailingBlanks, "");
const lines = trimmedText.split("\n");
const nonBlank = lines.filter((line) => line.trim() !== "");
assert(nonBlank.length > 0);

const indents = nonBlank.map((line) => {
const trimmedLine = line.trimStart();
return line.length - trimmedLine.length;
});
const indentN = Math.min(...indents);

const trimmedLines = lines.map((line) => line.slice(indentN));
return trimmedLines.join("\n");
}

export class Otp {
public readonly events = new Set<PopcornEvent>();

Expand Down Expand Up @@ -59,12 +80,13 @@ export class Otp {
});
});

const result = await this.popcorn.evaluate(async (popcorn): Promise<BootResult> => {
const boot = await popcorn.boot();
return boot.ok
? { ok: true, data: null }
: { ok: false, error: boot.error.serialize() };
});
const result = await this.popcorn.evaluate(
async (popcorn): Promise<BootResult> => {
const boot = await popcorn.boot();
if (boot.ok) return { ok: true, data: null };
return { ok: false, error: boot.error.serialize() };
},
);

if (!result.ok) {
await this.dispose();
Expand All @@ -74,19 +96,22 @@ export class Otp {
}

public async send(
target: string,
target: string | BeamTarget,
payload?: unknown,
opts?: PopcornSendOpts,
): Promise<BootResult> {
const popcorn = this.requirePopcorn();

return await popcorn.evaluate(
async (instance, args) => {
const result = await instance.send(args.target, args.payload, args.opts);

return result.ok
? { ok: true, data: null }
: { ok: false, error: result.error.serialize() };
const result = await instance.send(
args.target,
args.payload,
args.opts,
);

if (result.ok) return { ok: true, data: null };
return { ok: false, error: result.error.serialize() };
},
{ target, payload, opts },
);
Expand Down Expand Up @@ -136,7 +161,11 @@ export class Otp {
this.events.add(event);

for (const [name, waiters] of this.eventWaiters) {
if (hasKey(event, name)) {
if (
typeof event === "object" &&
event !== null &&
Object.hasOwn(event, name)
) {
this.eventWaiters.delete(name);
for (const resolve of waiters) {
resolve(event);
Expand All @@ -147,7 +176,11 @@ export class Otp {

private findEvent(name: string): PopcornEvent | null {
for (const event of this.events) {
if (hasKey(event, name)) {
if (
typeof event === "object" &&
event !== null &&
Object.hasOwn(event, name)
) {
return event;
}
}
Expand All @@ -156,9 +189,6 @@ export class Otp {
}
}

const hasKey = (event: PopcornEvent, key: string) =>
typeof event === "object" && event !== null && key in event;

type Fixtures = {
otp: Otp;
};
Expand Down
126 changes: 71 additions & 55 deletions otp/js/e2e/otp.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { assert, expect, test } from "./helpers";
import { assert, expect, test, trimLeft } from "./helpers";

function evalOpts(code: string) {
return {
beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", code] },
};
}

test("boots", async ({ otp }) => {
const result = await otp.boot({ beam: { assetsUrl: "/otp-assets" } });
Expand Down Expand Up @@ -163,30 +169,25 @@ test("failing boot fails fast (no timeout)", async ({ page }) => {
});

test("handles events in both directions", async ({ otp }) => {
const BRIDGE_BOOT_EVAL = [
"spawn(fun() ->",
" ok = wasm:send(#{direct => true, nested => #{count => 1}}),",
" true = register(bridge, self()),",
" Loop = fun(F) ->",
" ok = wasm:send(#{bridge_ready => true}),",
" receive",
" {wasm, PayloadJson, MetaJson} ->",
" Payload = json:decode(PayloadJson),",
" Meta = json:decode(MetaJson),",
" ok = wasm:send(#{reply => Payload, meta => Meta})",
" after 100 ->",
" F(F)",
" end",
" end,",
" Loop(Loop)",
"end).",
].join(" ");
const boot = await otp.boot({
beam: {
assetsUrl: "/otp-assets",
extraArgs: ["-eval", BRIDGE_BOOT_EVAL],
},
});
const BRIDGE_BOOT_EVAL = trimLeft(`
spawn(fun() ->
ok = wasm:send(#{direct => true, nested => #{count => 1}}),
true = register(bridge, self()),
Loop = fun(F) ->
ok = wasm:send(#{bridge_ready => true}),
receive
{wasm, PayloadJson, MetaJson} ->
Payload = json:decode(PayloadJson),
Meta = json:decode(MetaJson),
ok = wasm:send(#{reply => Payload, meta => Meta})
after 100 ->
F(F)
end
end,
Loop(Loop)
end).
`);
const boot = await otp.boot(evalOpts(BRIDGE_BOOT_EVAL));

assert(boot.ok);
await otp.waitForEvent("direct");
Expand All @@ -211,50 +212,65 @@ test("handles events in both directions", async ({ otp }) => {
});
});

test("run_js evaluates code on the main thread", async ({ otp, page }) => {
// run_js is fire-and-forget: it emits no onEvent and returns nothing, so we
// poll the side effect the eval'd code leaves on the main-thread window.
const RUN_JS_BOOT_EVAL = 'wasm:run_js(<<"window.popcorn = {js: true}">>).';
const boot = await otp.boot({
beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] },
});
test("run_js returns the snippet result to the caller", async ({ otp }) => {
const RUN_JS_BOOT_EVAL = trimLeft(`
V = wasm:run_js(<<"(args) => 1 + 2">>, #{}),
ok = wasm:send(#{run_js_result => V}).
`);
const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL));
assert(boot.ok);

await page.waitForFunction("window.popcorn?.js === true");
await otp.waitForEvent("run_js_result");
expect(otp.events).toContainEqual({ run_js_result: 3 });
});

test("run_js runs an async snippet", async ({ otp, page }) => {
const RUN_JS_BOOT_EVAL =
'wasm:run_js(<<"(async () => { window.popcorn = {async: true}; })()">>).';
const boot = await otp.boot({
beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] },
});
test("run_js passes args and awaits an async snippet", async ({ otp }) => {
const RUN_JS_BOOT_EVAL = trimLeft(`
V = wasm:run_js(<<"async ({a, b}) => a + b">>, #{a => 2, b => 5}),
ok = wasm:send(#{run_js_async => V}).
`);
const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL));
assert(boot.ok);

await page.waitForFunction("window.popcorn?.async === true");
await otp.waitForEvent("run_js_async");
expect(otp.events).toContainEqual({ run_js_async: 7 });
});

test("a throwing run_js snippet doesn't stop later ones from running", async ({
otp,
page,
}) => {
// The first eval throws; the second must still run and leave its side effect.
const RUN_JS_BOOT_EVAL =
"wasm:run_js(<<\"throw new Error('boom')\">>), " +
'wasm:run_js(<<"window.popcorn = {throwing: true}">>).';
const boot = await otp.boot({
beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] },
});
test("run_js accepts a custom timeout", async ({ otp }) => {
const RUN_JS_BOOT_EVAL = trimLeft(`
R = try
wasm:run_js(<<"() => new Promise(() => {})">>, #{}, [{timeout, 0}])
catch
error:run_js_timeout -> <<"timeout">>
end,
ok = wasm:send(#{run_js_timeout => R}).
`);
const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL));
assert(boot.ok);

await otp.waitForEvent("run_js_timeout");
expect(otp.events).toContainEqual({ run_js_timeout: "timeout" });
});

test("a throwing run_js snippet raises in the caller", async ({ otp }) => {
const RUN_JS_BOOT_EVAL = trimLeft(`
R = try
wasm:run_js(<<"() => { throw new Error('boom') }">>, #{})
catch
error:{run_js, Msg} -> Msg
end,
ok = wasm:send(#{run_js_error => R}).
`);
const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL));
assert(boot.ok);

await page.waitForFunction("window.popcorn?.throwing === true");
await otp.waitForEvent("run_js_error");
expect(otp.events).toContainEqual({ run_js_error: "Error: boom" });
});

test("popcorn.send() reports unregistered process", async ({ otp }) => {
const READY_BOOT_EVAL = "ok = wasm:send(#{ready => true}).";
const boot = await otp.boot({
beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", READY_BOOT_EVAL] },
});
const boot = await otp.boot(evalOpts(READY_BOOT_EVAL));
assert(boot.ok);
await otp.waitForEvent("ready");

Expand Down
80 changes: 68 additions & 12 deletions otp/js/src/beam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { extractTar } from "./tar";
import type {
BeamBootOptions,
BeamSendPayload,
BeamTarget,
EmscriptenModule,
} from "./types";
import {
Expand All @@ -13,6 +14,7 @@ import {
fetchBinary,
fetchJson,
isGzip,
unreachable,
} from "./utils";

const DEFAULT_USER = "web_user";
Expand Down Expand Up @@ -213,13 +215,43 @@ export function send(
return { ok: false, error: err("bridge:not-started", {}) };
}

let targetName: string;
let target: PreparedTarget;
if (isNameTarget(message.target)) {
targetName = message.target.name;
target = {
kind: TARGET_REGISTERED_NAME,
argType: "string",
value: targetName,
length: utf8Length(targetName),
};
} else {
targetName = message.target.pid;
const bytes = base64ToBytes(targetName);
target = {
kind: TARGET_PID_BYTES,
argType: "array",
value: bytes,
length: bytes.length,
};
}

const status = module.ccall(
"sendVmMessage",
"number",
["string", "number", "string", "number", "string", "number"],
[
message.targetName,
utf8Length(message.targetName),
"number",
target.argType,
"number",
"string",
"number",
"string",
"number",
],
[
target.kind,
target.value,
target.length,
message.payloadJson,
utf8Length(message.payloadJson),
message.metaJson,
Expand All @@ -234,20 +266,44 @@ export function send(
if (status === 1) {
return {
ok: false,
error: err("bridge:listener-not-found", {
targetName: message.targetName,
}),
error: err("bridge:listener-not-found", { targetName }),
};
}
unreachable();
}

return {
ok: false,
error: err("internal:check", {
detail: `Unexpected sendVmMessage status: ${String(status)}`,
}),
};
const TARGET_REGISTERED_NAME = 0;
const TARGET_PID_BYTES = 1;

type PreparedTarget =
| {
kind: typeof TARGET_REGISTERED_NAME;
argType: "string";
value: string;
length: number;
}
| {
kind: typeof TARGET_PID_BYTES;
argType: "array";
value: Uint8Array;
length: number;
};

function isNameTarget(
target: BeamTarget,
): target is Extract<BeamTarget, { name: string }> {
return Object.hasOwn(target, "name");
}

function utf8Length(text: string): number {
return UTF8.encode(text).length;
}

function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Loading
Loading