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

[DO NOT MERGE] Connection E2E tests #583

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
26 changes: 14 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ concurrency:

jobs:
build:
timeout-minutes: 10
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -42,18 +43,19 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node ./bin/print-ci-env.cjs >> $GITHUB_ENV
- run: npm run ci
- name: Run Playwright tests
if: env.STAGE == 'REVIEW' || env.STAGE == 'STAGING'
uses: docker://mcr.microsoft.com/playwright:v1.45.0-jammy
with:
args: npx playwright test
- name: Store reports
if: (env.STAGE == 'REVIEW' || env.STAGE == 'STAGING') && failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 3
# TODO: Uncomment E2E tests once ready to test
# - name: Run Playwright tests
# if: env.STAGE == 'REVIEW' || env.STAGE == 'STAGING'
# uses: docker://mcr.microsoft.com/playwright:v1.45.0-jammy
# with:
# args: npx playwright test
# - name: Store reports
# if: (env.STAGE == 'REVIEW' || env.STAGE == 'STAGING') && failure()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 3
microbit-grace marked this conversation as resolved.
Show resolved Hide resolved
- run: npx website-deploy-aws
if: github.repository_owner == 'microbit-foundation' && (env.STAGE == 'REVIEW' || success())
env:
Expand Down
8 changes: 4 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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@microbit/makecode-embed": "^0.0.0-alpha.7",
"@microbit/microbit-connection": "^0.0.0-alpha.30",
"@microbit/microbit-connection": "^0.0.0-alpha.32",
"@microbit/ml-header-generator": "^0.4.3",
"@microbit/smoothie": "^1.37.0-microbit.2",
"@tensorflow/tfjs": "^4.20.0",
Expand Down
24 changes: 23 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,35 @@ import {
import { hasMakeCodeMlExtension } from "./makecode/utils";
import { PostImportDialogState } from "./model";
import "theme-package/fonts/fonts.css";
import { MockWebUSBConnection } from "./device/mockUsb";
import {
MicrobitRadioBridgeConnection,
MicrobitWebBluetoothConnection,
MicrobitWebUSBConnection,
} from "@microbit/microbit-connection";
import { MockWebBluetoothConnection } from "./device/mockBluetooth";

export interface ProviderLayoutProps {
children: ReactNode;
}

const isMockDeviceMode = () => true;
// TODO: Use cookie mechanism for isMockDeviceMode.
// We use a cookie set from the e2e tests. Avoids having separate test and live builds.
// Boolean(
// document.cookie.split("; ").find((row) => row.startsWith("mockDevice="))
// );
microbit-grace marked this conversation as resolved.
Show resolved Hide resolved

const logging = deployment.logging;

const usb = isMockDeviceMode()
? (new MockWebUSBConnection() as unknown as MicrobitWebUSBConnection)
: new MicrobitWebUSBConnection({ logging });
const bluetooth = isMockDeviceMode()
? (new MockWebBluetoothConnection() as unknown as MicrobitWebBluetoothConnection)
: new MicrobitWebBluetoothConnection({ logging });
const radioBridge = new MicrobitRadioBridgeConnection(usb, { logging });

const Providers = ({ children }: ProviderLayoutProps) => {
const deployment = useDeployment();
const { ConsentProvider } = deployment.compliance;
Expand All @@ -63,7 +85,7 @@ const Providers = ({ children }: ProviderLayoutProps) => {
<ConsentProvider>
<TranslationProvider>
<ConnectStatusProvider>
<ConnectProvider>
<ConnectProvider {...{ usb, bluetooth, radioBridge }}>
<BufferedDataProvider>
<ConnectionStageProvider>
{children}
Expand Down
18 changes: 9 additions & 9 deletions src/connect-actions-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ const ConnectContext = createContext<ConnectContextValue | null>(null);

interface ConnectProviderProps {
children: ReactNode;
usb: MicrobitWebUSBConnection;
bluetooth: MicrobitWebBluetoothConnection;
radioBridge: MicrobitRadioBridgeConnection;
}

export const ConnectProvider = ({ children }: ConnectProviderProps) => {
const usb = useRef(new MicrobitWebUSBConnection()).current;
const logging = useRef(useLogging()).current;
const bluetooth = useRef(
new MicrobitWebBluetoothConnection({ logging })
).current;
const radioBridge = useRef(
new MicrobitRadioBridgeConnection(usb, { logging })
).current;
export const ConnectProvider = ({
children,
usb,
bluetooth,
radioBridge,
}: ConnectProviderProps) => {
const [isInitialized, setIsInitialized] = useState<boolean>(false);

useEffect(() => {
Expand Down
37 changes: 37 additions & 0 deletions src/device/mockBluetooth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
BoardVersion,
ConnectionStatus,
ConnectionStatusEvent,
DeviceConnectionEventMap,
DeviceWebBluetoothConnection,
TypedEventTarget,
} from "@microbit/microbit-connection";

export class MockWebBluetoothConnection
extends TypedEventTarget<DeviceConnectionEventMap>
implements DeviceWebBluetoothConnection
{
status: ConnectionStatus = ConnectionStatus.NO_AUTHORIZED_DEVICE;

async initialize(): Promise<void> {}
dispose(): void {}

private mockStatus(newStatus: ConnectionStatus) {
this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus));
}

async connect(): Promise<ConnectionStatus> {
this.mockStatus(ConnectionStatus.CONNECTING);
await new Promise((resolve) => setTimeout(resolve, 100));
this.mockStatus(ConnectionStatus.CONNECTED);
return ConnectionStatus.CONNECTED;
}
getBoardVersion(): BoardVersion | undefined {
return "V2";
}
async disconnect(): Promise<void> {}
async serialWrite(_data: string): Promise<void> {}

clearDevice(): void {}
setNameFilter(_name: string): void {}
}
70 changes: 70 additions & 0 deletions src/device/mockUsb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
AfterRequestDevice,
BeforeRequestDevice,
BoardVersion,
ConnectionStatus,
ConnectionStatusEvent,
DeviceConnectionEventMap,
DeviceWebUSBConnection,
FlashDataSource,
FlashOptions,
TypedEventTarget,
} from "@microbit/microbit-connection";

/**
* A mock USB connection used during end-to-end testing.
*/
export class MockWebUSBConnection
extends TypedEventTarget<DeviceConnectionEventMap>
implements DeviceWebUSBConnection
{
status: ConnectionStatus = ConnectionStatus.NO_AUTHORIZED_DEVICE;

private fakeDeviceId: number | undefined = 123;

constructor() {
super();
// Make globally available to allow e2e tests to configure interactions.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(window as any).mockUsb = this;
this.fakeDeviceId = Math.round(Math.random() * 1000);
}

async initialize(): Promise<void> {}
dispose(): void {}

mockDeviceId(deviceId: number | undefined) {
this.fakeDeviceId = deviceId;
}

private mockStatus(newStatus: ConnectionStatus) {
this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus));
}

async connect(): Promise<ConnectionStatus> {
this.dispatchTypedEvent("beforerequestdevice", new BeforeRequestDevice());
await new Promise((resolve) => setTimeout(resolve, 100));
this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice());
await new Promise((resolve) => setTimeout(resolve, 100));
this.mockStatus(ConnectionStatus.CONNECTED);
return ConnectionStatus.CONNECTED;
}
getDeviceId(): number | undefined {
return this.fakeDeviceId;
}
getBoardVersion(): BoardVersion | undefined {
return "V2";
}
async flash(
_dataSource: FlashDataSource,
options: FlashOptions
): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 100));
options.progress(50, options.partial);

Check failure on line 63 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe call of an `any` typed value

Check failure on line 63 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe member access .progress on an `error` typed value

Check failure on line 63 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe member access .partial on an `error` typed value
await new Promise((resolve) => setTimeout(resolve, 100));
options.progress(undefined, options.partial);

Check failure on line 65 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe call of an `any` typed value

Check failure on line 65 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe member access .progress on an `error` typed value

Check failure on line 65 in src/device/mockUsb.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe member access .partial on an `error` typed value
}
async disconnect(): Promise<void> {}
async serialWrite(_data: string): Promise<void> {}
clearDevice(): void {}
}
67 changes: 67 additions & 0 deletions src/e2e/app/connection-dialogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* (c) 2024, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { expect, type Page } from "@playwright/test";
import { MockWebUSBConnection } from "../../device/mockUsb";

export class ConnectionDialogs {
constructor(public readonly page: Page) {}

async close() {
await this.page.getByLabel("Close").click();
}

async waitForText(name: string) {
await this.page.getByText(name).waitFor();
}

private async clickNext() {
await this.page.getByRole("button", { name: "Next" }).click();
}

async bluetoothDownloadProgram() {
await this.waitForText("What you need to connect using Web Bluetooth");
await this.clickNext();
await this.waitForText("Connect USB cable to micro:bit");
await this.clickNext();
await this.waitForText("Download data collection program to micro:bit");
await this.clickNext();
}

async expectManualTransferProgramDialog() {
await expect(
this.page.getByText("Transfer saved hex file to micro:bit")
).toBeVisible();
}

async mockUsbDeviceNotSelected() {
await this.page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const mockUsb = (window as any).mockUsb as MockWebUSBConnection;
mockUsb.mockDeviceId(undefined);
});
}

async bluetoothConnect() {
await this.bluetoothDownloadProgram();
await this.waitForText("Downloading the data collection program");
await this.waitForText("Disconnect USB and connect battery pack");
await this.clickNext();
await this.waitForText("Copy pattern");
await this.enterBluetoothPattern();
await this.clickNext();
await this.waitForText("Connect to micro:bit using Web Bluetooth");
await this.clickNext();
await this.waitForText("Connect using Web Bluetooth");
}

async enterBluetoothPattern() {
await this.page.locator(".css-1jvu5j > .chakra-button").first().click();
await this.page.locator("div:nth-child(11) > .chakra-button").click();
await this.page.locator("div:nth-child(17) > .chakra-button").click();
await this.page.locator("div:nth-child(23) > .chakra-button").click();
await this.page.locator("div:nth-child(29) > .chakra-button").click();
}
}
50 changes: 49 additions & 1 deletion src/e2e/app/data-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,61 @@
*
* SPDX-License-Identifier: MIT
*/
import { type Page } from "@playwright/test";
import { expect, Locator, type Page } from "@playwright/test";
import { Navbar } from "./shared";
import { ConnectionDialogs } from "./connection-dialogs";

export class DataSamplesPage {
public readonly navbar: Navbar;
private url: string;
private heading: Locator;
private connectBtn: Locator;

constructor(public readonly page: Page) {
this.url = `http://localhost:5173${
process.env.CI ? process.env.BASE_URL : "/"
}data-samples`;
this.navbar = new Navbar(page);
this.heading = this.page.getByRole("heading", { name: "Data samples" });
this.connectBtn = this.page.getByLabel("Connect to micro:bit");
}

async goto(flags: string[] = ["open"]) {
const response = await this.page.goto(this.url);
await this.page.evaluate(
(flags) => localStorage.setItem("flags", flags.join(",")),
flags
);
return response;
}

expectUrl() {
expect(this.page.url()).toEqual(this.url);
}

async closeDialog() {
await this.page.getByLabel("Close").click();
}

async connect() {
await this.connectBtn.click();
return new ConnectionDialogs(this.page);
}

async expectConnected() {
await expect(this.connectBtn).toBeHidden();
await expect(
this.page.getByText("Your data collection micro:bit is connected!")
).toBeVisible();
}

async expectOnPage() {
await expect(this.heading).toBeVisible();
this.expectUrl();
}

async expectCorrectInitialState() {
this.expectUrl();
await expect(this.heading).toBeVisible({ timeout: 10000 });
}
}
Loading
Loading