Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions platform/wab/playwright/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BASE_URL=http://localhost:3003
TEST_USER_EMAIL=user2@example.com
TEST_USER_PASSWORD=!53kr3tz!
7 changes: 7 additions & 0 deletions platform/wab/playwright/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
91 changes: 91 additions & 0 deletions platform/wab/playwright/e2e/interactions-text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures/test";

import bundles from "../../cypress/bundles";
import { Operations } from "../types/interaction";
import { findFrameByText } from "../utils/frame";

const BUNDLE_NAME = "state-management";

test.describe("state-management-text-interactions", () => {
let projectId: string;
test.beforeEach(async ({ request, apiClient, page, context, env }) => {
await apiClient.login(env.testUser.email, env.testUser.password);
projectId = await apiClient.importProjectFromTemplate(bundles[BUNDLE_NAME]);

const cookies = await request.storageState();

await context.addCookies(cookies.cookies);
await page.goto(`/projects/${projectId}`);
});

test.afterEach(async ({ env, apiClient }) => {
if (projectId) {
await apiClient.removeProject(
projectId,
env.testUser.email,
env.testUser.password
);
}
});

test("can create all types of text interactions", async ({ pages, page }) => {
//GIVEN
const TEST_DATA = {
Copy link
Member

Choose a reason for hiding this comment

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

Not a fan of this pattern. It typically just obfuscates the actual values.

As a general rule, only make a variable for a constant if used 3 or more times.

arenaName: "text interactions",
setToText: "Set to",
updateVariableAction: "updateVariable",
textVarName: "textVar",
goodbyeValue: '"goodbye"',
goodbyeText: "goodbye",
clearVariableSelector: "Clear variable",
helloText: "hello",
setToGoodbyeButtonName: 'Set to "goodbye"',
clearButtonName: "Clear",
undefinedText: "undefined",
};
await pages.projectPage.switchArena(TEST_DATA.arenaName);

//WHEN
const contentFrame = await findFrameByText(page, TEST_DATA.setToText);
Copy link
Member

Choose a reason for hiding this comment

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

Why aren't these locators in ProjectPage? And why is findFrameByText necessary here instead of the specific frame locators that are already in ProjectPage?

await contentFrame
.locator("span", { hasText: TEST_DATA.setToText })
.first()
.click({ force: true });
await pages.projectPage.addInteraction("onClick", {
actionName: TEST_DATA.updateVariableAction,
args: {
variable: [TEST_DATA.textVarName],
operation: Operations.NEW_VALUE,
value: TEST_DATA.goodbyeValue,
},
});

await contentFrame
.locator(`text=${TEST_DATA.clearVariableSelector}`)
.click({ force: true });
await pages.projectPage.addInteraction("onClick", {
actionName: TEST_DATA.updateVariableAction,
args: {
variable: [TEST_DATA.textVarName],
operation: Operations.CLEAR_VALUE,
},
});

//THEN
await pages.projectPage.withinLiveMode(async (liveFrame) => {
await expect(liveFrame.locator("body")).toBeVisible();
await expect(liveFrame.getByText(TEST_DATA.helloText)).toBeVisible();
await liveFrame
.getByRole("button", { name: TEST_DATA.setToGoodbyeButtonName })
.click();
await expect(
liveFrame.getByText(TEST_DATA.goodbyeText, { exact: true })
).toBeVisible();
await liveFrame
.getByRole("button", { name: TEST_DATA.clearButtonName })
.click();
await expect(liveFrame.getByText(TEST_DATA.undefinedText)).toBeVisible();
});
});
});
31 changes: 31 additions & 0 deletions platform/wab/playwright/fixtures/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test as base } from "@playwright/test";

import { ProjectPage } from "../pages/project-page";
import { ENVIRONMENT, Environment } from "../types/environment";
import { ApiClient } from "../utils/api-client";

export interface TestFixtures {
pages: {
projectPage: ProjectPage;
};
env: Environment;
apiClient: ApiClient;
}

export const test = base.extend<TestFixtures>({
pages: async ({ page }, use) => {
const pages = {
projectPage: new ProjectPage(page),
};
await use(pages);
},
// eslint-disable-next-line no-empty-pattern
env: async ({}, use) => {
const environment = ENVIRONMENT;
await use(environment);
},
apiClient: async ({ request, env }, use) => {
const client = new ApiClient(request, env.baseUrl);
await use(client);
},
});
14 changes: 14 additions & 0 deletions platform/wab/playwright/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "playwright",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@dotenvx/dotenvx": "^1.47.6",
"@playwright/test": "^1.54.0",
"@types/node": "^24.0.13"
},
"scripts": {
"e2e": "npx dotenvx run -f .env.test -- npx playwright test"
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason to do this here, instead of loading dotenv in playwright config?

}
}
9 changes: 9 additions & 0 deletions platform/wab/playwright/pages/base-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Page } from "playwright/test";

export class BasePage {
Copy link
Member

Choose a reason for hiding this comment

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

Get rid of this, just inline

constructor(private readonly page: Page)

in every model.

readonly page: Page;

constructor(page: Page) {
this.page = page;
}
}
128 changes: 128 additions & 0 deletions platform/wab/playwright/pages/project-page.ts
Copy link
Member

Choose a reason for hiding this comment

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

Since we have a single-page application AND we have the concept of pages in our app, we should get rid of the "page" terminology. Maybe rename "page" to "model (i.e. rename from "pages/project-page.ts" to "models/StudioModel.ts")? Ultimately, in test code, I want to be able to write something like studio.switchArena("some page").

Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { FrameLocator, Locator, Page } from "playwright/test";

import { BasePage } from "./base-page";
import { Interaction } from "../types/interaction";

export class ProjectPage extends BasePage {
readonly frame = this.page
.frameLocator("iframe.studio-frame")
.frameLocator("iframe");
readonly projectNavButton = this.frame.locator('[id="proj-nav-button"]');
readonly projectNavClearSearchButton = this.frame.locator(
'[data-test-id="nav-dropdown-clear-search"]'
);
readonly projectNavSearchInput = this.frame.locator(
'[data-test-id="nav-dropdown-search-input"]'
);
readonly addInteractionButton = this.frame.locator(
'[data-test-id="add-interaction"]'
);
readonly interactionsSearchInput = this.frame.locator("#interactions-select");
readonly actionsDropdownButton = this.frame.locator(
'[data-plasmic-prop="action-name"]'
);
readonly stateButton = this.frame.locator('[data-plasmic-prop="variable"]');
readonly windowSaveButton = this.frame
.locator('[data-test-id="data-picker"]')
.locator("text=Save");

readonly operationDropdownButton = this.frame.locator(
'[data-plasmic-prop="operation"]'
);
readonly valueButton = this.frame.locator('[data-plasmic-prop="value"]');
readonly valueCodeInput = this.frame.locator(
"div.react-monaco-editor-container"
);
readonly closeSidebarButton = this.frame.locator(
'[data-test-id="close-sidebar-modal"]'
);
readonly enterLiveModeButton = this.frame.locator(
'[data-test-id="enter-live-mode-btn"]'
);
readonly liveFrame = this.page
.locator("iframe")
.first()
.contentFrame()
.locator("iframe")
.contentFrame()
.locator('[data-test-id="live-frame"]')
.contentFrame();

readonly exitLiveModeButton = this.frame.locator(
'[data-test-id="exit-live-mode-btn"]'
);

constructor(page: Page) {
super(page);
}

async selectInteractionEventById(eventHandler: string): Promise<Locator> {
const dropdownElement = this.frame.locator(
`#interactions-select-opt-${eventHandler}`
);
return dropdownElement;
}

async selectElementWithDataKey(key: string): Promise<Locator> {
const element = this.frame.locator(`[data-key="${key}"]`);
return element;
}

async getInteractionAction(actionName: string): Promise<Locator> {
const interactionAction = await this.selectElementWithDataKey(actionName);
return interactionAction;
}

async getStateVariable(stateVar: string): Promise<Locator> {
const stateVariable = this.frame
.locator(`[data-test-id="0-${stateVar}"]`)
.first();
return stateVariable;
}

async switchArena(name: string) {
await this.projectNavButton.click({ force: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

I think in some cases, these force: true settings copied from Cypress should no longer be necessary.

Copy link
Member

Choose a reason for hiding this comment

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

+1. Please get rid of as many force: true as possible.

if (await this.projectNavClearSearchButton.isVisible()) {
await this.projectNavClearSearchButton.click({ force: true });
}
await this.projectNavSearchInput.fill(name);
await this.frame.locator(`text=${name}`).click({ force: true });
await this.page.waitForTimeout(1000);
Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't tested it, but instead of waiting 1s I think it's sufficient to wait for #proj-nav-button to change to the new arena name. Then, subsequent Playwright selectors should naturally wait if the new content is still loading.

}

async addInteraction(eventHandler: string, interaction: Interaction) {
await this.addInteractionButton.click();
await this.interactionsSearchInput.fill(eventHandler);
const interactionsEventDropdownElement =
await this.selectInteractionEventById(eventHandler);
await interactionsEventDropdownElement.scrollIntoViewIfNeeded();
await interactionsEventDropdownElement.click();

await this.actionsDropdownButton.first().click();
await (await this.getInteractionAction(interaction.actionName)).click();
if (interaction.args.variable) {
await this.stateButton.click();
await (await this.getStateVariable(interaction.args.variable[0])).click();
await this.windowSaveButton.click();
}
if (interaction.args.operation) {
await this.operationDropdownButton.click();
await this.frame
.locator(`[data-key="${interaction.args.operation}"]`)
.click();
}
if (interaction.args.value) {
await this.valueButton.click();
await this.valueCodeInput.pressSequentially(interaction.args.value);
await this.windowSaveButton.click();
}

await this.closeSidebarButton.click();
}
Copy link
Member

Choose a reason for hiding this comment

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

I see this file growing to become as large as the existing cypress/support/utils.ts file. How should we divide it into separate files? Maybe by feature?


async withinLiveMode(fn: (liveFrame: FrameLocator) => Promise<void>) {
await this.enterLiveModeButton.click({ force: true });
await fn(this.liveFrame);
await this.exitLiveModeButton.click({ force: true });
}
}
70 changes: 70 additions & 0 deletions platform/wab/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
import { ENVIRONMENT, loadEnv } from "./types/environment";

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: ENVIRONMENT.baseUrl,

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},

/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},

/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },

/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],

/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
21 changes: 21 additions & 0 deletions platform/wab/playwright/types/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getEnvVar } from "../utils/env";
Copy link
Member

Choose a reason for hiding this comment

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

Move getEnvVar into this file, do not export it.


export type Environment = {
baseUrl: string;
testUser: {
email: string;
password: string;
};
};

export function loadEnv(): Environment {
Copy link
Member

Choose a reason for hiding this comment

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

Do not export

return {
baseUrl: getEnvVar("BASE_URL", "http://localhost:3003"),
testUser: {
email: getEnvVar("TEST_USER_EMAIL", "user2@example.com"),
password: getEnvVar("TEST_USER_PASSWORD", "!53kr3tz!"),
},
};
}

export const ENVIRONMENT = loadEnv();
Loading
Loading