Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9df9b4e
test: :white_check_mark: Add unit tests to get to 80% coverage
thkruz Jan 3, 2026
a21954f
feat: :sparkles: Add operations log management and UI
thkruz Jan 4, 2026
8761943
feat: :sparkles: Implement dynamic antenna options in UI
thkruz Jan 4, 2026
7728907
fix: :bug: fix issue where asset tree remains locked on restart/continue
thkruz Jan 4, 2026
f45712e
refactor: :recycle: Support multi-instance in ACU and OMT adapters
thkruz Jan 4, 2026
faeec38
feat: :sparkles: Add simulated time management and UI updates
thkruz Jan 4, 2026
4394a75
fix: :bug: fix beacon frequency not being set from target satellite
thkruz Jan 4, 2026
dd51143
test: :white_check_mark: add unit tests
thkruz Jan 4, 2026
c470aee
test: :white_check_mark: add additional tests for OMTModule and Modal…
thkruz Jan 4, 2026
3146497
feat: :sparkles: configure Playwright for end-to-end testing
thkruz Jan 4, 2026
7274821
feat: :sparkles: add developer menu and access control
thkruz Jan 4, 2026
22b8d81
feat: :sparkles: add logging level configuration
thkruz Jan 4, 2026
b115eb3
refactor: :recycle: replace console.log with Logger.info
thkruz Jan 4, 2026
111a81a
feat: :sparkles: add tab-active condition for objectives
thkruz Jan 4, 2026
05501cf
feat: :sparkles: add loopback control and input power display
thkruz Jan 4, 2026
67ea569
test: :white_check_mark: automate e2e testing of scenario 1
thkruz Jan 4, 2026
df81eed
docs: :memo: add retrospective for Scenario 1 E2E test
thkruz Jan 4, 2026
2f73c10
feat: :sparkles: add NICE Framework codes to Objective
thkruz Jan 4, 2026
eceb701
feat: :sparkles: support prefix matching for tab selection
thkruz Jan 4, 2026
a5aa6fd
feat: :sparkles: add Scenario 2 objectives and tests
thkruz Jan 6, 2026
761eec1
feat: :sparkles: enhance color gradient for amplitude visualization
thkruz Jan 6, 2026
0d7f9be
chore: :wrench: add TODO for origin validation in messaging
thkruz Jan 6, 2026
4d14787
feat: :sparkles: add support for new BUC conditions
thkruz Jan 8, 2026
9f73821
feat: :sparkles: add character support in quiz management
thkruz Jan 8, 2026
474b36f
feat: :sparkles: add encryption and payload adapters
thkruz Jan 8, 2026
58fd3b4
style: :art: update LED classes for alarm indicators
thkruz Jan 8, 2026
ef3c135
style: :art: update LED classes for alarm indicators
thkruz Jan 8, 2026
b7d0642
feat: :sparkles: add signal status indicator to filter adapter
thkruz Jan 8, 2026
5c1b812
style: :art: update LED classes for alarm indicators
thkruz Jan 8, 2026
9c21756
feat: :sparkles: add preserveOptionOrder to quiz options
thkruz Jan 8, 2026
2e6092d
feat: :sparkles: add tx-modem-not-transmitting condition
thkruz Jan 8, 2026
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ PUBLIC_USER_API_URL=https://user.keeptrack.space
# Set to R2 custom domain for production (e.g., https://assets.signalrange.space)
# This enables serving large audio files from R2 instead of bundling them
PUBLIC_ASSETS_BASE_URL=

# Developer Menu Whitelist
# Comma-separated list of Supabase user IDs that can access the dev menu
# Example: PUBLIC_DEV_USER_IDS=uuid1,uuid2,uuid3
PUBLIC_DEV_USER_IDS=

# Logging Level
# Controls minimum log level shown in console: LOG, INFO, WARN, ERROR
# Default: LOG (shows everything)
PUBLIC_LOG_LEVEL=LOG
4 changes: 3 additions & 1 deletion .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ PUBLIC_USER_API_URL=https://user.keeptrack.space
# Leave empty for local development (uses local files in public/)
# Set to R2 custom domain for production (e.g., https://assets.signalrange.space)
# This enables serving large audio files from R2 instead of bundling them
PUBLIC_ASSETS_BASE_URL=https://assets.signalrange.space
PUBLIC_ASSETS_BASE_URL=https://assets.signalrange.space

PUBLIC_LOG_LEVEL=WARN
37 changes: 36 additions & 1 deletion .github/workflows/build-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,41 @@ jobs:
echo "Line coverage: ${COVERAGE}%" >> $GITHUB_STEP_SUMMARY
fi

e2e-tests:
name: E2E Tests
needs: [lint, type-check, test]
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
submodules: recursive

- name: Setup Node Project
uses: ./.github/actions/setup-node-project

- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium

- name: Run E2E Tests
run: npm run test:e2e

- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

- name: E2E Test Summary
if: always()
run: |
echo "### E2E Test Results" >> $GITHUB_STEP_SUMMARY
if [ -d playwright-report ]; then
echo "E2E tests completed. See artifact for details." >> $GITHUB_STEP_SUMMARY
fi

security-audit:
name: Security Audit
runs-on: ubuntu-latest
Expand Down Expand Up @@ -154,7 +189,7 @@ jobs:

build:
name: Build
needs: [lint, type-check, test, security-audit]
needs: [lint, type-check, test, security-audit, e2e-tests]
runs-on: ubuntu-latest
steps:
- name: Checkout Code
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ yarn-error.log*
coverage/
.nyc_output/

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

# Temporary files
tmp/
temp/
Expand Down
46 changes: 46 additions & 0 deletions e2e/fixtures/test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test as base } from '@playwright/test';
import { CampaignSelectionPage } from '../pages/campaign-selection.page';
import { ScenarioSelectionPage } from '../pages/scenario-selection.page';
import { MissionControlPage } from '../pages/mission-control.page';

/**
* Custom fixtures for SignalRange e2e tests.
* Provides page objects as test fixtures.
*/
type SignalRangeFixtures = {
campaignSelectionPage: CampaignSelectionPage;
scenarioSelectionPage: ScenarioSelectionPage;
missionControlPage: MissionControlPage;
};

/**
* Extended test function with SignalRange fixtures.
*/
export const test = base.extend<SignalRangeFixtures>({
// Clear storage and set test mode flags before each test
page: async ({ page }, use) => {
// Set up test mode: auto-close dialogs and clear storage
await page.addInitScript(() => {
// Auto-close dialogs for faster testing
(window as any).AUTO_CLOSE_DIALOGS = true;
// Clear storage
localStorage.clear();
sessionStorage.clear();
});
await use(page);
},

campaignSelectionPage: async ({ page }, use) => {
await use(new CampaignSelectionPage(page));
},

scenarioSelectionPage: async ({ page }, use) => {
await use(new ScenarioSelectionPage(page));
},

missionControlPage: async ({ page }, use) => {
await use(new MissionControlPage(page));
},
});

export { expect } from '@playwright/test';
54 changes: 54 additions & 0 deletions e2e/pages/base.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Page, Locator } from '@playwright/test';

/**
* Abstract base class for all page objects.
* Provides common functionality for page navigation and waiting.
*/
export abstract class BasePage {
constructor(protected readonly page: Page) {}

/**
* URL pattern for this page. Can be a string or RegExp.
*/
abstract readonly url: string | RegExp;

/**
* Navigate to this page and wait for it to load.
*/
async goto(): Promise<void> {
if (typeof this.url === 'string') {
await this.page.goto(this.url);
}
await this.waitForPageLoad();
}

/**
* Wait for page-specific elements to indicate the page is ready.
* Override in subclasses to wait for page-specific conditions.
*/
protected abstract waitForPageLoad(): Promise<void>;

/**
* Get a locator for an element by test ID.
*/
protected getByTestId(testId: string): Locator {
return this.page.locator(`[data-testid="${testId}"]`);
}

/**
* Get a locator for an element by its ID attribute.
*/
protected getById(id: string): Locator {
return this.page.locator(`#${id}`);
}

/**
* Wait for navigation to complete after an action.
*/
protected async waitForNavigation(action: () => Promise<void>): Promise<void> {
await Promise.all([
this.page.waitForURL(/.*/),
action(),
]);
}
}
90 changes: 90 additions & 0 deletions e2e/pages/campaign-selection.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

/**
* Page object for the Campaign Selection page.
* This is the root page where users select a campaign to play.
*/
export class CampaignSelectionPage extends BasePage {
readonly url = '/';

// Main elements
readonly pageContainer: Locator;
readonly pageTitle: Locator;
readonly subtitle: Locator;
readonly campaignGrid: Locator;
readonly loginWarning: Locator;

// Campaign cards
readonly campaignCards: Locator;
readonly sandboxCard: Locator;

constructor(page: Page) {
super(page);
this.pageContainer = page.locator('#campaign-selection-page');
this.pageTitle = page.locator('.campaign-selection-header h1');
this.subtitle = page.locator('.campaign-selection-header .subtitle');
this.campaignGrid = page.locator('.campaign-grid');
this.loginWarning = page.locator('.login-warning');
this.campaignCards = page.locator('.campaign-card');
this.sandboxCard = page.locator('.sandbox-card');
}

protected async waitForPageLoad(): Promise<void> {
await expect(this.pageContainer).toBeVisible();
await expect(this.pageTitle).toBeVisible();
await expect(this.campaignGrid).toBeVisible();
}

/**
* Get a specific campaign card by its ID.
*/
getCampaignCard(campaignId: string): Locator {
return this.page.locator(`[data-campaign-id="${campaignId}"]`);
}

/**
* Select a campaign by clicking on its card.
* Waits for navigation to the scenario selection page.
*/
async selectCampaign(campaignId: string): Promise<void> {
const card = this.getCampaignCard(campaignId);
await expect(card).toBeVisible();
await expect(card).not.toHaveClass(/disabled/);
await card.click();
await this.page.waitForURL(`/campaigns/${campaignId}`);
}

/**
* Get the count of available (non-disabled) campaigns.
*/
async getAvailableCampaignCount(): Promise<number> {
return this.campaignCards.filter({ hasNot: this.page.locator('.disabled') }).count();
}

/**
* Check if a campaign is locked.
*/
async isCampaignLocked(campaignId: string): Promise<boolean> {
const card = this.getCampaignCard(campaignId);
const lockedBanner = card.locator('.locked-banner');
return lockedBanner.isVisible();
}

/**
* Check if a campaign is completed.
*/
async isCampaignCompleted(campaignId: string): Promise<boolean> {
const card = this.getCampaignCard(campaignId);
const completedBanner = card.locator('.completed-banner');
return completedBanner.isVisible();
}

/**
* Check if the login warning is visible.
*/
async isLoginWarningVisible(): Promise<boolean> {
const display = await this.loginWarning.evaluate(el => getComputedStyle(el).display);
return display !== 'none';
}
}
Loading
Loading