לדלג לתוכן

10.9 Lighthouse פתרון

פתרון - לייטהאוס ואסטרטגיית בדיקות - Lighthouse and Testing Strategy

פתרון תרגיל 1

// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/products",
        "http://localhost:3000/about"
      ],
      "numberOfRuns": 5,
      "startServerCommand": "npm run start",
      "startServerReadyPattern": "ready on"
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:seo": ["error", { "minScore": 0.9 }],
        "categories:best-practices": ["error", { "minScore": 0.9 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}
# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

      - name: Upload Lighthouse report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: lighthouse-report
          path: .lighthouseci/
          retention-days: 14
// package.json
{
  "scripts": {
    "lighthouse": "lhci autorun",
    "lighthouse:local": "lighthouse http://localhost:3000 --output html --output-path ./reports/lighthouse.html --view"
  }
}

פתרון תרגיל 2

תוכנית תיקון לפי סדר עדיפויות:

עדיפות 1 - נגישות ו-SEO בסיסי (שעה):

<!-- תיקון lang attribute -->
<html lang="he" dir="rtl">

<!-- תיקון viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<!-- תיקון meta description -->
<meta name="description" content="תיאור רלוונטי של הדף בעד 155 תווים" />

<!-- תיקון alt text -->
<img src="hero.jpg" alt="תיאור מדויק של התמונה" />

<!-- תיקון labels -->
<label htmlFor="email">אימייל</label>
<input id="email" type="email" />

<!-- תיקון anchor text -->
<!-- רע: <a href="/products">לחצו כאן</a> -->
<a href="/products">צפו בכל המוצרים שלנו</a>

עדיפות 2 - ניגודיות צבעים (30 דקות):

/* רע - ניגודיות 2.5:1 */
.text-light {
  color: #aaa; /* על רקע לבן */
}

/* טוב - ניגודיות 4.5:1+ */
.text-light {
  color: #595959; /* על רקע לבן */
}

/* כפתור - ניגודיות מספקת */
.button {
  background-color: #2563eb;
  color: #ffffff; /* ניגודיות 7.4:1 */
}

עדיפות 3 - ביצועים - תמונות (שעה):

// תמונת hero: 3MB JPEG -> next/image עם WebP
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="תמונת גיבור"
  width={1920}
  height={1080}
  priority
  sizes="100vw"
  quality={80}
/>
// next/image ימיר ל-WebP ויצמצם ל-~200KB

עדיפות 4 - ביצועים - JavaScript (2 שעות):

// code splitting - טעינה דינמית של מודולים כבדים
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <div style={{ height: '400px' }}>טוען...</div>,
});

// החלפת ספריות כבדות
// moment.js -> date-fns
// lodash -> native methods
// כל ספרייה שלא בשימוש -> הסרה

עדיפות 5 - CLS (שעה):

// תמונות עם dimensions
<Image src="/photo.jpg" alt="..." width={600} height={400} />

// פונטים ללא FOUT
import { Rubik } from 'next/font/google';
const rubik = Rubik({ subsets: ['hebrew'], display: 'swap' });

פתרון תרגיל 3

בדיקות יחידה (10+):

  1. validateTask - בדיקת validation של שם משימה
  2. filterTasks - סינון לפי סטטוס
  3. sortTasks - מיון לפי תאריך/שם
  4. calculateStats - חישוב סטטיסטיקות
  5. formatDueDate - עיצוב תאריך יעד
  6. isOverdue - בדיקה אם משימה באיחור
  7. parseNotification - ניתוח הודעת תזכורת
  8. mergeTaskLists - מיזוג רשימות משימות
  9. searchTasks - חיפוש לפי טקסט
  10. getTaskPriority - חישוב עדיפות
  11. useAuth hook - ניהול אימות
  12. useTaskFilters hook - ניהול פילטרים

בדיקות אינטגרציה (6+):

  1. TaskItem - רינדור, toggle, מחיקה, עריכה
  2. TaskForm - יצירת משימה חדשה עם validation
  3. TaskList - הצגת רשימה, סינון, מיון
  4. LoginForm - התחברות עם שגיאות
  5. ShareDialog - שיתוף משימה עם משתמש
  6. StatsPanel - הצגת סטטיסטיקות

בדיקות E2E (4+):

  1. תהליך הרשמה והתחברות מלא
  2. יצירת משימה, סימון כהושלמה, מחיקה
  3. שיתוף משימה עם משתמש אחר
  4. סינון וחיפוש משימות

הגדרות coverage:

// vite.config.ts
test: {
  coverage: {
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
}

CI Pipeline:

lint + typecheck (30s)
     |
unit tests + coverage (1-2min)  ← נכשל אם coverage מתחת ל-80%
     |
build (1-2min)
     |
 +---+---+
 |       |
E2E    Lighthouse
(3-5min) (2-3min)

פתרון תרגיל 4

// utils/date.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatDate, isOverdue, daysUntil, getRelativeTime } from './date';

describe('formatDate', () => {
  it('should format date in Hebrew', () => {
    const date = new Date('2026-03-08');
    const formatted = formatDate(date);
    expect(formatted).toContain('2026');
    expect(formatted).toContain('8');
  });

  it('should handle different dates', () => {
    const date = new Date('2025-12-25');
    const formatted = formatDate(date);
    expect(formatted).toContain('2025');
  });
});

describe('isOverdue', () => {
  it('should return true for past date', () => {
    const pastDate = new Date('2020-01-01');
    expect(isOverdue(pastDate)).toBe(true);
  });

  it('should return false for future date', () => {
    const futureDate = new Date('2030-01-01');
    expect(isOverdue(futureDate)).toBe(false);
  });
});

describe('daysUntil', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-08'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should return positive for future date', () => {
    const future = new Date('2026-03-18');
    expect(daysUntil(future)).toBe(10);
  });

  it('should return negative for past date', () => {
    const past = new Date('2026-03-03');
    expect(daysUntil(past)).toBeLessThan(0);
  });

  it('should return 0 or 1 for today', () => {
    const today = new Date('2026-03-08');
    expect(daysUntil(today)).toBeLessThanOrEqual(1);
  });
});

describe('getRelativeTime', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-08T12:00:00'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should return "הרגע" for less than a minute', () => {
    const date = new Date('2026-03-08T11:59:30');
    expect(getRelativeTime(date)).toBe('הרגע');
  });

  it('should return minutes for less than an hour', () => {
    const date = new Date('2026-03-08T11:45:00');
    expect(getRelativeTime(date)).toBe('לפני 15 דקות');
  });

  it('should return hours for less than a day', () => {
    const date = new Date('2026-03-08T09:00:00');
    expect(getRelativeTime(date)).toBe('לפני 3 שעות');
  });

  it('should return days for less than a month', () => {
    const date = new Date('2026-03-03T12:00:00');
    expect(getRelativeTime(date)).toBe('לפני 5 ימים');
  });

  it('should return formatted date for more than a month', () => {
    const date = new Date('2026-01-01T12:00:00');
    const result = getRelativeTime(date);
    expect(result).toContain('2026');
  });
});
// components/TaskItem.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { TaskItem } from './TaskItem';

const defaultTask = {
  id: '1',
  title: 'משימה לדוגמה',
  completed: false,
  dueDate: null,
};

const defaultProps = {
  task: defaultTask,
  onToggle: vi.fn(),
  onDelete: vi.fn(),
  onEdit: vi.fn(),
};

describe('TaskItem', () => {
  it('should render task title', () => {
    render(<TaskItem {...defaultProps} />);
    expect(screen.getByText('משימה לדוגמה')).toBeInTheDocument();
  });

  it('should render unchecked checkbox for incomplete task', () => {
    render(<TaskItem {...defaultProps} />);
    expect(screen.getByRole('checkbox')).not.toBeChecked();
  });

  it('should render checked checkbox for completed task', () => {
    render(<TaskItem {...defaultProps} task={{ ...defaultTask, completed: true }} />);
    expect(screen.getByRole('checkbox')).toBeChecked();
  });

  it('should call onToggle when checkbox clicked', async () => {
    const user = userEvent.setup();
    const onToggle = vi.fn();
    render(<TaskItem {...defaultProps} onToggle={onToggle} />);

    await user.click(screen.getByRole('checkbox'));
    expect(onToggle).toHaveBeenCalledWith('1');
  });

  it('should call onDelete when delete button clicked', async () => {
    const user = userEvent.setup();
    const onDelete = vi.fn();
    render(<TaskItem {...defaultProps} onDelete={onDelete} />);

    await user.click(screen.getByRole('button', { name: /מחק/ }));
    expect(onDelete).toHaveBeenCalledWith('1');
  });

  it('should enter edit mode on double click', async () => {
    const user = userEvent.setup();
    render(<TaskItem {...defaultProps} />);

    await user.dblClick(screen.getByText('משימה לדוגמה'));
    expect(screen.getByRole('textbox')).toHaveValue('משימה לדוגמה');
  });

  it('should save on Enter in edit mode', async () => {
    const user = userEvent.setup();
    const onEdit = vi.fn();
    render(<TaskItem {...defaultProps} onEdit={onEdit} />);

    await user.dblClick(screen.getByText('משימה לדוגמה'));
    await user.clear(screen.getByRole('textbox'));
    await user.type(screen.getByRole('textbox'), 'כותרת חדשה{Enter}');

    expect(onEdit).toHaveBeenCalledWith('1', 'כותרת חדשה');
  });

  it('should show overdue style for overdue task', () => {
    const overdueTask = {
      ...defaultTask,
      dueDate: new Date('2020-01-01'),
    };
    const { container } = render(<TaskItem {...defaultProps} task={overdueTask} />);
    expect(container.querySelector('.overdue')).toBeInTheDocument();
  });
});

פתרון תרגיל 5

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx vitest run --coverage
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7

  build:
    runs-on: ubuntu-latest
    needs: [lint, unit-tests]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - name: Upload build
        uses: actions/upload-artifact@v4
        with:
          name: build
          path: .next/
          retention-days: 1

  e2e:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    needs: [build]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install chromium --with-deps
      - name: Download build
        uses: actions/download-artifact@v4
        with:
          name: build
          path: .next/
      - run: npx playwright test --project=chromium
      - name: Upload Playwright report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

  lighthouse:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    needs: [build]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Download build
        uses: actions/download-artifact@v4
        with:
          name: build
          path: .next/
      - run: npm install -g @lhci/cli
      - run: lhci autorun
      - name: Upload Lighthouse report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: lighthouse-report
          path: .lighthouseci/
          retention-days: 14

פתרון תרגיל 6

// package.json
{
  "scripts": {
    "test:report": "node scripts/generateReport.mjs"
  }
}
// scripts/generateReport.mjs
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';

function runCommand(cmd) {
  try {
    return { output: execSync(cmd, { encoding: 'utf-8' }), success: true };
  } catch (error) {
    return { output: error.stdout || error.message, success: false };
  }
}

function generateReport() {
  const timestamp = new Date().toLocaleString('he-IL');
  console.log('מריץ בדיקות יחידה...');
  const unitResult = runCommand('npx vitest run --reporter=json --coverage 2>&1');

  console.log('מריץ בדיקות E2E...');
  const e2eResult = runCommand('npx playwright test --reporter=json 2>&1');

  let unitData = { passed: 0, failed: 0, skipped: 0 };
  try {
    const parsed = JSON.parse(unitResult.output);
    unitData = {
      passed: parsed.numPassedTests || 0,
      failed: parsed.numFailedTests || 0,
      skipped: parsed.numPendingTests || 0,
    };
  } catch {}

  const html = `
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
  <meta charset="UTF-8" />
  <title>דוח בדיקות</title>
  <style>
    body { font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
    h1 { color: #1f2937; }
    .card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 16px 0; }
    .pass { color: #16a34a; }
    .fail { color: #dc2626; }
    .metric { display: inline-block; margin: 0 20px; text-align: center; }
    .metric .value { font-size: 32px; font-weight: bold; }
    .metric .label { font-size: 14px; color: #6b7280; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 8px 12px; border: 1px solid #e5e7eb; text-align: right; }
    th { background: #f9fafb; }
  </style>
</head>
<body>
  <h1>דוח בדיקות</h1>
  <p>תאריך: ${timestamp}</p>

  <div class="card">
    <h2>בדיקות יחידה</h2>
    <div>
      <div class="metric">
        <div class="value pass">${unitData.passed}</div>
        <div class="label">עברו</div>
      </div>
      <div class="metric">
        <div class="value fail">${unitData.failed}</div>
        <div class="label">נכשלו</div>
      </div>
      <div class="metric">
        <div class="value">${unitData.skipped}</div>
        <div class="label">דולגו</div>
      </div>
    </div>
    <p class="${unitResult.success ? 'pass' : 'fail'}">
      סטטוס: ${unitResult.success ? 'עברו בהצלחה' : 'נכשלו'}
    </p>
  </div>

  <div class="card">
    <h2>בדיקות E2E</h2>
    <p class="${e2eResult.success ? 'pass' : 'fail'}">
      סטטוס: ${e2eResult.success ? 'עברו בהצלחה' : 'נכשלו'}
    </p>
  </div>

  <footer>
    <p>נוצר אוטומטית בתאריך ${timestamp}</p>
  </footer>
</body>
</html>
  `;

  writeFileSync('reports/test-report.html', html);
  console.log('דוח נשמר ב-reports/test-report.html');
}

generateReport();

תשובות לשאלות

1. Lab Data נמדד בסביבה מבוקרת (מכשיר סימולציה, רשת מוגדרת) ומספק תוצאות עקביות אבל לא מייצגות את כל המשתמשים. Field Data נמדד ממשתמשים אמיתיים (CrUX) ומשקף ביצועים אמיתיים עם מגוון מכשירים ורשתות. הציונים עשויים להיות שונים כי Lab לא כולל שונות של מכשירים ותנאי רשת.

2. 100% coverage אינו יעד מומלץ כי: (1) הוא מוביל לכתיבת בדיקות "למען ה-coverage" ולא למען ערך. (2) קוד פשוט שלא שווה לבדוק (getters, types) מנפח את הבדיקות. (3) coverage לא מבטיח שהבדיקות בודקות את הדבר הנכון. יעד 80% statements ו-75% branches טוב - ומתמקדים בכיסוי של לוגיקה קריטית.

3. E2E: תהליכים שחוצים כמה דפים/שירותים ומייצגים flow של משתמש אמיתי (התחברות, רכישה). יחידה: פונקציות ולוגיקה בבידוד - validation, חישובים, helpers. כלל אצבע: אם ניתן לבדוק בבדיקת יחידה - תעדיפו אותה (מהירה יותר ואמינה יותר).

4. הרצת Lighthouse ב-CI מבטיחה שכל שינוי קוד נבדק אוטומטית - אם ביצועים או נגישות ירדו, ה-build נכשל. בדיקה ידנית תלויה בזיכרון של המפתח, לא עקבית, ולא מכסה כל PR. בנוסף, CI מספק היסטוריה ומגמות לאורך זמן.

5. מצב שבו בדיקות עוברות אבל האפליקציה שבורה יכול לקרות כש: (1) הבדיקות בודקות מימוש ולא התנהגות. (2) אין מספיק בדיקות אינטגרציה/E2E. (3) ה-mocks לא מייצגים את המציאות. (4) אין בדיקות למקרי קצה. מניעה: שילוב של כל סוגי הבדיקות, עדכון mocks כשה-API משתנה, בדיקות smoke ב-staging.