All checks were successful
CI / goreleaser-lint (pull_request) Successful in 8s
CI / format (pull_request) Successful in 1m33s
CI / lint (pull_request) Successful in 3m23s
CI / build (pull_request) Successful in 3m5s
CI / test (pull_request) Successful in 4m38s
CI / e2e (pull_request) Successful in 26m24s
playwright-go v0.5200.1 fetches its driver from playwright.azureedge.net and two sibling mirrors. Microsoft retired that CDN, so the e2e job died on a 404 before it ever reached a test. The replacement host serves no builds/driver zip for any version — 1.52 through 1.62 all answer 400 — so there is nothing to pin or mirror to. v0.6100.0 onwards pulls playwright-core from npm and Node from nodejs.org instead, and moves the module path back to github.com/mxschmitt/playwright-go. Take v0.6201.1, the latest. The Playwright 1.52 to 1.62 jump needs no changes beyond the import path. The CLI must match the module the tests link against, and the three places that ran it had already drifted apart: the workflow pinned v0.5200.1, the Makefile and scripts/e2e.sh asked for @latest. Both now read the version from e2e/go.mod, and the workflow calls make e2e-install.
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package e2eutil
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mxschmitt/playwright-go"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var globalReporter *Reporter
|
|
|
|
// TestHelper provides utility methods for E2E testing.
|
|
type TestHelper struct {
|
|
t *testing.T
|
|
testName string
|
|
baseURL string
|
|
page playwright.Page
|
|
screenshotNum int
|
|
screenshotDir string
|
|
}
|
|
|
|
// NewTestHelper creates a new TestHelper instance.
|
|
func NewTestHelper(t *testing.T, testName string, baseURL string, page playwright.Page) *TestHelper {
|
|
t.Helper()
|
|
|
|
// Initialize reporter if not already set
|
|
if globalReporter == nil {
|
|
globalReporter = GetReporter()
|
|
}
|
|
|
|
// Get screenshot directory
|
|
screenshotDir := filepath.Join("..", "screenshots")
|
|
|
|
h := &TestHelper{
|
|
t: t,
|
|
testName: testName,
|
|
baseURL: baseURL,
|
|
page: page,
|
|
screenshotNum: 1,
|
|
screenshotDir: screenshotDir,
|
|
}
|
|
|
|
// Initialize test in reporter
|
|
h.recordResult("Test started", true, nil, "", 0)
|
|
|
|
// Automatically mark test as complete when it finishes
|
|
t.Cleanup(func() {
|
|
if globalReporter != nil {
|
|
globalReporter.TestFinished(t)
|
|
}
|
|
})
|
|
|
|
return h
|
|
}
|
|
|
|
// recordResult is a helper to record results to the reporter if available
|
|
func (h *TestHelper) recordResult(description string, passed bool, err error, screenshotPath string, duration time.Duration) {
|
|
if globalReporter != nil {
|
|
globalReporter.AddResult(h.testName, description, passed, err, screenshotPath, duration)
|
|
}
|
|
}
|
|
|
|
// Navigate navigates to the given path relative to the base URL.
|
|
func (h *TestHelper) Navigate(path string) error {
|
|
h.t.Helper()
|
|
url := h.baseURL + path
|
|
h.t.Logf("Navigating to %s", url)
|
|
_, err := h.page.Goto(url)
|
|
return err
|
|
}
|
|
|
|
// Fill fills an input field with the given value.
|
|
func (h *TestHelper) Fill(selector, value string) error {
|
|
h.t.Helper()
|
|
h.t.Logf("Filling %s with value", selector)
|
|
return h.page.Fill(selector, value)
|
|
}
|
|
|
|
// Click clicks on an element matching the given selector.
|
|
func (h *TestHelper) Click(selector string) error {
|
|
h.t.Helper()
|
|
h.t.Logf("Clicking %s", selector)
|
|
return h.page.Click(selector)
|
|
}
|
|
|
|
// Screenshot captures a screenshot with an auto-incrementing number.
|
|
// Returns the path to the saved screenshot.
|
|
func (h *TestHelper) Screenshot(name string) string {
|
|
h.t.Helper()
|
|
|
|
// Create filename with number prefix
|
|
filename := fmt.Sprintf("%02d-%s.png", h.screenshotNum, name)
|
|
h.screenshotNum++
|
|
|
|
// Full path
|
|
fullPath := filepath.Join(h.screenshotDir, filename)
|
|
|
|
// Capture screenshot
|
|
_, err := h.page.Screenshot(playwright.PageScreenshotOptions{
|
|
Path: playwright.String(fullPath),
|
|
})
|
|
if err != nil {
|
|
h.t.Logf("Failed to capture screenshot: %v", err)
|
|
return ""
|
|
}
|
|
|
|
h.t.Logf("Screenshot saved: %s", fullPath)
|
|
return fullPath
|
|
}
|
|
|
|
// WaitForSelector waits for an element matching the selector to appear.
|
|
func (h *TestHelper) WaitForSelector(selector string, timeout time.Duration) error {
|
|
h.t.Helper()
|
|
h.t.Logf("Waiting for selector %s", selector)
|
|
_, err := h.page.WaitForSelector(selector, playwright.PageWaitForSelectorOptions{
|
|
Timeout: playwright.Float(float64(timeout.Milliseconds())),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// AssertTextContains asserts that an element contains the expected text.
|
|
func (h *TestHelper) AssertTextContains(selector, expected string) {
|
|
h.t.Helper()
|
|
start := time.Now()
|
|
|
|
text, err := h.page.Locator(selector).TextContent()
|
|
if err != nil {
|
|
duration := time.Since(start)
|
|
screenshot := h.Screenshot("assertion-failure-text-content")
|
|
h.recordResult(fmt.Sprintf("Assert text in %s contains %q", selector, expected), false,
|
|
fmt.Errorf("Failed to get text content of %s: %v", selector, err), screenshot, duration)
|
|
require.NoError(h.t, err, "Failed to get text content of %s", selector)
|
|
return
|
|
}
|
|
|
|
passed := strings.Contains(text, expected)
|
|
var screenshot string
|
|
var resultErr error
|
|
|
|
if !passed {
|
|
resultErr = fmt.Errorf("Element %s should contain text %q, got %q", selector, expected, text)
|
|
screenshot = h.Screenshot("assertion-failure-text-contains")
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
h.recordResult(fmt.Sprintf("Assert text in %s contains %q", selector, expected), passed, resultErr, screenshot, duration)
|
|
require.Contains(h.t, text, expected, "Element %s should contain text %q", selector, expected)
|
|
}
|
|
|
|
// AssertURL asserts that the current URL contains the expected string.
|
|
func (h *TestHelper) AssertURL(expected string) {
|
|
h.t.Helper()
|
|
start := time.Now()
|
|
|
|
url := h.page.URL()
|
|
passed := strings.Contains(url, expected)
|
|
|
|
var err error
|
|
var screenshot string
|
|
|
|
if !passed {
|
|
err = fmt.Errorf("URL should contain %q, got %q", expected, url)
|
|
screenshot = h.Screenshot("assertion-failure-url")
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
h.recordResult(fmt.Sprintf("Assert URL contains %q", expected), passed, err, screenshot, duration)
|
|
require.Contains(h.t, url, expected, "URL should contain %q, got %q", expected, url)
|
|
h.t.Logf("URL contains %q: %s", expected, url)
|
|
}
|
|
|
|
// GetURL returns the current page URL.
|
|
func (h *TestHelper) GetURL() string {
|
|
return h.page.URL()
|
|
}
|
|
|
|
// IsVisible checks if any element matching the selector is visible.
|
|
// Uses .First() to avoid Playwright strict-mode errors when the selector
|
|
// resolves to multiple elements.
|
|
func (h *TestHelper) IsVisible(selector string) bool {
|
|
h.t.Helper()
|
|
visible, err := h.page.Locator(selector).First().IsVisible()
|
|
if err != nil {
|
|
h.t.Logf("Error checking visibility of %s: %v", selector, err)
|
|
return false
|
|
}
|
|
return visible
|
|
}
|
|
|
|
// GetText returns the text content of an element.
|
|
func (h *TestHelper) GetText(selector string) (string, error) {
|
|
h.t.Helper()
|
|
return h.page.Locator(selector).TextContent()
|
|
}
|
|
|
|
// AssertNotVisible asserts that an element is not visible.
|
|
func (h *TestHelper) AssertNotVisible(selector string, message string) {
|
|
h.t.Helper()
|
|
start := time.Now()
|
|
|
|
visible := h.IsVisible(selector)
|
|
passed := !visible
|
|
|
|
if message == "" {
|
|
message = fmt.Sprintf("Element %s should not be visible", selector)
|
|
}
|
|
|
|
var err error
|
|
var screenshot string
|
|
|
|
if !passed {
|
|
err = fmt.Errorf("%s", message)
|
|
screenshot = h.Screenshot("assertion-failure-not-visible")
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
h.recordResult(fmt.Sprintf("Assert %s not visible", selector), passed, err, screenshot, duration)
|
|
require.False(h.t, visible, message)
|
|
}
|
|
|
|
// AssertVisible asserts that an element is visible.
|
|
func (h *TestHelper) AssertVisible(selector string, message string) {
|
|
h.t.Helper()
|
|
start := time.Now()
|
|
|
|
visible := h.IsVisible(selector)
|
|
passed := visible
|
|
|
|
if message == "" {
|
|
message = fmt.Sprintf("Element %s should be visible", selector)
|
|
}
|
|
|
|
var err error
|
|
var screenshot string
|
|
|
|
if !passed {
|
|
err = fmt.Errorf("%s", message)
|
|
screenshot = h.Screenshot("assertion-failure-visible")
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
h.recordResult(fmt.Sprintf("Assert %s visible", selector), passed, err, screenshot, duration)
|
|
require.True(h.t, visible, message)
|
|
}
|
|
|
|
// GetCurrentPath returns the path portion of the current URL (without baseURL).
|
|
func (h *TestHelper) GetCurrentPath() string {
|
|
h.t.Helper()
|
|
|
|
url := h.page.URL()
|
|
// Remove baseURL to get just the path
|
|
path := strings.TrimPrefix(url, h.baseURL)
|
|
return path
|
|
}
|
|
|
|
// LoginAsAdmin logs in as admin user with default credentials.
|
|
func (h *TestHelper) LoginAsAdmin() error {
|
|
h.t.Helper()
|
|
|
|
// Navigate to login page
|
|
if err := h.Navigate("/login"); err != nil {
|
|
return fmt.Errorf("failed to navigate to login page: %w", err)
|
|
}
|
|
|
|
// Wait for login form
|
|
if err := h.WaitForSelector("#email", 5*time.Second); err != nil {
|
|
return fmt.Errorf("email input not found: %w", err)
|
|
}
|
|
|
|
// Fill credentials
|
|
if err := h.Fill("#email", "hako@hako.com"); err != nil {
|
|
return fmt.Errorf("failed to fill email: %w", err)
|
|
}
|
|
|
|
if err := h.Fill("#password", "hako"); err != nil {
|
|
return fmt.Errorf("failed to fill password: %w", err)
|
|
}
|
|
|
|
// Submit form
|
|
if err := h.Click("button[type=\"submit\"]"); err != nil {
|
|
return fmt.Errorf("failed to click submit: %w", err)
|
|
}
|
|
|
|
// Wait for redirect to home
|
|
time.Sleep(2 * time.Second)
|
|
|
|
h.t.Log("Logged in as admin")
|
|
return nil
|
|
}
|
|
|
|
// Logout logs out the current user.
|
|
func (h *TestHelper) Logout() error {
|
|
h.t.Helper()
|
|
|
|
// Click logout button (adjust selector based on actual UI)
|
|
if err := h.Click("a[href=\"/logout\"]"); err != nil {
|
|
return fmt.Errorf("failed to click logout: %w", err)
|
|
}
|
|
|
|
time.Sleep(1 * time.Second)
|
|
h.t.Log("Logged out")
|
|
return nil
|
|
}
|
|
|
|
// CreateLink creates a new link via the Quick Add form.
|
|
// Navigates to /home first to ensure the form is available even when called
|
|
// repeatedly (after the first call the user is redirected to /links).
|
|
func (h *TestHelper) CreateLink(url string) error {
|
|
h.t.Helper()
|
|
|
|
// Ensure we're on the home page where the Quick Add form lives.
|
|
if !strings.HasSuffix(h.GetURL(), "/home") {
|
|
if err := h.Navigate("/home"); err != nil {
|
|
return fmt.Errorf("failed to navigate to home: %w", err)
|
|
}
|
|
}
|
|
|
|
// Wait for URL input on home page
|
|
if err := h.WaitForSelector("input[name=\"url\"]", 5*time.Second); err != nil {
|
|
return fmt.Errorf("URL input not found: %w", err)
|
|
}
|
|
|
|
// Fill URL
|
|
if err := h.Fill("input[name=\"url\"]", url); err != nil {
|
|
return fmt.Errorf("failed to fill URL: %w", err)
|
|
}
|
|
|
|
// Submit form
|
|
if err := h.Click("button[type=\"submit\"]"); err != nil {
|
|
return fmt.Errorf("failed to submit form: %w", err)
|
|
}
|
|
|
|
// Wait for redirect to /links page
|
|
start := time.Now()
|
|
for time.Since(start) < 5*time.Second {
|
|
currentURL := h.GetURL()
|
|
if strings.Contains(currentURL, "/links") {
|
|
h.t.Logf("Successfully redirected to links page")
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
h.t.Logf("Created link: %s", url)
|
|
return nil
|
|
}
|
|
|
|
// SearchLinks performs a search for links.
|
|
func (h *TestHelper) SearchLinks(query string) error {
|
|
h.t.Helper()
|
|
|
|
// Wait for search input
|
|
if err := h.WaitForSelector("input[name=\"search\"]", 5*time.Second); err != nil {
|
|
return fmt.Errorf("search input not found: %w", err)
|
|
}
|
|
|
|
// Fill search query
|
|
if err := h.Fill("input[name=\"search\"]", query); err != nil {
|
|
return fmt.Errorf("failed to fill search query: %w", err)
|
|
}
|
|
|
|
// Submit search (might be automatic or require button click)
|
|
time.Sleep(1 * time.Second)
|
|
|
|
h.t.Logf("Searched for: %s", query)
|
|
return nil
|
|
}
|
|
|
|
// WaitForText waits for an element to contain specific text.
|
|
func (h *TestHelper) WaitForText(selector, text string, timeout time.Duration) error {
|
|
h.t.Helper()
|
|
|
|
start := time.Now()
|
|
for time.Since(start) < timeout {
|
|
content, err := h.GetText(selector)
|
|
if err == nil && strings.Contains(content, text) {
|
|
return nil
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
return fmt.Errorf("timeout waiting for text %q in selector %s", text, selector)
|
|
}
|
|
|
|
// WaitForElementCount waits for a specific number of elements matching the selector.
|
|
func (h *TestHelper) WaitForElementCount(selector string, count int, timeout time.Duration) error {
|
|
h.t.Helper()
|
|
|
|
start := time.Now()
|
|
for time.Since(start) < timeout {
|
|
elements, err := h.page.Locator(selector).All()
|
|
if err == nil && len(elements) == count {
|
|
return nil
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
return fmt.Errorf("timeout waiting for %d elements matching %s", count, selector)
|
|
}
|
|
|
|
// SelectDropdown selects an option from a dropdown.
|
|
func (h *TestHelper) SelectDropdown(selector, value string) error {
|
|
h.t.Helper()
|
|
|
|
h.t.Logf("Selecting %s from dropdown %s", value, selector)
|
|
_, err := h.page.SelectOption(selector, playwright.SelectOptionValues{Values: &[]string{value}})
|
|
return err
|
|
}
|
|
|
|
// WaitForArchiveComplete waits for an archive to complete processing.
|
|
func (h *TestHelper) WaitForArchiveComplete(timeout time.Duration) error {
|
|
h.t.Helper()
|
|
|
|
// Wait for status indicator to show "completed" or similar
|
|
// This is a generic implementation - adjust based on actual UI
|
|
return h.WaitForText(".archive-status", "completed", timeout)
|
|
}
|
|
|
|
// GetArchiveStatus returns the current archive status.
|
|
func (h *TestHelper) GetArchiveStatus() (string, error) {
|
|
h.t.Helper()
|
|
|
|
return h.GetText(".archive-status")
|
|
}
|
|
|
|
// FillForm fills multiple form fields at once.
|
|
func (h *TestHelper) FillForm(fields map[string]string) error {
|
|
h.t.Helper()
|
|
|
|
for selector, value := range fields {
|
|
if err := h.Fill(selector, value); err != nil {
|
|
return fmt.Errorf("failed to fill %s: %w", selector, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetElementCount returns the number of elements matching the selector.
|
|
func (h *TestHelper) GetElementCount(selector string) (int, error) {
|
|
h.t.Helper()
|
|
|
|
elements, err := h.page.Locator(selector).All()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(elements), nil
|
|
}
|
|
|
|
// WaitForPath waits for the current path to contain the expected path.
|
|
func (h *TestHelper) WaitForPath(expectedPath string, timeout time.Duration) error {
|
|
h.t.Helper()
|
|
start := time.Now()
|
|
for time.Since(start) < timeout {
|
|
currentPath := h.GetCurrentPath()
|
|
if strings.Contains(currentPath, expectedPath) {
|
|
return nil
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("timeout waiting for path containing %q", expectedPath)
|
|
}
|