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
33 changes: 29 additions & 4 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"crelt": "^1.0.5",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
"idb": "^8.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.14.0",
"lzma": "^2.3.2",
Expand Down Expand Up @@ -72,6 +73,7 @@
"cross-env": "^7.0.3",
"ejs": "^3.1.9",
"eslint": "^10.9.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"prettier": "2.3.2",
Expand Down
5 changes: 4 additions & 1 deletion src/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { ErrorBoundary, UnexpectedErrorPage } from "@microbit/ui-patterns";
import { useCallback } from "react";
import { Outlet } from "react-router";
import StorageVersionErrorPage from "./fs/StorageVersionErrorPage";
import { useStorageVersionError } from "./fs/storage-status";
import { useDeployment } from "./deployment";
import { useLogging } from "./logging/logging-hooks";

Expand All @@ -20,14 +22,15 @@ const RootLayout = () => {
(error: unknown) => logging.error("Uncaught render error", error),
[logging]
);
const storageVersionError = useStorageVersionError();
return (
<ErrorBoundary
onError={handleError}
fallback={(_error, reference) => (
<UnexpectedErrorPage supportUrl={supportLink} reference={reference} />
)}
>
<Outlet />
{storageVersionError ? <StorageVersionErrorPage /> : <Outlet />}
</ErrorBoundary>
);
};
Expand Down
26 changes: 23 additions & 3 deletions src/e2e/app-test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@ type MyFixtures = {
app: App;
};

export const test = base.extend<MyFixtures>({
app: async ({ page, context }, use) => {
type Options = {
/** Hide IndexedDB so the editor falls back to session storage. */
noIndexedDB: boolean;
/** Open the editor before the test. Off for tests that embed it. */
autoGoto: boolean;
};

export const test = base.extend<MyFixtures & Options>({
noIndexedDB: [false, { option: true }],
autoGoto: [true, { option: true }],
app: async ({ page, context, noIndexedDB, autoGoto }, use) => {
const app = new App(page, context);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await context.addCookies([
Expand All @@ -29,7 +38,18 @@ export const test = base.extend<MyFixtures>({
url: app.baseUrl,
},
]);
await app.goto();
if (noIndexedDB) {
await context.addInitScript(() => {
// The getter lives on the prototype so an own property is needed.
Object.defineProperty(window, "indexedDB", {
value: undefined,
configurable: true,
});
});
}
if (autoGoto) {
await app.goto();
}
await use(app);
},
});
44 changes: 29 additions & 15 deletions src/e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ export interface BrowserDownload {
// E2E_PORT points the suite at a server on another port.
const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`;

interface UrlOptions {
export interface UrlOptions {
flags?: Flag[];
fragment?: string;
language?: string;
/** Iframe controller mode, as embedded by classroom. */
controller?: boolean;
}

interface SaveOptions {
Expand Down Expand Up @@ -280,7 +282,7 @@ export class App {
}

async goto(options: UrlOptions = {}) {
await this.page.goto(optionsToURL(options));
await this.page.goto(editorUrl(options));
// Wait for the page to be loaded
await this.editor.waitFor();
}
Expand Down Expand Up @@ -495,19 +497,28 @@ export class App {
await this.page.getByRole("button", { name: "Close" }).click();
}

async closeAndExpectBeforeUnloadDialogVisible(
visible: boolean
): Promise<void> {
if (visible) {
this.page.on("dialog", async (dialog) => {
expect(dialog.type() === "beforeunload").toEqual(visible);

// Though https://playwright.dev/docs/api/class-page#page-event-dialog
// says that dialog.dismiss() is needed otherwise the page will freeze,
// in practice, it appears that the dialog is dismissed automatically.
});
}
async closeWithoutBeforeUnloadPrompt(): Promise<void> {
expect(await this.closeAndCollectDialogs()).not.toContain("beforeunload");
}

async closeAndExpectBeforeUnloadPrompt(): Promise<void> {
expect(await this.closeAndCollectDialogs()).toContain("beforeunload");
}

/**
* Playwright accepts a beforeunload dialog itself if nobody listens, so
* listen to see it. Any dialog is handled before the page can close.
*/
private async closeAndCollectDialogs(): Promise<string[]> {
const dialogs: string[] = [];
this.page.on("dialog", async (dialog) => {
dialogs.push(dialog.type());
await dialog.accept();
});
const closed = this.page.waitForEvent("close");
await this.page.close({ runBeforeUnload: true });
await closed;
return dialogs;
}

async expectDocumentationTopLevelHeading(
Expand Down Expand Up @@ -823,7 +834,7 @@ const getAbsoluteFilePath = (filePathFromProjectRoot: string) => {
return path.join(dir.replace("src/e2e", ""), filePathFromProjectRoot);
};

const optionsToURL = (options: UrlOptions): string => {
export const editorUrl = (options: UrlOptions = {}): string => {
const flags = new Set<string>([
"none",
"noWelcome",
Expand All @@ -836,6 +847,9 @@ const optionsToURL = (options: UrlOptions): string => {
if (options.language) {
params.push(["l", options.language]);
}
if (options.controller) {
params.push(["controller", "1"]);
}
return (
baseUrl +
// We didn't use BASE_URL here as CRA seems to set it to "" before running jest.
Expand Down
27 changes: 21 additions & 6 deletions src/e2e/edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,45 @@ import { test } from "./app-test-fixtures.js";

test.describe("edits", () => {
test("doesn't prompt on close if no edits made", async ({ app }) => {
await app.closeAndExpectBeforeUnloadDialogVisible(false);
await app.closeWithoutBeforeUnloadPrompt();
});

test("prompts on close if file edited", async ({ app }) => {
test("doesn't prompt on close if file edited, as the project is saved", async ({
app,
}) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);

await app.closeAndExpectBeforeUnloadDialogVisible(true);
await app.closeWithoutBeforeUnloadPrompt();
});

test("prompts on close if project name edited", async ({ app }) => {
test("doesn't prompt on close if project name edited, as the project is saved", async ({
app,
}) => {
const name = "idiosyncratic ruminant";
await app.setProjectName(name);
await app.expectProjectName(name);

await app.closeAndExpectBeforeUnloadDialogVisible(true);
await app.closeWithoutBeforeUnloadPrompt();
});

test("retains text across a reload via session storage", async ({ app }) => {
test("retains text across a reload", async ({ app }) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);

await app.page.reload();

await app.expectEditorContainText(/A change/);
});

test("retains text across a reload straight after typing", async ({
app,
}) => {
// Writes are coalesced for a few hundred milliseconds, so this relies on
// the pending ones being flushed as the page unloads.
await app.typeInEditor("A change!");
await app.page.reload();

await app.expectEditorContainText(/A change/);
});
});
Loading