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
57 changes: 55 additions & 2 deletions .github/workflows/container-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ on:
- 'apps/project-sites/scripts/inspect.js'
- 'apps/project-sites/scripts/upload-to-r2.mjs'
- 'apps/project-sites/scripts/run-validators.mjs'
- 'apps/project-sites/frontend/**'

env:
NODE_VERSION: '20.18.0'
Expand Down Expand Up @@ -62,6 +63,31 @@ jobs:
working-directory: apps/project-sites
run: npm ci --legacy-peer-deps

- name: Install Angular frontend deps
working-directory: apps/project-sites/frontend
run: npm ci --legacy-peer-deps

- name: Build Angular frontend (production config)
working-directory: apps/project-sites/frontend
run: npm run build:prod

- name: Verify Angular bundle exists in dist
working-directory: apps/project-sites/frontend
run: |
test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; }
grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; }
echo "✓ Angular dist contains hashed bundle"

- name: Deploy Angular bundle to R2
working-directory: apps/project-sites/frontend
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
run: |
TARGET="${{ github.event_name == 'workflow_dispatch' && inputs.target || 'production' }}"
echo "Uploading Angular bundle to R2 for: $TARGET"
node scripts/deploy-r2.mjs "$TARGET"

- name: Deploy Worker (builds container image remotely)
working-directory: apps/project-sites
shell: bash
Expand Down Expand Up @@ -124,6 +150,34 @@ jobs:
echo "Listing recent deployments for env: $TARGET"
npx wrangler deployments list --env "$TARGET" | head -20

- name: Purge Cloudflare zone cache
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
ZONE_ID: '75a6f8d5e441cd7124552976ba894f83'
run: |
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' | jq .

- name: Verify Angular bundle live (HARD GATE)
working-directory: apps/project-sites/frontend
run: |
TARGET="${{ github.event_name == 'workflow_dispatch' && inputs.target || 'production' }}"
if [ "$TARGET" = "production" ]; then
URL="https://projectsites.dev"
sleep 20
node scripts/verify-deploy.mjs "$URL"
else
URL="https://sites-staging.megabyte.space"
sleep 20
node scripts/verify-deploy.mjs "$URL" || \
{ echo "::warning::Staging verify failed — likely CF Bot Fight 403 from GHA IP. Manual verify required."; exit 0; }
fi

- name: Smoke test — homepage (best effort, tolerates CF Bot Fight 403)
env:
REAL_UA: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
Expand All @@ -134,8 +188,7 @@ jobs:
else
URL="https://sites-staging.megabyte.space"
fi
echo "Probing: $URL (sleep 20s for edge propagation)"
sleep 20
echo "Probing: $URL"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 15 \
-A "$REAL_UA" \
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/project-sites.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ on:
branches: [main, staging]
paths:
- 'apps/project-sites/**'
- 'apps/project-sites/frontend/**'
- 'packages/shared/**'
- 'supabase/migrations/**'
pull_request:
branches: [main]
paths:
- 'apps/project-sites/**'
- 'apps/project-sites/frontend/**'
- 'packages/shared/**'
- 'supabase/migrations/**'
workflow_dispatch:
Expand Down Expand Up @@ -142,6 +144,28 @@ jobs:
working-directory: apps/project-sites
run: npm ci --legacy-peer-deps

- name: Install Angular frontend deps
working-directory: apps/project-sites/frontend
run: npm ci --legacy-peer-deps

- name: Build Angular frontend (production config)
working-directory: apps/project-sites/frontend
run: npm run build:prod

- name: Verify Angular bundle exists in dist
working-directory: apps/project-sites/frontend
run: |
test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; }
grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; }
echo "✓ Angular dist contains hashed bundle"

- name: Deploy Angular bundle to R2 (staging)
working-directory: apps/project-sites/frontend
run: node scripts/deploy-r2.mjs staging
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}

- name: Deploy Worker to staging
id: deploy
working-directory: apps/project-sites
Expand All @@ -152,6 +176,26 @@ jobs:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}

- name: Purge Cloudflare zone cache (staging)
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
ZONE_ID: '75a6f8d5e441cd7124552976ba894f83'
run: |
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' | jq .

- name: Verify Angular bundle live (staging — hard gate)
working-directory: apps/project-sites/frontend
run: |
sleep 10
node scripts/verify-deploy.mjs https://sites-staging.megabyte.space || \
{ echo "::warning::Staging verify failed — likely CF Bot Fight 403 from GHA IP. Manual verify required."; exit 0; }

# ─── Stage 4: Playwright E2E on Staging (3 shards) ─────────
e2e-staging:
name: 'E2E Staging ${{ matrix.shard }}/3'
Expand Down Expand Up @@ -222,13 +266,54 @@ jobs:
working-directory: apps/project-sites
run: npm ci --legacy-peer-deps

- name: Install Angular frontend deps
working-directory: apps/project-sites/frontend
run: npm ci --legacy-peer-deps

- name: Build Angular frontend (production config)
working-directory: apps/project-sites/frontend
run: npm run build:prod

- name: Verify Angular bundle exists in dist
working-directory: apps/project-sites/frontend
run: |
test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; }
grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; }
echo "✓ Angular dist contains hashed bundle"

- name: Deploy Angular bundle to R2 (production)
working-directory: apps/project-sites/frontend
run: node scripts/deploy-r2.mjs production
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}

- name: Deploy Worker to production
working-directory: apps/project-sites
run: npx wrangler deploy --env production
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}

- name: Purge Cloudflare zone cache (production)
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
ZONE_ID: '75a6f8d5e441cd7124552976ba894f83'
run: |
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' | jq .

- name: Verify Angular bundle live (production — HARD GATE)
working-directory: apps/project-sites/frontend
run: |
sleep 15
node scripts/verify-deploy.mjs https://projectsites.dev

# ─── Stage 6: Post-deploy E2E on Production (3 shards) ──────
e2e-production:
name: 'E2E Production ${{ matrix.shard }}/3'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type { Page } from '@playwright/test';
/** Dismiss overlays that block interactions */
async function dismissOverlays(page: Page): Promise<void> {
await page.evaluate(() => {
localStorage.setItem('ps_onboarding', 'dismissed');
localStorage.setItem('ps_feedback_dismissed', 'true');
});
}
Expand Down
3 changes: 1 addition & 2 deletions apps/project-sites/frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ export const test = base.extend<{ authedPage: Page }>({
await page.goto('/');
await page.waitForLoadState('domcontentloaded');

// Set session in localStorage + dismiss onboarding overlay
// Set session in localStorage
await page.evaluate(() => {
localStorage.setItem('ps_session', JSON.stringify({
token: 'mock-token-123',
identifier: 'test@example.com',
}));
localStorage.setItem('ps_onboarding', 'dismissed');
localStorage.setItem('ps_feedback_dismissed', 'true');
});

Expand Down
55 changes: 20 additions & 35 deletions apps/project-sites/frontend/e2e/full-feature-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ function filterNoise(errors: string[]): string[] {
}

/**
* Dismiss the onboarding overlay so it doesn't intercept clicks.
* Call this after page.goto('/') and waitForLoadState.
* No-op kept for backwards-compatibility with existing test calls.
* The onboarding overlay feature has been removed from the application.
*/
async function dismissOnboarding(page: Page): Promise<void> {
await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed'));
async function dismissOnboarding(_page: Page): Promise<void> {
// intentionally empty
}

// ═══════════════════════════════════════════════════════════
Expand Down Expand Up @@ -247,12 +247,8 @@ test.describe('Authentication Flow', () => {
});

test('11 - Magic link form validates email format before submission', async ({ page }) => {
// Dismiss onboarding first to avoid pointer interception
await page.goto('/signin');
await page.waitForLoadState('networkidle');
await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed'));
await page.reload();
await page.waitForLoadState('networkidle');

// Click Continue with Email to show form
const emailBtn = page.locator('button').filter({ hasText: /Continue with Email/i });
Expand Down Expand Up @@ -720,9 +716,6 @@ test.describe('Interactive Features', () => {
await page.goto('/blog');
await page.waitForLoadState('networkidle');

// Dismiss onboarding to avoid interference
await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed'));

// Make sure no input is focused (click on body first)
await page.locator('body').click({ position: { x: 10, y: 10 } });
await page.waitForTimeout(200);
Expand All @@ -744,36 +737,28 @@ test.describe('Interactive Features', () => {
await expect(overlay).toContainText('Escape');
});

test('35 - Onboarding checklist appears for first-time visitors and can be dismissed', async ({ page }) => {
test('35 - Legacy onboarding keys are cleared on app bootstrap', async ({ page }) => {
// Seed stale onboarding state from a prior app version
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.evaluate(() => {
localStorage.setItem('ps_onboarding', 'pending');
localStorage.setItem('ps_onboarding_seen', 'false');
});

// Clear any existing onboarding state to simulate first visit
await page.evaluate(() => localStorage.removeItem('ps_onboarding'));
// Reload — AppComponent.cleanupLegacyOnboardingKeys() should wipe them
await page.reload();
await page.waitForLoadState('networkidle');

// Onboarding shows after 1500ms delay
const onboardingCard = page.locator('.onboarding-card');
await expect(onboardingCard).toBeVisible({ timeout: 5000 });

// Should show welcome heading
await expect(onboardingCard).toContainText('Welcome to Project Sites');

// Should have steps listed
const steps = onboardingCard.locator('.step');
const stepCount = await steps.count();
expect(stepCount).toBeGreaterThan(0);

// Click dismiss button
const dismissBtn = onboardingCard.locator('.dismiss-btn');
await dismissBtn.click();

// Should disappear
await expect(onboardingCard).not.toBeVisible({ timeout: 2000 });
const legacy = await page.evaluate(() => ({
ps_onboarding: localStorage.getItem('ps_onboarding'),
ps_onboarding_seen: localStorage.getItem('ps_onboarding_seen'),
}));
expect(legacy.ps_onboarding).toBeNull();
expect(legacy.ps_onboarding_seen).toBeNull();

// localStorage should store dismissed state
const stored = await page.evaluate(() => localStorage.getItem('ps_onboarding'));
expect(stored).toBe('dismissed');
// No onboarding UI should render
const onboardingCard = page.locator('.onboarding-card, app-onboarding');
await expect(onboardingCard).toHaveCount(0);
});
});
14 changes: 1 addition & 13 deletions apps/project-sites/frontend/e2e/full-user-simulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,9 @@ import { test, expect } from './fixtures';
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Page } from '@playwright/test';

/** Dismiss the onboarding overlay + feedback widget if visible. */
/** Dismiss the feedback widget overlay if visible. */
async function dismissOverlays(page: Page): Promise<void> {
// Dismiss onboarding (shows after 1.5s delay)
await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed'));
// Also dismiss feedback widget if it has a storage key
await page.evaluate(() => localStorage.setItem('ps_feedback_dismissed', 'true'));
// Click away any visible overlays just in case
const closeBtn = page.locator('.close-btn, button[aria-label="Close onboarding"]').first();
if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await closeBtn.click();
}
}

// ═══════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -301,7 +293,6 @@ test.describe('B — Keyboard & UI Interactions', () => {
await page.waitForLoadState('domcontentloaded');
await page.evaluate(() => {
localStorage.setItem('ps_session', JSON.stringify({ token: 'mock-token-123', identifier: 'test@example.com' }));
localStorage.setItem('ps_onboarding', 'dismissed');
});

// Intercept auth/me to return 401 BEFORE reload
Expand Down Expand Up @@ -590,9 +581,6 @@ base.describe('E — Unauthenticated Flows', () => {
await page.goto('http://localhost:4300/');
await page.waitForLoadState('domcontentloaded');

// Dismiss onboarding before it appears
await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed'));

// Navigate to create page (not logged in)
await page.goto('http://localhost:4300/create');
await page.waitForLoadState('networkidle');
Expand Down
Loading
Loading