Skip to content
Merged
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
52 changes: 52 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copilot Instructions

## Application Under Test

Testspace (s2testorg.stridespace.com) — a test management SaaS.

## Hamburger / Options Menu (DETAILS[data-url])

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.

```javascript
// Find the row by item name
const row = page.locator('tr', { has: page.getByText(itemName, { exact: true }) });

// Hover, then click the <summary> element via page.evaluate —
// clicking <summary> is the reliable semantic toggle for <details data-url>;
// page.evaluate bypasses Playwright's visibility constraints
await row.hover();
await page.evaluate((name) => {
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
const row = link?.closest('tr');
row?.querySelector('details summary')?.click();
}, itemName);

// Wait for AJAX menu content (scoped to row), then click the action —
// CRITICAL: scope the querySelector to the row — document.querySelector will hit the first
// (possibly empty) .options-menu in DOM order, which may not be the one that just loaded
await row.locator('.options-menu').waitFor({ state: 'visible' });
await page.evaluate((name) => {
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
const row = link?.closest('tr');
row?.querySelector('.options-menu a[data-method="delete"]')?.click();
}, itemName);
```

## Confirmation Dialog (overmind)

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".

```javascript
// Wait on the button (not just the container — the container shell pre-exists empty)
await page.locator('.overmind-dialog-container button[type="submit"]').waitFor({ state: 'visible' });
// Dispatch click synchronously in-page to bypass detachment retries
await page.evaluate(() => {
document.querySelector('.overmind-dialog-container button[type="submit"]').click();
});
// After destructive confirmation, do an explicit goto to bypass Turbolinks cache-preview —
// Turbolinks may serve a stale cached page before the real GET completes, causing assertions
// to run against old DOM. page.goto forces a fresh server-rendered response.
await page.goto(BASE_URL + '/expected-path');
await page.waitForLoadState('domcontentloaded');
```
43 changes: 43 additions & 0 deletions tests/components/checks/delproject.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// NOTE: This spec runs a composite fixture chain: loginComponent (setup) + newprojectComponent (setup)
// + delprojectComponent (test) — 3 components total. The effective Playwright timeout may need to be
// increased to at least 90000ms in playwright.config.js if this test times out in CI.

const { test, expect } = require('@playwright/test');
const { loginComponent } = require('../login-component');
const { newprojectComponent } = require('../newproject-component');
const { delprojectComponent } = require('../delproject-component');

// Set projectName_7 to "delete-abc" where abc is a 3-digit random number (validation_requirements).
// Must be assigned at module level so beforeEach can read it when wiring newProject's projectName_6 input.
if (!process.env.projectName_7) {
process.env.projectName_7 = `delete-${String(Math.floor(Math.random() * 900) + 100)}`;
}

test.describe("delProject", () => {
test.beforeEach(async ({ page }) => {
await loginComponent(page);
const projectname6EnvValue = process.env.projectName_7;
if (projectname6EnvValue === undefined) {
throw new Error("Missing required environment variable projectName_7 for component input projectName_6");
}
process.env.projectName_6 = projectname6EnvValue;
await newprojectComponent(page);
});

test("Check", async ({ page }) => {
// Validate that component can delete a project
const projectname7EnvValue = process.env.projectName_7;
if (projectname7EnvValue === undefined) {
throw new Error("Missing required environment variable projectName_7 for component input projectName_7");
}
process.env.projectName_7 = projectname7EnvValue;
await delprojectComponent(page);

// After project is deleted, confirm it is no longer in the projects listing
// not.toBeAttached() — DOM node should be entirely removed, not merely hidden
await expect(
page.locator('#wrapper').getByRole('link', { name: projectname7EnvValue, exact: true })
).not.toBeAttached();
});

});
18 changes: 13 additions & 5 deletions tests/components/checks/login.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ const { test, expect } = require('@playwright/test');
const { loginComponent } = require('../login-component');

test.describe("login", () => {
test("login", async ({ page }) => {
// TODO: Add structured test cases in the Validation panel to generate test stubs here.
process.env.BASE_URL = process.env.BASE_URL || 'https://s2testorg.stridespace.com';
process.env.PASSWORD = process.env.PASSWORD || '';
test("check domain", async ({ page }) => {
// Confirm that the correct domain is being used
const BASE_URL = process.env.BASE_URL || 'https://s2testorg.stridespace.com/';
process.env.BASE_URL = BASE_URL;
const PASSWORD = process.env.PASSWORD || '';
process.env.PASSWORD = PASSWORD;

await loginComponent(page);
// Add assertions here

// Step: EXPECT: DIV: s2testorg Help Mark Underseth
// Confirm "s2testorg" domain — assert org name is present in the page header
// No role available for DIV.header-top — CSS class scoped to stable #header ancestor
await expect(page.locator('#header .header-top')).toContainText('s2testorg');
});

});
37 changes: 37 additions & 0 deletions tests/components/checks/newproject.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// NOTE: This spec runs login (setup) + newprojectComponent + delprojectComponent (teardown) — 3 components.
// The combined chain carries significantly higher timeout risk. Consider increasing the configured
// Playwright timeout to at least 90000ms in playwright.config.js if this test times out in CI.

const { test, expect } = require('@playwright/test');
const { loginComponent } = require('../login-component');
const { delprojectComponent } = require('../delproject-component');
const { newprojectComponent } = require('../newproject-component');

test.describe("newProject", () => {
test.beforeEach(async ({ page }) => {
await loginComponent(page);
});

test.afterEach(async ({ page }) => {
const projectname7EnvValue = process.env.projectName_6;
if (projectname7EnvValue === undefined) {
throw new Error("Missing required environment variable projectName_6 for component input projectName_7");
}
process.env.projectName_7 = projectname7EnvValue;
await delprojectComponent(page);
});

test("Confirm New Project", async ({ page }) => {
// Check if the new standalone project is created
// Guidance: set projectName_6 to "test-<3-digit-random-number>" (e.g., "test-123") if not provided
const projectname6EnvValue = process.env.projectName_6 ||
`test-${String(Math.floor(Math.random() * 900) + 100)}`;
process.env.projectName_6 = projectname6EnvValue;
await newprojectComponent(page);

// Step: EXPECT: TH: testProject my test project
// Instructions: Use projectName_6 to confirm created
await expect(page.getByRole('link', { name: projectname6EnvValue, exact: true })).toBeVisible();
});

});
66 changes: 66 additions & 0 deletions tests/components/delproject-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Component: delProject
* Description: Component to delete a project
*
* Steps:
* 1. Navigate to $env.BASE_URL
* 2. Click on "Projects"
* 3. Click on options menu
* 4. Click on "Delete"
* 5. Click on "YES"
* 6. Submit form
*/
async function delprojectComponent(page) {
const projectName_7 = process.env.projectName_7;
if (!projectName_7) throw new Error("Required input projectName_7 is not set");

const BASE_URL = process.env.BASE_URL;
if (!BASE_URL) throw new Error('Environment variable BASE_URL is required but not defined');

// Step 1: Navigate to $env.BASE_URL
await page.goto(BASE_URL);

// Step 2: Click on "Projects"
await page.locator('#header').getByRole('link', { name: 'Projects' }).click();
await page.waitForURL(/\/projects$/);

// Step 3: Click on options menu
const projectRow = page.locator('tr', { has: page.getByText(projectName_7, { exact: true }) });
await projectRow.hover();
// Click the <summary> element — the semantic toggle for <details data-url>
// page.evaluate bypasses Playwright's visibility model; summary is always in DOM regardless of hover state
await page.evaluate((name) => {
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
const row = link?.closest('tr');
row?.querySelector('details summary')?.click();
}, projectName_7);

// Step 4: Click on "Delete"
// DETAILS[data-url] — content is AJAX-injected; page.evaluate bypasses event synthesis
// Scope to the correct row to avoid hitting another row's empty options-menu
await projectRow.locator('.options-menu').waitFor({ state: 'visible' });
await page.evaluate((name) => {
const link = Array.from(document.querySelectorAll('tr a')).find(a => a.textContent.trim() === name);
const row = link?.closest('tr');
row?.querySelector('.options-menu a[data-method="delete"]')?.click();
}, projectName_7);

// Step 5: Click on "YES"
// Step 6: Submit form
// Register DELETE response capture BEFORE clicking YES
const deletePromise = page.waitForResponse(
resp => resp.request().method() === 'DELETE' && resp.url().includes('/projects/'),
{ timeout: 15000 }
);
// overmind dialog is AJAX-injected — page.evaluate bypasses stability check on re-renders
await page.locator('.overmind-dialog-container button[type="submit"]').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.overmind-dialog-container button[type="submit"]').click();
});
await deletePromise;
// Explicit goto bypasses Turbolinks cache-preview — guarantees fresh server-rendered /projects page
await page.goto(`${BASE_URL}/projects`);
await page.waitForLoadState('domcontentloaded');
}

module.exports = { delprojectComponent };
9 changes: 7 additions & 2 deletions tests/components/login-component.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { expect } = require('@playwright/test');

/**
Comment on lines +1 to 3
* Component: login
* Description: Component to login into Testspace
Expand All @@ -10,6 +12,7 @@
* 5. Enter password
* 6. Click on "SUBMIT"
* 7. Submit signin form
* 8. Navigate to $env.BASE_URL
*/
async function loginComponent(page) {
const BASE_URL = process.env.BASE_URL;
Expand All @@ -33,9 +36,11 @@ async function loginComponent(page) {
// Step 6: Click on "SUBMIT"
// Step 7: Submit signin form
await page.getByRole('button', { name: 'Submit', exact: true }).click();
// Form uses data-remote="true" (Rails UJS); Turbolinks follows the redirect as a top-level
// navigation. Wait for the browser to leave the signin subdomain, then land on BASE_URL.
// The signin form uses data-remote="true" (Rails UJS); Turbolinks follows the server redirect
// as a top-level navigation away from signin.stridespace.com.
await page.waitForURL(url => !url.hostname.startsWith('signin.'), { timeout: 30000 });

// Step 8: Navigate to $env.BASE_URL
await page.goto(BASE_URL);
}

Expand Down
57 changes: 57 additions & 0 deletions tests/components/newproject-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const { expect } = require('@playwright/test');

/**
Comment on lines +1 to +3
* Component: newProject
* Description: Component to create a new Standalone project
*
* Steps:
* 1. Navigate to $env.BASE_URL
* 2. Click on "New Project"
* 3. Click on "STANDALONE"
* 4. Enter name for the project
* 5. Click on project description
* 6. Enter project description
* 7. Click on "SUBMIT"
* 8. Submit new project dialog
*/
async function newprojectComponent(page) {
// Inputs — handle every declared input, even if not used in steps
const projectName_6 = process.env.projectName_6;
if (!projectName_6) throw new Error("Required input projectName_6 is not set");

// Environment variables
const BASE_URL = process.env.BASE_URL;
if (!BASE_URL) throw new Error('Environment variable BASE_URL is required but not defined');

// Step 1: Navigate to $env.BASE_URL
await page.goto(BASE_URL);

// Step 2: Click on "New Project"
// data-remote="true" — AJAX call loads dialog inline, no page navigation
await page.getByRole('link', { name: 'New Project' }).click();
// Wait for AJAX-loaded dialog to render before interacting (composite flow, data-remote link)
await page.locator('.overmind-dialog-container').waitFor({ state: 'visible' });

// Step 3: Click on "STANDALONE"
await page.locator('#new-connected-project-dialog').getByRole('button', { name: 'Standalone', exact: true }).click();
// Multi-step dialog transition: wait for New Project form dialog to appear
await page.locator('#new-project-dialog').waitFor({ state: 'visible' });

// Step 4: Enter name for the project
await page.locator('#new-project-dialog').getByLabel('Name').fill(projectName_6);

// Step 5: Click on project description
// Step 6: Enter project description
// fill() focuses the element automatically — click + fill collapsed into single call
await page.locator('#new-project-dialog').getByLabel('Description').fill('this is a new standalone project');

// Step 7: Click on "SUBMIT"
await page.locator('#new-project-dialog').getByRole('button', { name: 'Submit', exact: true }).click();

// Step 8: Submit new project dialog
// Submit event triggered by click above — wait for post-submit navigation to complete
// Form action is POST /projects (collection path) — redirect returns to projects listing
await page.waitForLoadState('domcontentloaded');
}

module.exports = { newprojectComponent };
Loading