Skip to content
Open
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
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions popdoc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ To make a block runnable, use the `elixir-popcorn` fence:
```
````

For interactive IEx examples, use `iex-popcorn`. Clicking an `iex>` prompt runs the command in an on-page xterm.js terminal (you can also type freely there). The terminal can also be opened on any page with the floating `iex` button in the bottom-right corner. Its `Clear` button wipes the screen only, while `Reset` restarts the whole Popcorn runtime: variables, modules, and eval-block state are shared in one VM, so they reset together:

````
```iex-popcorn
iex> x = 1 + 1
2
iex> x * 10
20
```
````

Then generate docs normally:

```bash
Expand Down
25 changes: 25 additions & 0 deletions popdoc/e2e/fixture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ Example.hello()
Example.divide_all(10, [2, 5, 0])
```

## IEx session

Click an `iex>` prompt to run it in the on-page terminal, or open the terminal
any time with the floating `iex` button in the bottom-right corner. You can also
type freely there. `Clear` wipes the screen; `Reset` restarts the whole Popcorn
runtime (variables, modules, and eval-block state).

```iex-popcorn
iex> x = 1 + 1
2
iex> x * 10
20
```

```iex-popcorn
iex> Enum.sort([3, 2, 1])
[1, 2, 3]
iex> total =
...> [10, 20, 30]
...> |> Enum.sum()
60
iex> total * 2
120
```

Build the docs with:

```bash
Expand Down
255 changes: 254 additions & 1 deletion popdoc/e2e/popdoc.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, Page, Locator } from "@playwright/test";
import { test, expect, devices, Page, Locator } from "@playwright/test";

const RUN_READY_TIMEOUT_MS = 60_000;
const EVAL_TIMEOUT_MS = 60_000;
Expand All @@ -21,6 +21,23 @@ async function runBlock(block: Locator) {
await expect(run).toBeEnabled({ timeout: EVAL_TIMEOUT_MS });
}

// Prompts are decorated before the WASM runtime finishes booting, but click
// handlers exist only once popdoc marks them bound — interacting earlier is
// silently lost.
async function awaitPromptReady(prompt: Locator) {
await expect(prompt).toHaveAttribute("data-popdoc-iex-bound", "true", {
timeout: RUN_READY_TIMEOUT_MS,
});
}

async function runPrompt(prompt: Locator) {
await awaitPromptReady(prompt);
await prompt.click();
await expect(prompt).toHaveAttribute("data-iex-state", "success", {
timeout: EVAL_TIMEOUT_MS,
});
}

test("decorates elixir-popcorn blocks and enables Run once Popcorn is ready", async ({
page,
}) => {
Expand Down Expand Up @@ -104,3 +121,239 @@ test("renders error with a stacktrace toggle for nested failures", async ({
await expect(trace).toBeVisible();
await expect(trace).toContainText(/Example|safe_div|check_denominator/);
});

test("decorates iex-popcorn prompts as clickable and opens the IEx terminal", async ({
page,
}) => {
const prompt = page.locator(".popdoc-iex-prompt").first();
await expect(prompt).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS });
await expect(prompt).toHaveAttribute("title", "Run in IEx");

const terminal = page.locator(".popdoc-terminal");
await expect(terminal).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS });
await expect(terminal).not.toHaveClass(/popdoc-terminal--open/);

await runPrompt(prompt);
await expect(terminal).toHaveClass(/popdoc-terminal--open/);
});

test("resets the IEx session, wiping the screen and prompt state", async ({
page,
}) => {
const prompt = page.locator(".popdoc-iex-prompt").first();
await runPrompt(prompt);

const reset = page.locator(".popdoc-terminal-btn", { hasText: "Reset" });
await reset.click();
await expect(prompt).not.toHaveAttribute("data-iex-state");

// The restarted runtime prints a fresh prompt; the old output is gone.
const rows = page.locator(".popdoc-terminal .xterm-rows");
await expect(rows).toContainText("iex(", { timeout: EVAL_TIMEOUT_MS });
await expect(rows).not.toContainText("x = 1 + 1");
});

test("clears only the terminal screen, keeping session and prompt state", async ({
page,
}) => {
const prompt = page.locator(".popdoc-iex-prompt").first();
await runPrompt(prompt);

const clear = page.locator(".popdoc-terminal-btn", { hasText: "Clear" });
await clear.click();

const rows = page.locator(".popdoc-terminal .xterm-rows");
await expect(rows).not.toContainText("x = 1 + 1");
// clear() keeps the cursor's line, so the live prompt survives.
await expect(rows).toContainText("iex(");
await expect(prompt).toHaveAttribute("data-iex-state", "success");
await expect(page.locator(".popdoc-terminal")).toHaveClass(
/popdoc-terminal--open/,
);
});

test("shows the iex launcher and opens the terminal from it", async ({
page,
}) => {
const launcher = page.locator(".popdoc-iex-launcher");
await expect(launcher).toBeVisible({ timeout: RUN_READY_TIMEOUT_MS });

const terminal = page.locator(".popdoc-terminal");
await launcher.click();
await expect(terminal).toHaveClass(/popdoc-terminal--open/);
await expect(launcher).toBeHidden();

await page.locator(".popdoc-terminal-btn", { hasText: "✕" }).click();
await expect(terminal).not.toHaveClass(/popdoc-terminal--open/);
await expect(launcher).toBeVisible();
});

test("launcher starts an IEx session on pages without popcorn blocks", async ({
page,
}) => {
await page.goto("/Example.html");
await page.waitForLoadState("networkidle");

const launcher = page.locator(".popdoc-iex-launcher");
await expect(launcher).toBeVisible({ timeout: RUN_READY_TIMEOUT_MS });
await launcher.click();

await expect(page.locator(".popdoc-terminal")).toHaveClass(
/popdoc-terminal--open/,
);
await expect(page.locator(".popdoc-terminal .xterm-rows")).toContainText(
"iex(",
{ timeout: EVAL_TIMEOUT_MS },
);
});

test("collapses and closes the IEx terminal", async ({ page }) => {
const prompt = page.locator(".popdoc-iex-prompt").first();
await awaitPromptReady(prompt);
await prompt.click();

const terminal = page.locator(".popdoc-terminal");
await expect(terminal).toHaveClass(/popdoc-terminal--open/);

const collapse = page.locator(".popdoc-terminal-collapse");
await collapse.click();
await expect(terminal).toHaveClass(/popdoc-terminal--collapsed/);

await collapse.click();
await expect(terminal).not.toHaveClass(/popdoc-terminal--collapsed/);

await page.locator(".popdoc-terminal-btn", { hasText: "✕" }).click();
await expect(terminal).not.toHaveClass(/popdoc-terminal--open/);
});

test("reveals the next iex> prompt only after the previous one succeeds", async ({
page,
}) => {
const block = page.locator("pre.popcorn-iex").first();
const prompts = block.locator(".popdoc-iex-prompt");
await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS });

const first = prompts.nth(0);
const second = prompts.nth(1);

await expect(first).toHaveClass(/popdoc-iex-prompt--runnable/);
await expect(second).not.toHaveClass(/popdoc-iex-prompt--runnable/);

await runPrompt(first);
await expect(first).not.toHaveClass(/popdoc-iex-prompt--runnable/);
await expect(second).toHaveClass(/popdoc-iex-prompt--runnable/);
});

test("scopes iex> execution to each code block independently", async ({
page,
}) => {
const blocks = page.locator("pre.popcorn-iex");
await expect(blocks).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS });

const secondBlockPrompt = blocks.nth(1).locator(".popdoc-iex-prompt").first();
await expect(secondBlockPrompt).toHaveClass(/popdoc-iex-prompt--runnable/);

await runPrompt(secondBlockPrompt);

const firstBlockPrompts = blocks.nth(0).locator(".popdoc-iex-prompt");
await expect(firstBlockPrompts.nth(0)).not.toHaveAttribute("data-iex-state");
await expect(firstBlockPrompts.nth(1)).not.toHaveAttribute("data-iex-state");
});

test("clicking a later iex> in the same block runs earlier prompts first", async ({
page,
}) => {
const block = page.locator("pre.popcorn-iex").first();
const prompts = block.locator(".popdoc-iex-prompt");
await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS });

await runPrompt(prompts.nth(1));
// The chain runs earlier prompts first, so by now the first one succeeded.
await expect(prompts.nth(0)).toHaveAttribute("data-iex-state", "success");
});

test("marks continuation lines and runs the whole multi-line command", async ({
page,
}) => {
const block = page.locator("pre.popcorn-iex").nth(1);
const conts = block.locator(".popdoc-iex-prompt--cont");
await expect(conts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS });

// Bound state is marked on the main prompt; conts are wired in the same
// pass.
await awaitPromptReady(
block.locator(".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)").first(),
);
await conts.first().click();
await expect(conts.first()).toHaveAttribute("data-iex-state", "success", {
timeout: EVAL_TIMEOUT_MS,
});

const pills = block.locator(
".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)",
);
await expect(pills.nth(0)).toHaveAttribute("data-iex-state", "success");
await expect(pills.nth(1)).toHaveAttribute("data-iex-state", "success");
await expect(pills.nth(2)).toHaveClass(/popdoc-iex-prompt--runnable/);
});

test("shows the run affordance without hover", async ({ page }) => {
const runnable = page.locator(".popdoc-iex-prompt--runnable").first();
await expect(runnable).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS });

const icon = runnable.locator(".popdoc-iex-icon");
const content = await icon.evaluate(
(el) => getComputedStyle(el, "::before").content,
);
expect(content).toBe('"▶"');
});

test("keeps decorations out of the copied text", async ({ page }) => {
const block = page.locator("pre.popcorn-iex").first();
await expect(block.locator(".popdoc-iex-prompt").first()).toBeAttached({
timeout: RUN_READY_TIMEOUT_MS,
});

const code = block.locator("code");
const text = await code.evaluate((el) =>
Array.from(el.children)
.map((c) => c.textContent)
.join(""),
);
expect(text).toContain("iex> ");
expect(text).not.toMatch(/[▶✓]/);
});

test.describe("touch", () => {
// defaultBrowserType cannot change inside a describe (forces a new worker);
// keep the Pixel 7 viewport/touch traits and let the project pick the browser.
const { defaultBrowserType: _browser, ...pixel7 } = devices["Pixel 7"];
test.use(pixel7);

test("prompt runs on tap", async ({ page }) => {
const prompt = page.locator(".popdoc-iex-prompt").first();
await awaitPromptReady(prompt);
await prompt.tap();
await expect(prompt).toHaveAttribute("data-iex-state", "success", {
timeout: EVAL_TIMEOUT_MS,
});
});
});

test("previews the chain of commands that will run on hover", async ({
page,
}) => {
const block = page.locator("pre.popcorn-iex").first();
const prompts = block.locator(
".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)",
);
await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS });

// Hover mirroring is bound together with the click handlers.
await awaitPromptReady(prompts.nth(1));
await prompts.nth(1).hover();
await expect(prompts.nth(0)).toHaveClass(/popdoc-iex-hover-chain/);

await page.mouse.move(0, 0);
await expect(prompts.nth(0)).not.toHaveClass(/popdoc-iex-hover-chain/);
});
15 changes: 13 additions & 2 deletions popdoc/js/build.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import * as esbuild from "esbuild";
import { copyFile, mkdir } from "fs/promises";
import { mkdir, readFile, writeFile } from "fs/promises";
import { createRequire } from "module";
import { popcorn } from "@swmansion/popcorn/esbuild";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const rootDir = resolve(__dirname, "..");
const assetsDir = resolve(rootDir, "assets");
const bundlePath = resolve(rootDir, "wasm/out/bundle.avm");
const xtermCssSrc = require.resolve("@xterm/xterm/css/xterm.css");

await mkdir(assetsDir, { recursive: true });

Expand All @@ -19,6 +22,14 @@ await esbuild.build({
plugins: [popcorn({ bundlePaths: [bundlePath] })],
});

await copyFile(resolve(__dirname, "src/popdoc.css"), resolve(assetsDir, "popdoc.css"));
const [xtermCss, popdocCss] = await Promise.all([
readFile(xtermCssSrc, "utf8"),
readFile(resolve(__dirname, "src/popdoc.css"), "utf8"),
]);

await writeFile(
resolve(assetsDir, "popdoc.css"),
`${xtermCss}\n${popdocCss}`,
);

console.log("[popdoc] runtime scaffold built into assets/");
Loading
Loading