diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..256cfb4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 `
` 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 element via page.evaluate — +// clicking is the reliable semantic toggle for
; +// 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'); +``` diff --git a/tests/components/checks/delproject.spec.js b/tests/components/checks/delproject.spec.js new file mode 100644 index 0000000..724a1bf --- /dev/null +++ b/tests/components/checks/delproject.spec.js @@ -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(); + }); + +}); diff --git a/tests/components/checks/login.spec.js b/tests/components/checks/login.spec.js index a21d55f..4d4a084 100644 --- a/tests/components/checks/login.spec.js +++ b/tests/components/checks/login.spec.js @@ -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'); }); + }); diff --git a/tests/components/checks/newproject.spec.js b/tests/components/checks/newproject.spec.js new file mode 100644 index 0000000..59aa2f7 --- /dev/null +++ b/tests/components/checks/newproject.spec.js @@ -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(); + }); + +}); diff --git a/tests/components/delproject-component.js b/tests/components/delproject-component.js new file mode 100644 index 0000000..e94c4bb --- /dev/null +++ b/tests/components/delproject-component.js @@ -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 element — the semantic toggle for
+ // 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 }; diff --git a/tests/components/login-component.js b/tests/components/login-component.js index b565bc2..54690ad 100644 --- a/tests/components/login-component.js +++ b/tests/components/login-component.js @@ -1,3 +1,5 @@ +const { expect } = require('@playwright/test'); + /** * Component: login * Description: Component to login into Testspace @@ -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; @@ -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); } diff --git a/tests/components/newproject-component.js b/tests/components/newproject-component.js new file mode 100644 index 0000000..28c8493 --- /dev/null +++ b/tests/components/newproject-component.js @@ -0,0 +1,57 @@ +const { expect } = require('@playwright/test'); + +/** + * 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 };