Skip to content

Commit 46bf984

Browse files
authored
Component examples (#3)
* Example components - login, newProject, and delProject
1 parent 6a0d37a commit 46bf984

7 files changed

Lines changed: 275 additions & 7 deletions

File tree

.github/copilot-instructions.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copilot Instructions
2+
3+
## Application Under Test
4+
5+
Testspace (s2testorg.stridespace.com) — a test management SaaS.
6+
7+
## Hamburger / Options Menu (DETAILS[data-url])
8+
9+
Projects, Spaces, and other list items expose a hamburger menu via a `<details data-url="/resource/:id/menu">` element inside each table row. Menu content is **AJAX-fetched** when the details element opens.
10+
11+
```javascript
12+
// Find the row by item name
13+
const row = page.locator('tr', { has: page.getByText(itemName, { exact: true }) });
14+
15+
// Hover, then click the <summary> element via page.evaluate —
16+
// clicking <summary> is the reliable semantic toggle for <details data-url>;
17+
// page.evaluate bypasses Playwright's visibility constraints
18+
await row.hover();
19+
await page.evaluate((name) => {
20+
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
21+
const row = link?.closest('tr');
22+
row?.querySelector('details summary')?.click();
23+
}, itemName);
24+
25+
// Wait for AJAX menu content (scoped to row), then click the action —
26+
// CRITICAL: scope the querySelector to the row — document.querySelector will hit the first
27+
// (possibly empty) .options-menu in DOM order, which may not be the one that just loaded
28+
await row.locator('.options-menu').waitFor({ state: 'visible' });
29+
await page.evaluate((name) => {
30+
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
31+
const row = link?.closest('tr');
32+
row?.querySelector('.options-menu a[data-method="delete"]')?.click();
33+
}, itemName);
34+
```
35+
36+
## Confirmation Dialog (overmind)
37+
38+
Destructive actions (delete, etc.) open a confirmation dialog injected via `data-remote="true"` XHR. The dialog re-renders after injection — Playwright's normal `.click()` fails with "element was detached from DOM".
39+
40+
```javascript
41+
// Wait on the button (not just the container — the container shell pre-exists empty)
42+
await page.locator('.overmind-dialog-container button[type="submit"]').waitFor({ state: 'visible' });
43+
// Dispatch click synchronously in-page to bypass detachment retries
44+
await page.evaluate(() => {
45+
document.querySelector('.overmind-dialog-container button[type="submit"]').click();
46+
});
47+
// After destructive confirmation, do an explicit goto to bypass Turbolinks cache-preview —
48+
// Turbolinks may serve a stale cached page before the real GET completes, causing assertions
49+
// to run against old DOM. page.goto forces a fresh server-rendered response.
50+
await page.goto(BASE_URL + '/expected-path');
51+
await page.waitForLoadState('domcontentloaded');
52+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// NOTE: This spec runs a composite fixture chain: loginComponent (setup) + newprojectComponent (setup)
2+
// + delprojectComponent (test) — 3 components total. The effective Playwright timeout may need to be
3+
// increased to at least 90000ms in playwright.config.js if this test times out in CI.
4+
5+
const { test, expect } = require('@playwright/test');
6+
const { loginComponent } = require('../login-component');
7+
const { newprojectComponent } = require('../newproject-component');
8+
const { delprojectComponent } = require('../delproject-component');
9+
10+
// Set projectName_7 to "delete-abc" where abc is a 3-digit random number (validation_requirements).
11+
// Must be assigned at module level so beforeEach can read it when wiring newProject's projectName_6 input.
12+
if (!process.env.projectName_7) {
13+
process.env.projectName_7 = `delete-${String(Math.floor(Math.random() * 900) + 100)}`;
14+
}
15+
16+
test.describe("delProject", () => {
17+
test.beforeEach(async ({ page }) => {
18+
await loginComponent(page);
19+
const projectname6EnvValue = process.env.projectName_7;
20+
if (projectname6EnvValue === undefined) {
21+
throw new Error("Missing required environment variable projectName_7 for component input projectName_6");
22+
}
23+
process.env.projectName_6 = projectname6EnvValue;
24+
await newprojectComponent(page);
25+
});
26+
27+
test("Check", async ({ page }) => {
28+
// Validate that component can delete a project
29+
const projectname7EnvValue = process.env.projectName_7;
30+
if (projectname7EnvValue === undefined) {
31+
throw new Error("Missing required environment variable projectName_7 for component input projectName_7");
32+
}
33+
process.env.projectName_7 = projectname7EnvValue;
34+
await delprojectComponent(page);
35+
36+
// After project is deleted, confirm it is no longer in the projects listing
37+
// not.toBeAttached() — DOM node should be entirely removed, not merely hidden
38+
await expect(
39+
page.locator('#wrapper').getByRole('link', { name: projectname7EnvValue, exact: true })
40+
).not.toBeAttached();
41+
});
42+
43+
});

tests/components/checks/login.spec.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ const { test, expect } = require('@playwright/test');
22
const { loginComponent } = require('../login-component');
33

44
test.describe("login", () => {
5-
test("login", async ({ page }) => {
6-
// TODO: Add structured test cases in the Validation panel to generate test stubs here.
7-
process.env.BASE_URL = process.env.BASE_URL || 'https://s2testorg.stridespace.com';
8-
process.env.PASSWORD = process.env.PASSWORD || '';
5+
test("check domain", async ({ page }) => {
6+
// Confirm that the correct domain is being used
7+
const BASE_URL = process.env.BASE_URL || 'https://s2testorg.stridespace.com/';
8+
process.env.BASE_URL = BASE_URL;
9+
const PASSWORD = process.env.PASSWORD || '';
10+
process.env.PASSWORD = PASSWORD;
11+
912
await loginComponent(page);
10-
// Add assertions here
13+
14+
// Step: EXPECT: DIV: s2testorg Help Mark Underseth
15+
// Confirm "s2testorg" domain — assert org name is present in the page header
16+
// No role available for DIV.header-top — CSS class scoped to stable #header ancestor
17+
await expect(page.locator('#header .header-top')).toContainText('s2testorg');
1118
});
19+
1220
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// NOTE: This spec runs login (setup) + newprojectComponent + delprojectComponent (teardown) — 3 components.
2+
// The combined chain carries significantly higher timeout risk. Consider increasing the configured
3+
// Playwright timeout to at least 90000ms in playwright.config.js if this test times out in CI.
4+
5+
const { test, expect } = require('@playwright/test');
6+
const { loginComponent } = require('../login-component');
7+
const { delprojectComponent } = require('../delproject-component');
8+
const { newprojectComponent } = require('../newproject-component');
9+
10+
test.describe("newProject", () => {
11+
test.beforeEach(async ({ page }) => {
12+
await loginComponent(page);
13+
});
14+
15+
test.afterEach(async ({ page }) => {
16+
const projectname7EnvValue = process.env.projectName_6;
17+
if (projectname7EnvValue === undefined) {
18+
throw new Error("Missing required environment variable projectName_6 for component input projectName_7");
19+
}
20+
process.env.projectName_7 = projectname7EnvValue;
21+
await delprojectComponent(page);
22+
});
23+
24+
test("Confirm New Project", async ({ page }) => {
25+
// Check if the new standalone project is created
26+
// Guidance: set projectName_6 to "test-<3-digit-random-number>" (e.g., "test-123") if not provided
27+
const projectname6EnvValue = process.env.projectName_6 ||
28+
`test-${String(Math.floor(Math.random() * 900) + 100)}`;
29+
process.env.projectName_6 = projectname6EnvValue;
30+
await newprojectComponent(page);
31+
32+
// Step: EXPECT: TH: testProject my test project
33+
// Instructions: Use projectName_6 to confirm created
34+
await expect(page.getByRole('link', { name: projectname6EnvValue, exact: true })).toBeVisible();
35+
});
36+
37+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Component: delProject
3+
* Description: Component to delete a project
4+
*
5+
* Steps:
6+
* 1. Navigate to $env.BASE_URL
7+
* 2. Click on "Projects"
8+
* 3. Click on options menu
9+
* 4. Click on "Delete"
10+
* 5. Click on "YES"
11+
* 6. Submit form
12+
*/
13+
async function delprojectComponent(page) {
14+
const projectName_7 = process.env.projectName_7;
15+
if (!projectName_7) throw new Error("Required input projectName_7 is not set");
16+
17+
const BASE_URL = process.env.BASE_URL;
18+
if (!BASE_URL) throw new Error('Environment variable BASE_URL is required but not defined');
19+
20+
// Step 1: Navigate to $env.BASE_URL
21+
await page.goto(BASE_URL);
22+
23+
// Step 2: Click on "Projects"
24+
await page.locator('#header').getByRole('link', { name: 'Projects' }).click();
25+
await page.waitForURL(/\/projects$/);
26+
27+
// Step 3: Click on options menu
28+
const projectRow = page.locator('tr', { has: page.getByText(projectName_7, { exact: true }) });
29+
await projectRow.hover();
30+
// Click the <summary> element — the semantic toggle for <details data-url>
31+
// page.evaluate bypasses Playwright's visibility model; summary is always in DOM regardless of hover state
32+
await page.evaluate((name) => {
33+
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
34+
const row = link?.closest('tr');
35+
row?.querySelector('details summary')?.click();
36+
}, projectName_7);
37+
38+
// Step 4: Click on "Delete"
39+
// DETAILS[data-url] — content is AJAX-injected; page.evaluate bypasses event synthesis
40+
// Scope to the correct row to avoid hitting another row's empty options-menu
41+
await projectRow.locator('.options-menu').waitFor({ state: 'visible' });
42+
await page.evaluate((name) => {
43+
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
44+
const row = link?.closest('tr');
45+
row?.querySelector('.options-menu a[data-method="delete"]')?.click();
46+
}, projectName_7);
47+
48+
// Step 5: Click on "YES"
49+
// Step 6: Submit form
50+
// Register DELETE response capture BEFORE clicking YES
51+
const deletePromise = page.waitForResponse(
52+
resp => resp.request().method() === 'DELETE' && resp.url().includes('/projects/'),
53+
{ timeout: 15000 }
54+
);
55+
// overmind dialog is AJAX-injected — page.evaluate bypasses stability check on re-renders
56+
await page.locator('.overmind-dialog-container button[type="submit"]').waitFor({ state: 'visible' });
57+
await page.evaluate(() => {
58+
document.querySelector('.overmind-dialog-container button[type="submit"]').click();
59+
});
60+
await deletePromise;
61+
// Explicit goto bypasses Turbolinks cache-preview — guarantees fresh server-rendered /projects page
62+
await page.goto(`${BASE_URL}/projects`);
63+
await page.waitForLoadState('domcontentloaded');
64+
}
65+
66+
module.exports = { delprojectComponent };

tests/components/login-component.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { expect } = require('@playwright/test');
2+
13
/**
24
* Component: login
35
* Description: Component to login into Testspace
@@ -10,6 +12,7 @@
1012
* 5. Enter password
1113
* 6. Click on "SUBMIT"
1214
* 7. Submit signin form
15+
* 8. Navigate to $env.BASE_URL
1316
*/
1417
async function loginComponent(page) {
1518
const BASE_URL = process.env.BASE_URL;
@@ -33,9 +36,11 @@ async function loginComponent(page) {
3336
// Step 6: Click on "SUBMIT"
3437
// Step 7: Submit signin form
3538
await page.getByRole('button', { name: 'Submit', exact: true }).click();
36-
// Form uses data-remote="true" (Rails UJS); Turbolinks follows the redirect as a top-level
37-
// navigation. Wait for the browser to leave the signin subdomain, then land on BASE_URL.
39+
// The signin form uses data-remote="true" (Rails UJS); Turbolinks follows the server redirect
40+
// as a top-level navigation away from signin.stridespace.com.
3841
await page.waitForURL(url => !url.hostname.startsWith('signin.'), { timeout: 30000 });
42+
43+
// Step 8: Navigate to $env.BASE_URL
3944
await page.goto(BASE_URL);
4045
}
4146

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const { expect } = require('@playwright/test');
2+
3+
/**
4+
* Component: newProject
5+
* Description: Component to create a new Standalone project
6+
*
7+
* Steps:
8+
* 1. Navigate to $env.BASE_URL
9+
* 2. Click on "New Project"
10+
* 3. Click on "STANDALONE"
11+
* 4. Enter name for the project
12+
* 5. Click on project description
13+
* 6. Enter project description
14+
* 7. Click on "SUBMIT"
15+
* 8. Submit new project dialog
16+
*/
17+
async function newprojectComponent(page) {
18+
// Inputs — handle every declared input, even if not used in steps
19+
const projectName_6 = process.env.projectName_6;
20+
if (!projectName_6) throw new Error("Required input projectName_6 is not set");
21+
22+
// Environment variables
23+
const BASE_URL = process.env.BASE_URL;
24+
if (!BASE_URL) throw new Error('Environment variable BASE_URL is required but not defined');
25+
26+
// Step 1: Navigate to $env.BASE_URL
27+
await page.goto(BASE_URL);
28+
29+
// Step 2: Click on "New Project"
30+
// data-remote="true" — AJAX call loads dialog inline, no page navigation
31+
await page.getByRole('link', { name: 'New Project' }).click();
32+
// Wait for AJAX-loaded dialog to render before interacting (composite flow, data-remote link)
33+
await page.locator('.overmind-dialog-container').waitFor({ state: 'visible' });
34+
35+
// Step 3: Click on "STANDALONE"
36+
await page.locator('#new-connected-project-dialog').getByRole('button', { name: 'Standalone', exact: true }).click();
37+
// Multi-step dialog transition: wait for New Project form dialog to appear
38+
await page.locator('#new-project-dialog').waitFor({ state: 'visible' });
39+
40+
// Step 4: Enter name for the project
41+
await page.locator('#new-project-dialog').getByLabel('Name').fill(projectName_6);
42+
43+
// Step 5: Click on project description
44+
// Step 6: Enter project description
45+
// fill() focuses the element automatically — click + fill collapsed into single call
46+
await page.locator('#new-project-dialog').getByLabel('Description').fill('this is a new standalone project');
47+
48+
// Step 7: Click on "SUBMIT"
49+
await page.locator('#new-project-dialog').getByRole('button', { name: 'Submit', exact: true }).click();
50+
51+
// Step 8: Submit new project dialog
52+
// Submit event triggered by click above — wait for post-submit navigation to complete
53+
// Form action is POST /projects (collection path) — redirect returns to projects listing
54+
await page.waitForLoadState('domcontentloaded');
55+
}
56+
57+
module.exports = { newprojectComponent };

0 commit comments

Comments
 (0)