diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 13efc33e..81f770f2 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -84,12 +84,19 @@ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/ RUN npm install -g \ corepack \ @antfu/ni \ + playwright \ @anthropic-ai/claude-code -# Copy and set up firewall script -COPY init-firewall.sh /usr/local/bin/ +# install playwright browsers USER root -RUN chmod +x /usr/local/bin/init-firewall.sh && \ - echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \ - chmod 0440 /etc/sudoers.d/node-firewall +RUN npm exec playwright install-deps +USER node +RUN npm exec playwright install + +# Copy and set up init scripts +COPY node-init.sh init-firewall.sh install-playwright.sh /usr/local/bin/ +USER root +RUN chmod +x /usr/local/bin/node-init.sh && \ + echo "node ALL=(root) NOPASSWD: /usr/local/bin/node-init.sh" > /etc/sudoers.d/node-init && \ + chmod 0440 /etc/sudoers.d/node-init USER node diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 835c103d..e8698194 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "build": { "dockerfile": "Dockerfile", "args": { - "TZ": "${localEnv:TZ:America/Los_Angeles}" + "TZ": "${localEnv:TZ:Europe/Zurich}" } }, "runArgs": [ @@ -57,5 +57,5 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", "workspaceFolder": "/workspace", - "postCreateCommand": "sudo /usr/local/bin/init-firewall.sh" + "postCreateCommand": "sudo /usr/local/bin/node-init.sh" } diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index 93ef9b60..266ce9c3 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -53,6 +53,7 @@ done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q) # Resolve and add other allowed domains for domain in \ "registry.npmjs.org" \ + "chatgpt.com" \ "api.anthropic.com" \ "sentry.io" \ "statsig.anthropic.com" \ diff --git a/.devcontainer/install-playwright.sh b/.devcontainer/install-playwright.sh new file mode 100644 index 00000000..b067db91 --- /dev/null +++ b/.devcontainer/install-playwright.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -uo pipefail + +echo "Installing Playwright browsers" + +npm i -g corepack && pnpm -v && \ +cd /workspace/packages/ && \ +pnpm install && \ +cd /workspace/packages/e2e-tests/ && \ +pnpm exec playwright install-deps && \ +sudo -u node pnpm exec playwright install && \ +echo "OK. Playwright browsers installed successfully." diff --git a/.devcontainer/node-init.sh b/.devcontainer/node-init.sh new file mode 100644 index 00000000..94b0e718 --- /dev/null +++ b/.devcontainer/node-init.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +bash /usr/local/bin/install-playwright.sh + +bash /usr/local/bin/init-firewall.sh diff --git a/.gitignore b/.gitignore index 41f6f3e3..7b7acd90 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ typings/ /tmp/ /.pnpm-store/ .DS_Store +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2f318b6d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,252 @@ +# AI Agent Guidelines + +_Vendor-neutral instructions for AI coding assistants (GitHub Copilot, Claude, Cursor, etc.)_ + +## Key Rules + +1. **Follow existing patterns** - Look at neighboring code before implementing +2. **Test everything** - Every feature needs unit tests AND E2E tests +3. **Document decisions** - Explain WHY, not just WHAT +4. **Keep it simple** - This is a demo project, avoid over-engineering +5. **Security first** - Even demos should follow security best practices + +## Coding Standards + +### TypeScript Style + +- Use 2-space indentation for all files +- Use explicit return types for functions +- Use type imports with the `type` keyword +- Use `type` for object shapes and primitives +- NEVER use enums - use simple string unions instead +- Prefer const assertions for literal types +- Prefer `type` over `interface` for object types + +### Naming Conventions + +- **Variables, parameters, functions**: camelCase +- **Classes, interfaces, types**: PascalCase +- **Constants**: UPPER_CASE +- **Files**: kebab-case for utilities, PascalCase for components/types +- **Folders**: kebab-case + +### File Organization + +- One export per file when possible +- Group related functionality in directories +- Use index.ts files to re-export from directories +- Keep files focused and under 300 lines when possible +- Routes go in `/src/routes/` with subdirectories for organization +- Types go in `/src/types/` as separate files +- Unit tests are colocated as `*.test.ts` files +- E2E tests go in `/packages/e2e-tests/` +- Feature docs go in `/docs/features/` + +## Commit Messages + +``` +feat: add calendar subscription endpoint + +Implements RFC 5545 compliant iCalendar feed to test +client compatibility across different calendar apps. + +Fixes #123 +``` + +- First line: `: ` (lowercase, ≤72 chars) +- Types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore` +- Body: Explain WHY the change was made +- Reference issues with `Fixes #123` or `Relates to #123` +- Create feature branches as `feat/feature-name` +- If GPG issues: `git commit --no-gpg-sign -m "message"` + +## Documentation Standards + +1. Every module should include JSDoc comments for all exported functions, classes, and interfaces +2. Include parameter descriptions, return type descriptions, and examples +3. Document thrown exceptions +4. Update relevant documentation when changing functionality + +## Testing Guidelines + +### Unit Tests + +- Test files should be colocated with source files as `*.test.ts` +- ALWAYS use Node.js built-in test runner (not Jest, Vitest, etc.) +- Focus on testing business logic and edge cases +- Use descriptive test names that explain the expected behavior + +### E2E Tests + +- Located in `/packages/e2e-tests/` +- Use Cypress for browser automation (or Playwright for BDD approach) +- Test user flows and integration between components +- Include visual regression tests where appropriate + +#### E2E Framework Migration Guidelines + +When migrating between test frameworks: +1. **Check Node.js version requirements** - Some features require specific versions +2. **Verify package versions exist** before adding to package.json +3. **Read all files before editing** - Even if you know the content +4. **Update CI/CD workflows** - Located in `.github/workflows/` +5. **Test locally first** when possible + +#### BDD Testing Pattern + +If using Playwright with BDD: +- Write features in Gherkin syntax in `/features/` directory +- Implement step definitions in `/steps/` directory +- Use Page Object Model in `/pages/` directory +- Use `createBdd()` pattern for importing Given/When/Then +- Generate test files with `pnpm run generate` before running tests + +## Error Handling + +1. Use descriptive error messages +2. Include context in error messages +3. Prefer explicit error handling over try-catch for expected errors +4. Log errors appropriately without exposing sensitive information + +## Code Review Checklist + +When reviewing or writing code, ensure: + +- [ ] Code follows the established patterns in the codebase +- [ ] New features include appropriate tests +- [ ] Documentation is updated where necessary +- [ ] No console.log statements in production code +- [ ] Error cases are handled appropriately +- [ ] Security considerations have been addressed + +## Dependencies + +**ALWAYS prefer built-in Node.js modules over external packages** + +When adding dependencies: +1. Justify why it's needed in the PR +2. Pin exact version (no ^ or ~) +3. Check bundle size impact +4. Verify license compatibility + +Example justification: +``` +Adding ical-generator@9.0.0: +- Needed for RFC 5545 compliant calendar generation +- No suitable Node.js built-in alternative +- MIT licensed, 45KB minified +``` + +## Demo Development Process + +### When Adding New Demo Features +1. ALWAYS create feature documentation first in `/docs/features/` +2. ALWAYS implement types before implementation in `/src/types/` +3. ALWAYS add both unit tests and E2E tests +4. ALWAYS link new demos from the homepage +5. ALWAYS test with multiple browsers/clients +6. ALWAYS document vendor-specific workarounds + +### Demo Requirements Checklist +- [ ] Single concept focus (one demo = one feature) +- [ ] Live interactive demo at `/demos/` +- [ ] API endpoint documented with curl examples +- [ ] Client compatibility table in docs +- [ ] Links to official specs (RFC, W3C, etc.) +- [ ] E2E tests covering happy path + edge cases +- [ ] Vendor workarounds documented with examples + +## Security Considerations + +- Never expose sensitive information in demos +- Use placeholder data for examples +- Validate all user inputs +- Follow OWASP guidelines for web security +- Document any intentional security simplifications for demo purposes + +## Workflow Efficiency Guidelines + +### Before Starting Major Changes +1. **Check environment compatibility** (Node.js version, etc.) +2. **Verify all dependencies exist** with correct versions +3. **Locate configuration files** using absolute paths +4. **Read files before editing** - always use Read tool first + +### Common File Locations +- CI/CD workflows: `/workspace/.github/workflows/` +- E2E tests: `/workspace/packages/e2e-tests/` +- Documentation: `/workspace/docs/` +- Types: `/workspace/packages/app/src/types/` + +### Tool Usage Best Practices +1. **Batch related operations** - Use multiple tool calls in parallel when possible +2. **Use Glob for finding files** - More efficient than multiple LS commands +3. **Check command output** - Don't assume operations succeeded +4. **Handle errors gracefully** - Have fallback plans for common issues + +### File System Operations + +#### Efficient Patterns +1. **Bulk File Discovery** + ```bash + # Good - Single glob for multiple patterns + Glob("**/*.{test,spec}.{js,ts}") + + # Avoid - Multiple sequential globs + Glob("**/*.test.js") + Glob("**/*.test.ts") + ``` + +2. **Smart File Reading** + - Read configuration files early to understand project structure + - Use `head_limit` parameter in `Grep` for large codebases + - Read multiple related files in parallel when analyzing code patterns + +3. **Batch Editing** + - Group related changes together + - Use `replace_all` parameter when renaming variables/functions + - Plan multi-file refactoring before starting + +### Test Framework Migration Pattern + +1. **Initial Assessment Phase** + - Use `Glob` to find all test files at once + - Read package.json files to understand current dependencies + - Check CI workflows early to understand test execution context + +2. **Common Mistakes to Avoid** + - Creating files one by one instead of batch operations + - Incomplete initial implementation requiring multiple edits + - Missing configuration updates (tsconfig.json, CI workflows) + - Installing dependencies one by one instead of batch updates + +3. **Systematic Approach** + - Map old test structure to new framework + - Create all page objects first before migrating tests + - Update all related configuration files together + - Test locally before pushing changes + +### Monorepo-Specific Patterns + +1. **Workspace Management** + - Check workspace configuration files first (pnpm-workspace.yaml, etc.) + - Understand package interdependencies + - Use workspace-aware commands (`pnpm -w`, `pnpm -F `) + +2. **Dependency Installation** + - Use correct package manager workspace commands + - Install at appropriate level (workspace root vs package) + - Verify lockfile updates after changes + +### CI/CD Best Practices + +1. **Proactive Checks** + - Always check existing CI workflows before making changes + - Verify workspace configurations (monorepo setup, package managers) + - Test locally mimicking CI environment when possible + +2. **Common CI Issues** + - Missing working directory specifications + - Incorrect dependency installation commands for monorepos + - Browser installation requirements for E2E tests + - Node.js version mismatches \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 25d4ce3e..a753aff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,172 +2,188 @@ This document provides guidance for AI assistants working with this codebase, particularly around CI/CD workflows and best practices. -## 🚨 GOLDEN RULE: CI Must Be Green - -**No task is complete until all CI checks pass.** This is non-negotiable. When working on any PR: - -1. Changes must pass all CI checks -2. Multiple commits to fix CI are expected and normal -3. Once CI is green, squash all fix commits into a single clean commit -4. Only after CI is green and commits are squashed is the task considered done - -## CI Flow Overview - -When working with pull requests in this repository, the following CI checks will run automatically: - -### 1. Build and Lint (`ci.yml`) -- **Trigger**: On every push and PR to main branch -- **Node Version**: 22.x -- **Steps**: - 1. Checkout code - 2. Setup pnpm - 3. Install dependencies with frozen lockfile - 4. Run linting (`pnpm run lint`) - 5. Run build (`pnpm run build`) - -### 2. E2E Tests (`e2e-tests.yml`) -- **Trigger**: On every push and PR -- **Node Version**: 22.x -- **Environment**: Ubuntu latest -- **Steps**: - 1. Checkout code - 2. Setup pnpm - 3. Install dependencies - 4. Build lit-ssr-demo package - 5. Install Playwright Chromium browser - 6. Generate BDD test files (`pnpm run generate`) - 7. Run E2E tests (`pnpm run e2e`) - -## Working with PRs - -### Critical Requirements - -**IMPORTANT**: A task is NOT complete until CI is green. Always ensure all CI checks pass before considering any work finished. - -### CI Workflow Process - -1. **Make your changes** according to the task requirements - -2. **Run CI locally** to catch issues early: - ```bash - pnpm run ci - ``` - This runs: clean → build & lint → e2e tests - -3. **Fix any CI failures** immediately: - - Commit fixes as needed (multiple commits are fine during this phase) - - Keep pushing fixes until all CI checks are green - - Common issues: - - ESLint formatting (auto-fixable with `pnpm run lint:eslint -- --fix`) - - Unused exports (check with Knip) - - TypeScript errors - - Test failures - -4. **Once CI is green, squash commits**: - ```bash - # Count your CI fix commits (e.g., if you made 5 commits to fix CI) - git rebase -i HEAD~5 - - # In the interactive rebase, keep the first commit as 'pick' - # Change all CI fix commits to 'squash' or 's' - # Save and exit, then write a clean commit message - - # Force push the squashed commit - git push --force-with-lease - ``` - -5. **Example squash workflow**: - ```bash - # After multiple CI fixes, your history might look like: - # - Fix lint errors - # - Fix TypeScript errors - # - Update test snapshots - # - Fix import paths - # - Initial feature implementation - - # Squash into one clean commit: - git rebase -i HEAD~5 - # Result: "feat: implement new feature with all CI checks passing" - ``` - -### Before Pushing Changes - -Always verify CI will pass: +## 🚨 GOLDEN RULE: All Checks Must Pass LOCALLY Before Pushing + +**Never push code that you know will fail CI. Fix everything locally first.** + +## 📋 MANDATORY WORKFLOW: Local Quality → Local CI → Push → GitHub CI + +### Phase 1: Fix Code Quality Issues Locally + ```bash -# Full CI check -pnpm run ci +# Step 1: Auto-fix what can be fixed +pnpm run fix -# Individual checks if needed +# Step 2: Check what remains pnpm run lint -pnpm run build -pnpm run e2e + +# Step 3: Manually fix remaining issues +# Keep running pnpm run lint until it shows 0 errors/warnings ``` -### Common CI Failures and Fixes +### Phase 2: Run Full CI Locally (BEFORE committing) -#### 1. Node Version Issues -- **Problem**: Experimental TypeScript flags not supported -- **Fix**: Ensure Node 22.7.0+ is used, or use `tsx` for older versions +```bash +# This runs the EXACT same checks as GitHub CI: +pnpm run ci -#### 2. Browser Dependencies -- **Problem**: Playwright browser launch failures -- **Fix**: In CI, only Chromium is installed. Locally, install with: - ```bash - npx playwright install chromium - ``` +# What this does: +# 1. clean → Removes all build artifacts +# 2. build → TypeScript compilation + all packages +# 3. lint → ESLint + Knip (in parallel with build) +# 4. e2e → Full E2E test suite +``` -#### 3. BDD Generation Errors -- **Problem**: Step definitions not found -- **Fix**: - - Ensure all Gherkin steps have matching step definitions - - Run `pnpm run generate` before tests - - Check for duplicate step definitions +**If `pnpm run ci` fails locally → DO NOT COMMIT** -#### 4. Lint Failures -- **Problem**: Code style or unused code issues -- **Fix**: - ```bash - # Auto-fix formatting - pnpm run lint:eslint -- --fix - - # Check for unused exports - pnpm run lint:knip - ``` +### Phase 3: Commit and Push (ONLY after local CI passes) -#### 5. Unused Dependencies -- **Problem**: `tsx` or other dependencies marked as unused by lint -- **Fix**: Remove from package.json if truly unused - ```bash - # Remove from packages/app/package.json if not needed - pnpm remove tsx - ``` +```bash +# Now you can safely commit and push +git add . +git commit -m "feat: your feature" +git push +``` + +### Phase 4: GitHub CI Verification + +GitHub CI should now pass on first try because you already verified locally. + +## ⚡ Quick Commands Reference + +**During Development (granular checks):** +```bash +pnpm run lint:eslint # Just ESLint +pnpm run lint:knip # Just unused exports +pnpm run build # Just TypeScript/build +pnpm run e2e # Just E2E tests +``` + +**Before Committing (full check):** +```bash +pnpm run fix # Auto-fix issues +pnpm run ci # Full CI verification +``` + +## ❌ TASK COMPLETION CRITERIA + +A task is ONLY complete when: +1. ✅ `pnpm run fix` has been run +2. ✅ `pnpm run lint` shows 0 errors/warnings +3. ✅ `pnpm run ci` passes locally +4. ✅ Code is committed and pushed +5. ✅ GitHub CI is green + +**No exceptions. Ever.** -#### 6. Unused Exports (Knip Issues) -- **Problem**: Functions appear unused but are actually imported -- **Root Cause**: Knip doesn't detect usage if entry points aren't configured -- **Fix**: Add directories to knip.json entry points - ```json - { - "packages/e2e-tests": { - "entry": [ - "playwright.config.ts", - "steps/**/*.ts", - "pages/**/*.ts", - "utils/**/*.ts" // Add this to detect utility function usage - ] - } +## 🚀 Common Linting Issues & Quick Fixes + +### ESLint Issues +```bash +# Issue: Formatting errors +pnpm run lint:eslint -- --fix # Auto-fixes most formatting + +# Issue: Unused variables (no-unused-vars) +# Fix: Remove variable OR prefix with underscore: _unusedVar + +# Issue: Missing file extensions in imports +# Fix: Add .ts/.js extension: import { foo } from "./bar.ts" +``` + +### Knip Issues (Unused Exports) +```bash +# Issue: Function appears unused but is imported elsewhere +# Fix: Add directory to knip.json entry points: +{ + "packages/e2e-tests": { + "entry": [..., "utils/**/*.ts"] // Add this } - ``` +} +``` + +### TypeScript Issues +```bash +# Issue: Type errors +npx tsc --noEmit # Check TypeScript without building + +# Issue: Strict null checks +# Fix: Use optional chaining (?.) or non-null assertion (!) +``` + +### Prettier Issues +```bash +# Issue: Quote style, spacing, line endings +pnpm run fix # Usually fixes all Prettier issues automatically +``` + +## 🔍 Understanding CI Scripts + +### Local CI (`pnpm run ci`) +Mirrors GitHub Actions exactly: +```bash +ci: "run-s clean ci:build-and-lint ci:e2e" +# Breakdown: +# - clean: Remove all build artifacts +# - ci:build-and-lint: Parallel build + lint +# - ci:e2e: Full E2E test suite +``` + +### GitHub Actions (automatic on push) +Two workflows that run the same checks: + +**1. Build and Lint (`ci.yml`)** +- Runs: `pnpm run lint` + `pnpm run build` +- Trigger: Every push and PR + +**2. E2E Tests (`e2e-tests.yml`)** +- Runs: `pnpm run generate` + `pnpm run e2e` +- Includes: Playwright browser setup +- Trigger: Every push and PR + +**Key Point:** These should NEVER fail if you ran `pnpm run ci` locally first. + +## 🛠️ Troubleshooting Common Issues + +### Node Version +```bash +# Problem: Experimental TypeScript flags not supported +# Fix: Ensure Node 22.7.0+ is used +node --version # Should be 22.7.0+ +``` + +### Playwright Browsers +```bash +# Problem: Browser launch failures in tests +# Fix: Install browsers locally +npx playwright install chromium # Minimum for CI +npx playwright install # All browsers for full testing +``` + +### Build Artifacts +```bash +# Problem: Stale build artifacts causing errors +# Fix: Clean and rebuild +pnpm run clean +pnpm run build +``` -## Best Practices When Making Changes +### Dependency Issues +```bash +# Problem: Module not found errors +# Fix: Reinstall dependencies +rm -rf node_modules pnpm-lock.yaml +pnpm install +``` -### 1. E2E Test Development +## 📚 Best Practices + +### E2E Test Development - Follow [Gherkin best practices](packages/e2e-tests/GHERKIN_RULES.md) - Write business-readable scenarios - Use Page Object Model for maintainability - Avoid shared state between tests -#### Avoiding Shared State in Tests +**Avoiding Shared State in Tests:** - **Never use module-level variables** to store test data - Use test context or page properties to isolate state between tests - Example of safe state management: @@ -185,19 +201,19 @@ pnpm run e2e } ``` -### 2. Dependency Updates +### Dependency Management - Run `pnpm install` after any package.json changes - Commit pnpm-lock.yaml changes - Test thoroughly after major updates -### 3. TypeScript Configuration +### TypeScript Configuration - **Never include generated files** in `tsconfig.json` - Only include source TypeScript files: `"include": ["**/*.ts"]` - Generated files like `*.feature.spec.js` should never be in the TypeScript compilation - Keep strict mode enabled - Use proper type annotations -### 4. Generated Files and Version Control +### Generated Files - **Never commit generated files** to version control - Generated files to exclude: - `.features-gen/` directory (playwright-bdd generated specs) @@ -216,9 +232,9 @@ pnpm run e2e packages/e2e-tests/.features-gen/ ``` -### 5. Code Quality and Maintainability +### Code Quality -#### Extract Reusable Logic +**Extract Reusable Logic:** - Move validation and business logic to utility functions - Don't duplicate complex logic in step definitions - Example: @@ -233,14 +249,14 @@ pnpm run e2e expect(isValidSemver(json.version)).toBe(true); ``` -#### Handle Null/Undefined Safely +**Handle Null/Undefined Safely:** - In test code, using non-null assertions (`!`) is acceptable when: - The test would fail anyway if the value is null - You're testing happy paths - Example: `parseInt(text!, 10)` in test assertions - For production code, always validate properly -#### TypeScript Best Practices +**TypeScript Best Practices:** - **Always use proper types instead of `any`** ```typescript // BAD @@ -262,7 +278,7 @@ pnpm run e2e const data = dataTable.hashes(); // Proper typing and intellisense ``` -#### Validation Function Design +**Validation Function Design:** - **Chain validation functions properly** ```typescript // BAD - incomplete validation @@ -279,7 +295,7 @@ pnpm run e2e } ``` -#### Code Style Consistency +**Code Style Consistency:** - **Use consistent quote styles within files** - **Prefer double quotes for consistency with project style** ```typescript @@ -289,17 +305,51 @@ pnpm run e2e } ``` -### 6. Git Workflow +## 📝 Git Workflow + +### Commit Strategy -#### Standard PR Workflow +**PRESERVE separate commits for:** +- Logical feature development steps (e.g., "add API endpoint", "add UI", "add tests") +- Different functional areas or concerns +- Commits that tell a meaningful development story +- Refactoring vs new features + +**SQUASH together:** +- CI fix commits (lint errors, formatting, unused imports, etc.) +- Typo fixes and minor corrections +- Multiple attempts at the same change +- "fix tests" commits that should have been part of the original test commit + +**Example of good commit history:** +``` +feat: add user authentication API +feat: add login UI components +test: add authentication E2E tests +docs: add authentication guide +``` + +**Example of commits to squash:** +``` +feat: add user authentication API +fix: resolve ESLint errors +fix: add missing imports +fix: resolve TypeScript errors +``` +↓ Should become: +``` +feat: add user authentication API +``` + +### Standard PR Workflow 1. Create feature branch from main -2. Implement the feature/fix +2. Implement the feature/fix with logical commits 3. Push and check CI status 4. Fix any CI failures (multiple commits OK) -5. Once CI is green, squash all commits +5. Once CI is green, squash only the CI fix commits (keep feature commits separate) 6. Address review comments (repeat steps 3-5 as needed) -#### Real Example from This Repository +### Example: Squashing CI Fix Commits ```bash # Initial work git checkout -b playwright-bdd @@ -322,40 +372,27 @@ git add . git commit -m "fix: update step definitions for BDD tests" git push -# CI is finally green! Time to squash using soft reset -git log --oneline # Shows 4 commits +# CI is finally green! Time to squash ONLY the CI fix commits +git log --oneline # Shows 4 commits total: 1 feature + 3 CI fixes -# Soft reset to before all the CI fix commits -git reset --soft HEAD~3 # Reset 3 commits (keeping the first) +# Squash only the 3 CI fix commits (keep the original feature commit) +git reset --soft HEAD~3 # Reset only the CI fix commits -# All changes are now staged, commit with a clean message -git commit -m "feat: migrate e2e tests from Cypress to Playwright-BDD - -- Replace Cypress with Playwright and playwright-bdd -- Convert tests to Gherkin format with BDD approach -- Implement Page Object Model pattern -- Update CI workflow for new test framework" +# Commit the fixes as amendments to the original feature +git commit --amend --no-edit # Amend the original commit with CI fixes git push --force-with-lease ``` -## Troubleshooting Commands - +### Debugging E2E Tests ```bash -# Clean all build artifacts -pnpm run clean - -# Reinstall dependencies -rm -rf node_modules pnpm-lock.yaml -pnpm install - -# Debug E2E tests +# Visual debugging cd packages/e2e-tests -pnpm test --debug -pnpm test --ui # Opens Playwright UI +pnpm test --debug # Step through tests +pnpm test --ui # Opens Playwright UI -# Check what CI will run -act # Requires act tool to run GitHub Actions locally +# Run specific test +pnpm test ical.feature # Just iCal tests ``` ## Environment Requirements @@ -373,9 +410,151 @@ act # Requires act tool to run GitHub Actions locally - `eslint.config.mjs`: Code style rules - `pnpm-workspace.yaml`: Monorepo configuration -## Getting Help +## 🚫 Git History Cleanup + +### Files That Should Never Be in Git + +Before committing, always check for these types of inappropriate files: + +#### 1. Package Manager Files +- `.pnpm-store/` - pnpm cache directory (can be 10K+ files) +- `node_modules/` - dependency installations +- `.npm/` - npm cache + +#### 2. Operating System Metadata +- `.DS_Store` - macOS Finder metadata files +- `Thumbs.db` - Windows thumbnail cache +- `desktop.ini` - Windows folder customization + +#### 3. IDE and Editor Files +- `.vscode/settings.json` - Local VS Code settings +- `.idea/` - JetBrains IDE files +- `*.swp`, `*.swo` - Vim swap files + +#### 4. Local Configuration +- `.claude/settings.local.json` - Local Claude Code settings +- `.env.local` - Local environment variables +- `config.local.js` - Local configuration overrides + +#### 5. Build Artifacts and Generated Files +- `dist/`, `build/` - Compiled output +- `.features-gen/` - Generated test files +- `coverage/` - Test coverage reports -- Check CI logs for specific error messages -- Review recent successful PR patterns -- Consult package documentation for tools (Playwright, playwright-bdd, etc.) -- Follow existing code patterns in the repository \ No newline at end of file +### Cleaning Committed Files from History + +**Step 1: Identify Affected Commits** +```bash +# Check if files exist in current commit +git ls-files | grep -E '\.(DS_Store|pnpm-store)' + +# Find which commits introduced the files +git log --name-only --oneline | grep -A1 -B1 ".DS_Store" +``` + +**Step 2: Clean History with filter-branch** +```bash +# Remove files from entire branch history +git filter-branch -f --index-filter \ + 'git rm -r --cached --ignore-unmatch .DS_Store .pnpm-store/ .claude/settings.local.json' \ + ..HEAD + +# Example: Remove multiple file types +git filter-branch -f --index-filter \ + 'git rm -r --cached --ignore-unmatch \ + .DS_Store docs/.DS_Store packages/.DS_Store \ + .pnpm-store/ .claude/settings.local.json' \ + f35fe8e..HEAD +``` + +**Step 3: Verify Cleanup** +```bash +# Check that files are gone from all commits +for commit in $(git rev-list HEAD); do + if git ls-tree -r $commit | grep -E '\.(DS_Store|pnpm-store)'; then + echo "Found inappropriate files in $commit" + fi +done + +# Should return no results if cleanup was successful +``` + +**Step 4: Force Push Cleaned History** +```bash +# Push the rewritten history +git push --force-with-lease +``` + +### Preventing Inappropriate Files + +**Maintain Comprehensive .gitignore:** +Ensure `.gitignore` includes all inappropriate file patterns: +```gitignore +# Package managers +/.pnpm-store/ +node_modules/ +.npm/ + +# OS metadata +.DS_Store +Thumbs.db +desktop.ini + +# IDE files +.vscode/settings.json +.idea/ +*.swp +*.swo + +# Local config +.claude/settings.local.json +.env.local +config.local.* + +# Build artifacts +dist/ +build/ +.features-gen/ +coverage/ +test-results/ +playwright-report/ +``` + +**Pre-commit Checks:** +Before any commit, run: +```bash +# Check for inappropriate files +git status | grep -E '\.(DS_Store|pnpm-store)' + +# Review what's being committed +git diff --cached --name-only +``` + +**Regular Audits:** +Periodically scan the repository: +```bash +# Find any inappropriate files that slipped through +find . -name ".DS_Store" -o -name ".pnpm-store" -o -name "Thumbs.db" + +# Check git-tracked files +git ls-files | grep -E '\.(DS_Store|pnpm-store|swp|local)' +``` + +**When NOT to Use filter-branch:** + +Use `git filter-branch` only when files are already committed to history. For uncommitted files: +- Use `git rm --cached ` to untrack +- Add patterns to `.gitignore` +- Use `git reset` to unstage inappropriate files + +**Remember**: `git filter-branch` rewrites history and requires `--force-with-lease` push. Only use when files are already in git history and need to be completely removed from all commits. + +## 💡 Quick Reference + +### Key Files + +- `.github/workflows/`: GitHub Actions CI/CD +- `knip.json`: Unused code detection config +- `eslint.config.mjs`: Code style rules +- `pnpm-workspace.yaml`: Monorepo configuration +- `packages/e2e-tests/`: E2E test suite diff --git a/docs/e2e-ical-test-plan.md b/docs/e2e-ical-test-plan.md new file mode 100644 index 00000000..6ba894b8 --- /dev/null +++ b/docs/e2e-ical-test-plan.md @@ -0,0 +1,332 @@ +# E2E Test Plan: iCalendar Feature + +## Overview + +This document outlines a comprehensive End-to-End testing strategy for the iCalendar (.ics) feed feature. The tests will validate both the web interface functionality and the generated iCalendar data compliance with RFC 5545 specifications. + +## Test Architecture + +### Testing Stack + +#### 1. **Primary Testing Framework** +- **Playwright-BDD**: For web interface testing (already established in project) +- **Node.js Built-in Test Runner**: For iCalendar parsing/validation tests + +#### 2. **iCalendar Client Libraries** (Research Results) + +| Library | Use Case | Advantages | Disadvantages | +|---------|----------|------------|---------------| +| **ical.js** ⭐ | RFC validation & parsing | - No dependencies
- Mozilla-backed
- Recent updates (12 days ago)
- 81 dependents | - Requires separate timezone data | +| **node-ical** | Node.js-specific testing | - Async/sync APIs
- Filesystem access
- JSDoc with IDE hints | - 8 months since update
- Uses axios dependency | +| **icalvalid** | RFC 5545 compliance | - Dedicated validator
- Based on RFC 5545 | - GitHub project (unknown maintenance) | + +**Recommendation**: Use **ical.js** as primary library with **icalvalid** for comprehensive RFC compliance validation. + +#### 3. **External Validation Services** +- **iCalendar.org Validator** (https://icalendar.org/validator.html) +- For comprehensive RFC 5545 compliance checking + +## Test Categories + +### A. Web Interface Tests (Playwright-BDD) + +#### A1. Demo Page Functionality +```gherkin +Feature: iCalendar Demo Page + As a user + I want to generate iCalendar feeds + So that I can test calendar client compatibility + + Scenario: Generate basic event feed + Given I am on the iCalendar demo page + When I fill in the event form with valid data + And I click "Generate Feed URL" + Then I should see a feed URL + And I should see download and subscribe links + + Scenario: Form validation + Given I am on the iCalendar demo page + When I submit the form with missing required fields + Then I should see validation errors + And the form should not submit + + Scenario: Timezone selection + Given I am on the iCalendar demo page + When I select different timezones + And I generate a feed URL + Then the URL should include the timezone parameter + + Scenario: Cancellation functionality + Given I am on the iCalendar demo page + When I set a cancelAt date in the past + And I generate a feed URL + Then the generated feed should show a cancelled event +``` + +#### A2. API Response Tests +```gherkin +Feature: iCalendar API Responses + As a calendar client + I want to receive valid iCalendar data + So that I can properly display events + + Scenario: Valid iCalendar response + When I request "/api/ical/events.ics" with valid parameters + Then I should receive a 200 response + And the Content-Type should be "text/calendar; charset=utf-8" + And the response should contain valid iCalendar data + + Scenario: Error handling + When I request "/api/ical/events.ics" with missing parameters + Then I should receive a 400 response + And the response should contain an error message + + Scenario: Cancelled event response + When I request an event with cancelAt in the past + Then the response should contain "METHOD:CANCEL" + And the response should contain "STATUS:CANCELLED" +``` + +### B. iCalendar Data Validation Tests (Node.js + ical.js) + +#### B1. RFC 5545 Compliance +```typescript +// Test file: packages/e2e-tests/ical-validation.test.ts + +describe('iCalendar RFC 5545 Compliance', () => { + test('generates valid VCALENDAR structure', async () => { + const response = await fetch('/api/ical/events.ics?title=Test&startAt=2025-08-01T14:30:00Z&duration=60'); + const icalData = await response.text(); + + const parsed = ICAL.parse(icalData); + const comp = new ICAL.Component(parsed); + + // Validate VCALENDAR properties + expect(comp.name).toBe('vcalendar'); + expect(comp.getFirstPropertyValue('version')).toBe('2.0'); + expect(comp.getFirstPropertyValue('prodid')).toContain('Hello World Web'); + }); + + test('generates proper VEVENT structure', async () => { + // Test VEVENT properties: UID, DTSTART, DTEND, SUMMARY, etc. + }); + + test('handles timezone data correctly', async () => { + // Test VTIMEZONE component generation when timezone specified + }); +}); +``` + +#### B2. Vendor Extension Validation +```typescript +describe('Vendor Extensions', () => { + test('includes Microsoft Outlook extensions', async () => { + const icalData = await generateTestEvent(); + const parsed = ICAL.parse(icalData); + + // Verify X-MICROSOFT-CDO-BUSYSTATUS is present and valid + }); + + test('includes Apple Calendar extensions for cancelled events', async () => { + const icalData = await generateCancelledEvent(); + + // Verify X-APPLE-TRAVEL-ADVISORY-BEHAVIOR + }); +}); +``` + +#### B3. Edge Case Testing +```typescript +describe('Edge Cases', () => { + test('handles very long event titles', async () => { + const longTitle = 'A'.repeat(1000); + // Test RFC line length limits + }); + + test('handles special characters in event data', async () => { + // Test Unicode, newlines, quotes, etc. + }); + + test('handles timezone edge cases', async () => { + // Test DST transitions, invalid timezones + }); +}); +``` + +### C. Integration Tests + +#### C1. Calendar Client Simulation +```typescript +describe('Calendar Client Compatibility', () => { + test('simulates Apple Calendar subscription', async () => { + // Use ical.js to parse as Apple Calendar would + const icalData = await fetchICalFeed(); + const events = parseEvents(icalData); + + // Verify event appears correctly + expect(events).toHaveLength(1); + expect(events[0].summary).toBe('Expected Title'); + }); + + test('simulates Google Calendar import', async () => { + // Test Google-specific quirks and requirements + }); + + test('simulates Outlook subscription', async () => { + // Test Microsoft-specific extensions + }); +}); +``` + +#### C2. Cancellation Flow Testing +```typescript +describe('Event Cancellation Flow', () => { + test('event lifecycle: created -> cancelled', async () => { + // 1. Create event + const originalEvent = await generateEvent({ cancelAt: null }); + + // 2. Cancel event (same UID, updated sequence) + const cancelledEvent = await generateEvent({ + cancelAt: new Date(Date.now() - 86400000).toISOString() + }); + + // 3. Verify cancellation properties + expect(cancelledEvent).toContain('METHOD:CANCEL'); + expect(cancelledEvent).toContain('SEQUENCE:1'); + }); +}); +``` + +### D. Performance & Load Testing + +#### D1. Response Time Testing +```typescript +describe('Performance', () => { + test('generates feed within acceptable time', async () => { + const start = performance.now(); + await fetch('/api/ical/events.ics?title=Test&startAt=2025-08-01T14:30:00Z&duration=60'); + const duration = performance.now() - start; + + expect(duration).toBeLessThan(500); // 500ms threshold + }); + + test('handles concurrent requests', async () => { + const requests = Array(10).fill().map(() => + fetch('/api/ical/events.ics?title=Test&startAt=2025-08-01T14:30:00Z&duration=60') + ); + + const responses = await Promise.all(requests); + responses.forEach(response => { + expect(response.status).toBe(200); + }); + }); +}); +``` + +## Implementation Strategy + +### Phase 1: Foundation (Week 1) +1. **Setup ical.js dependency** in e2e-tests package +2. **Create basic iCalendar parsing utilities** +3. **Add web interface BDD tests** for demo page +4. **Implement API response validation tests** + +### Phase 2: Validation (Week 2) +1. **RFC 5545 compliance tests** using ical.js +2. **Vendor extension validation** +3. **Timezone handling verification** +4. **Error condition testing** + +### Phase 3: Integration (Week 3) +1. **Calendar client simulation tests** +2. **Cancellation flow end-to-end testing** +3. **External validator integration** (icalendar.org) +4. **Performance benchmarking** + +### Phase 4: Edge Cases & Polish (Week 4) +1. **Edge case scenario testing** +2. **Load testing implementation** +3. **CI/CD integration optimization** +4. **Documentation and reporting** + +## File Structure + +``` +packages/e2e-tests/ +├── features/ +│ └── ical.feature # BDD scenarios +├── steps/ +│ └── ical-steps.ts # Step definitions +├── pages/ +│ └── ical-demo-page.ts # Page object model +├── utils/ +│ ├── ical-parser.ts # ical.js wrapper utilities +│ ├── test-data-generator.ts # Test data generation +│ └── external-validator.ts # icalendar.org API integration +├── validation/ +│ ├── rfc5545-compliance.test.ts # RFC compliance tests +│ ├── vendor-extensions.test.ts # Vendor-specific tests +│ └── integration.test.ts # Client simulation tests +└── performance/ + └── load-testing.test.ts # Performance benchmarks +``` + +## Dependencies to Add + +```json +{ + "devDependencies": { + "ical.js": "^2.2.0", + "node-fetch": "^3.3.2" + } +} +``` + +## Success Criteria + +### Functional Requirements +- ✅ All web interface interactions work correctly +- ✅ Generated iCalendar data is RFC 5545 compliant +- ✅ Vendor extensions are properly implemented +- ✅ Timezone handling works correctly +- ✅ Cancellation flow operates as expected + +### Quality Requirements +- ✅ 95%+ test coverage for iCalendar functionality +- ✅ All tests pass in CI/CD pipeline +- ✅ Response times under 500ms for feed generation +- ✅ External validator confirms RFC compliance + +### Maintenance Requirements +- ✅ Tests are maintainable and well-documented +- ✅ Clear failure messages and debugging information +- ✅ Automated reporting of test results + +## Risk Mitigation + +### Technical Risks +1. **ical.js parsing differences**: Validate against multiple libraries +2. **External validator availability**: Cache validation results, fallback to local validation +3. **Timezone data inconsistencies**: Test with specific known timezones + +### Maintenance Risks +1. **RFC specification changes**: Monitor RFC updates, automated compliance checking +2. **Vendor extension evolution**: Regular review of client compatibility +3. **Library deprecation**: Monitor dependency health, have fallback options + +## Monitoring & Reporting + +### Test Metrics +- Test execution time +- RFC compliance score +- Vendor compatibility matrix +- Performance benchmarks + +### Continuous Monitoring +- Daily RFC compliance checks +- Weekly vendor extension validation +- Monthly performance regression testing + +## Conclusion + +This comprehensive E2E testing strategy ensures the iCalendar feature is robust, RFC-compliant, and compatible with major calendar clients. The phased implementation approach allows for iterative development and early feedback, while the multi-layered testing approach catches issues at different levels of the application stack. \ No newline at end of file diff --git a/docs/features/ical.md b/docs/features/ical.md new file mode 100644 index 00000000..e6a73e25 --- /dev/null +++ b/docs/features/ical.md @@ -0,0 +1,334 @@ +# iCalendar Feed Feature + +## Overview + +This feature provides a minimal iCalendar (.ics) feed implementation for testing calendar client compatibility and documenting vendor-specific workarounds. The implementation follows RFC 5545 (iCalendar) and RFC 5546 (iTIP) specifications while including pragmatic workarounds for real-world client behavior. + +## Purpose + +- Test iCalendar feeds with various calendar clients (Apple Calendar, Google Calendar, Outlook, etc.) +- Document vendor-specific quirks and workarounds in code +- Provide a simple demo for experimentation before implementing in production applications +- Ensure RFC compliance while maintaining practical compatibility + +## API Endpoint + +### GET `/api/ical/events.ics` + +Generates a dynamic iCalendar feed for a single event. + +#### Query Parameters + +| Parameter | Type | Required | Description | Example | +|-----------|------|----------|-------------|---------| +| `title` | string | Yes | Event title/summary | `Team Meeting` | +| `startAt` | string | Yes | ISO 8601 datetime | `2025-08-01T14:30:00` | +| `duration` | number | Yes | Duration in minutes | `90` | +| `tz` | string | No | Timezone identifier (defaults to UTC). When specified, generates a VTIMEZONE component in the output. | `Europe/Zurich` | +| `cancelAt` | string | No | ISO datetime when event becomes cancelled | `2025-07-31T12:00:00` | + +#### Response + +- **Content-Type**: `text/calendar; charset=utf-8` +- **Body**: Valid iCalendar data + +#### Example Request + +``` +GET /api/ical/events.ics?title=Team%20Meeting&startAt=2025-08-01T14:30:00&duration=60&tz=Europe/Zurich +``` + +#### Example Response + +```icalendar +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Hello World Web//Event Feed//EN +METHOD:PUBLISH +BEGIN:VTIMEZONE +TZID:Europe/Zurich +... +END:VTIMEZONE +BEGIN:VEVENT +UID:a1b2c3d4@hello-world-web.local +DTSTAMP:20250119T120000Z +DTSTART;TZID=Europe/Zurich:20250801T143000 +DTEND;TZID=Europe/Zurich:20250801T153000 +SUMMARY:Team Meeting +STATUS:CONFIRMED +SEQUENCE:0 +END:VEVENT +END:VCALENDAR +``` + +## Demo Page + +### GET `/demos/ical` + +Provides an HTML form for testing the iCalendar feed with different parameters. + +Features: +- Input fields for all parameters +- Live preview of generated feed URL +- Copy-to-clipboard functionality +- Client-specific testing instructions + +## Implementation Details + +### UID Generation + +Event UIDs are generated using a hash of the event's stable properties (title + startAt) to ensure consistency across requests. This allows calendar clients to properly track updates to the same event. + +```typescript +const uid = `${revHash(title + startAt)}@hello-world-web.local`; +``` + +### Sequence Handling + +- `SEQUENCE: 0` - Initial event (not cancelled) +- `SEQUENCE: 1` - Updated event (cancelled) + +The sequence number increments when the event transitions to cancelled state, signaling to clients that this is an update to an existing event. + +### Cancellation Logic + +If `cancelAt` parameter is provided and the current time is past that timestamp: +- `METHOD: CANCEL` (instead of PUBLISH) +- `STATUS: CANCELLED` (instead of CONFIRMED) +- `SEQUENCE: 1` (incremented) + +## Client Compatibility + +### Tested Clients + +| Client | Version | Subscription | Import | Cancellation | Notes | +|--------|---------|--------------|--------|--------------|-------| +| Apple Calendar | macOS 14+ | ✅ | ✅ | ✅ | Refresh: 5min-1week | +| Google Calendar | Web | ✅ | ❌ | ✅ | Refresh: ~24h | +| Outlook Desktop | 2021+ | ✅ | ✅ | ✅ | Refresh: ~3h | +| Outlook.com | Web | ✅ | ✅ | ✅ | Refresh: 3-24h | +| Thunderbird | 115+ | ✅ | ✅ | ✅ | Manual refresh available | + +### Known Vendor Quirks + +#### Microsoft Outlook +- Requires proper `METHOD:CANCEL` for cancellations to avoid "not supported calendar message" errors +- May show duplicate events if UID is not consistent +- Uses `X-MICROSOFT-CDO-BUSYSTATUS` for free/busy status in scheduling assistant + +#### Google Calendar +- Slow refresh rate (~24 hours) for subscribed calendars +- Ignores .ics attachments unless sender matches organizer email +- May silently ignore invalid feeds +- **Ignores refresh interval hints** - deliberately ignores `X-PUBLISHED-TTL` and `X-GOOGLE-REFRESH-INTERVAL` to prevent server overload + +#### Apple Calendar +- Displays cancelled events with strikethrough +- Supports configurable refresh intervals (5 minutes to 1 week) +- Handles VTIMEZONE data well +- Has synchronization issues with cancelled Exchange events that may remain visible + +## RFC Compliance + +The implementation follows: +- [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545) - Internet Calendaring and Scheduling Core Object Specification (iCalendar) +- [RFC 5546](https://www.rfc-editor.org/rfc/rfc5546) - iCalendar Transport-Independent Interoperability Protocol (iTIP) + +Key compliance points: +- Proper UID persistence across updates +- SEQUENCE increment for modifications +- METHOD property for scheduling semantics +- VTIMEZONE components for non-UTC times +- STATUS property for event state + +## Timezone Handling + +Proper timezone handling is critical for iCalendar feeds to work correctly across different calendar clients and time zones. + +### Implementation Approach + +When a timezone is specified via the `tz` parameter, our implementation: + +1. Sets the timezone at the calendar level using `cal.timezone(timezone)` +2. This automatically generates the required `VTIMEZONE` component +3. Event times are then specified with `TZID` parameters that reference this timezone + +```typescript +// Setting timezone on calendar generates VTIMEZONE component +if (event.timezone) { + cal.timezone(event.timezone); +} + +// Results in output like: +// DTSTART;TZID=Europe/Zurich:20250801T143000 +``` + +### RFC 5545 Requirements + +According to [RFC 5545 Section 3.2.19](https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19), when using the `TZID` parameter: + +- The value MUST be the text value of a `TZID` property of a `VTIMEZONE` component in the iCalendar object +- An iCalendar parser will look for a matching `VTIMEZONE` component +- Using `TZID` without a corresponding `VTIMEZONE` creates an **invalid** iCalendar file + +### Why Not Use Global Timezone IDs? + +Global timezone IDs (prefixed with `/`, e.g., `TZID=/Europe/Zurich`) are an alternative approach that doesn't require `VTIMEZONE` components. However, we chose not to use them because: + +1. **No standard interpretation** - The iCalendar specification doesn't define how parsers should interpret global IDs +2. **Inconsistent support** - While many parsers treat them as Olson timezone IDs, this behavior is not guaranteed +3. **Testing focus** - As a demo app for testing calendar compatibility, we use the standard approach + +This decision is supported by [community consensus on Stack Overflow](https://stackoverflow.com/a/41073444/42941087) that VTIMEZONE components provide the most reliable, RFC-compliant approach. + +### Example: Valid vs Invalid Timezone Usage + +#### ❌ Invalid (Missing VTIMEZONE) +```icalendar +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART;TZID=Europe/Zurich:20250801T143000 +DTEND;TZID=Europe/Zurich:20250801T153000 +END:VEVENT +END:VCALENDAR +``` + +#### ✅ Valid (With VTIMEZONE) +```icalendar +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Zurich +... (timezone rules) ... +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Zurich:20250801T143000 +DTEND;TZID=Europe/Zurich:20250801T153000 +END:VEVENT +END:VCALENDAR +``` + +### Client Behavior Notes + +- **Without VTIMEZONE**: Calendar clients may fail to parse the file, ignore the timezone, or display incorrect times +- **With VTIMEZONE**: All RFC-compliant clients correctly interpret the event times in the specified timezone +- **UTC Alternative**: For simple cases, using UTC times (with 'Z' suffix) avoids timezone complexity entirely + +## Vendor Extensions + +To address real-world calendar client limitations, we include several vendor-specific properties that enhance compatibility while maintaining RFC compliance. + +### Microsoft Outlook Extensions + +#### `X-MICROSOFT-CDO-BUSYSTATUS` + +**Purpose**: Controls how events appear in Outlook's free/busy view and scheduling assistant. + +**Implementation**: +```typescript +// For cancelled events +event.x("X-MICROSOFT-CDO-BUSYSTATUS", "FREE"); + +// For confirmed events +event.x("X-MICROSOFT-CDO-BUSYSTATUS", "BUSY"); +``` + +**Rationale**: Outlook may not properly interpret cancelled events without explicit busy status. Setting cancelled events to "FREE" ensures they don't block time in scheduling and prevents conflicts with cancelled events. + +### Apple Calendar Extensions + +#### `X-APPLE-TRAVEL-ADVISORY-BEHAVIOR` + +**Purpose**: Enhances cancellation handling reliability in Apple Calendar. + +**Implementation**: +```typescript +// For cancelled events only +if (options.isCancelled) { + event.x("X-APPLE-TRAVEL-ADVISORY-BEHAVIOR", "AUTOMATIC"); +} +``` + +**Rationale**: Apple Calendar has known issues with cancelled event synchronization, particularly with Exchange/Outlook cancellations. This property provides additional hints to improve cancellation processing reliability. + +### Google Calendar Extensions + +#### `X-GOOGLE-REFRESH-INTERVAL` + +**Purpose**: Attempts to influence Google Calendar's subscription refresh rate. + +**Implementation**: +```typescript +// Applied to all events +event.x("X-GOOGLE-REFRESH-INTERVAL", "PT1H"); // 1 hour hint +``` + +**Rationale**: Google Calendar has extremely slow refresh rates (~24 hours) for subscribed calendars. While Google deliberately ignores this property to prevent server overload, it's included for documentation of intent and potential future compatibility. + +**Note**: This property is currently **ineffective** but represents standard practice in the calendar ecosystem. + +### Calendar Client Refresh Rate Comparison + +| Client | Default Refresh Rate | User Configurable | Respects X-Properties | +|--------|---------------------|-------------------|----------------------| +| **Apple Calendar** | 5 min - 1 week | ✅ Yes | ✅ Partially | +| **Google Calendar** | ~24 hours | ❌ No | ❌ No | +| **Outlook Desktop** | ~3 hours | ⚠️ Limited | ✅ Yes | +| **Outlook.com** | 3-24 hours | ❌ No | ✅ Partially | +| **Thunderbird** | Manual/configurable | ✅ Yes | ✅ Yes | + +## Testing + +### Manual Testing Checklist + +1. **Basic Event Creation** + - [ ] Generate feed URL with form + - [ ] Download .ics file + - [ ] Import into calendar client + - [ ] Verify event appears with correct details + +2. **Subscription Testing** + - [ ] Add feed URL as calendar subscription + - [ ] Verify initial event appears + - [ ] Modify parameters (keep same UID) + - [ ] Wait for/trigger refresh + - [ ] Verify updates are reflected + +3. **Cancellation Testing** + - [ ] Create event without cancelAt + - [ ] Subscribe in calendar client + - [ ] Add cancelAt in the past + - [ ] Wait for/trigger refresh + - [ ] Verify event is cancelled/removed + +### Automated Tests + +- **E2E Tests**: Cypress tests for demo form functionality +- **Unit Tests**: iCal generation with various parameters +- **Validation**: RFC compliance checking + +## Error Handling + +Invalid parameters return `400 Bad Request` with plain text error message: + +``` +Missing required parameter: title +``` + +## Security Considerations + +- All user input is validated and sanitized +- No server-side state is maintained +- Generated UIDs use cryptographic hashing +- No sensitive data is exposed in feeds + +## Future Enhancements + +This minimal implementation can be extended with: +- Multiple events per feed +- Recurring events (RRULE) +- Attendee management (ATTENDEE) +- Reminder/alarm support (VALARM) +- File attachments (ATTACH) +- Free/busy information \ No newline at end of file diff --git a/package.json b/package.json index d185f59c..a87830e9 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,32 @@ "type": "module", "scripts": { "dev": "pnpm -r --workspace-concurrency=99 run dev", - "dist": "pnpm run ci", - "clean": "pnpm -r run clean", + "dist": "pnpm run -s ci", + "clean": "pnpm run -rs clean", "ci": "run-s clean ci:build-and-lint ci:e2e", + "ci:compact": "run-s clean ci:build-and-lint:compact ci:e2e", "ci:build-and-lint": "run-p lint build", - "ci:e2e": "cross-env PORT=11111 start-server-and-test start http://localhost:11111 e2e", + "ci:build-and-lint:compact": "run-p lint:compact build", + "ci:e2e": "cross-env PORT=11111 COMPACT=1 start-server-and-test start http://localhost:11111 e2e", "start": "pnpm -F hello-world-web run start", "healthcheck": "pnpm -F hello-world-web run healthcheck", "e2e": "pnpm --dir packages/e2e-tests e2e", + "e2e:compact": "pnpm --dir packages/e2e-tests e2e:compact", + "fix": "pnpm run -rs fix && pnpm run -s lint:eslint:fix", "dev-trace-sync-io": "cross-env-shell 'export NODE_OPTIONS='--trace-sync-io' && $npm_execpath run dev:server'", - "build": "pnpm -r run build", + "build": "pnpm run -rs build", "prepublishOnly": "npm shrinkwrap --omit=dev", "pack": "run-s clean build && pnpm pack && realpath hello-world-web-*.tgz", "lint": "run-p lint:packages lint:eslint lint:knip", - "lint:packages": "pnpm -r run lint", + "lint:compact": "run-p lint:packages lint:eslint:compact lint:knip", + "lint:packages": "pnpm run -rs lint", "lint:knip": "knip", "lint:eslint": "eslint", + "lint:eslint:compact": "eslint --quiet", + "lint:eslint:fix": "pnpm run -s lint:eslint --fix", "cy": "pnpm --dir packages/e2e-tests cy", - "test": "pnpm run e2e" + "test": "pnpm run -s e2e", + "compact": "pnpm run -s ci:compact" }, "devDependencies": { "@eslint/compat": "^1.1.1", diff --git a/packages/app/package.json b/packages/app/package.json index b7199fe6..c4c2dcdc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,6 +13,7 @@ "start": "DEBUG='hello-world-web:*' NODE_ENV=production node --experimental-strip-types --experimental-transform-types ./src/bin/www.ts", "dev": "DEBUG='hello-world-web:*' nodemon -d 1.0 -e html,ts,tsx,js,css,json,env -w '**/*' -w .env --exec node --experimental-strip-types --experimental-transform-types ./src/bin/www.ts", "healthcheck": "node --experimental-strip-types --experimental-transform-types --experimental-fetch --no-warnings src/bin/healthcheck.mts || exit 1", + "test": "node --experimental-strip-types --test src/**/*.test.ts", "dist": "pnpm run ci", "clean": "rimraf dist src/views/lit-ssr-demo/lib", "dev-trace-sync-io": "cross-env-shell 'export NODE_OPTIONS='--trace-sync-io' && $npm_execpath run dev:server'", @@ -26,9 +27,11 @@ "debug": "~4.4.0", "dotenv": "^17.0.0", "express": "~5.1.0", + "ical-generator": "^9.0.0", "lit-html": "^3.1.4", "morgan": "~1.10.0", - "npm-run-all2": "^7.0.0" + "npm-run-all2": "^7.0.0", + "rev-hash": "^4.1.0" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index bd2e7f8a..05ae7dbc 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -6,9 +6,10 @@ import path from "node:path"; import { createRequire } from "node:module"; import config from "./config.ts"; -import { apiRouter } from "./routes/api/index.ts"; +import { apiRouter, icalRouter } from "./routes/api/index.ts"; import indexRouter from "./routes/home.ts"; import litSsrDemoRouter from "./routes/lit-ssr-demo.ts"; +import icalDemoRouter from "./routes/demos/ical.ts"; const { basePath } = config; const __filename = fileURLToPath(import.meta.url); @@ -33,6 +34,8 @@ app.use( // app routes app.use(path.join(basePath, "/"), indexRouter); app.use(path.join(basePath, "/api"), apiRouter); +app.use(path.join(basePath, "/api/ical"), icalRouter); +app.use(path.join(basePath, "/demos"), icalDemoRouter); app.use(path.join(basePath, "/"), litSsrDemoRouter); export default app; diff --git a/packages/app/src/routes/api/ical.ts b/packages/app/src/routes/api/ical.ts new file mode 100644 index 00000000..7393420f --- /dev/null +++ b/packages/app/src/routes/api/ical.ts @@ -0,0 +1,78 @@ +import { Router, type Request, type Response } from "express"; +import ical from "ical-generator"; +import type { EventQueryParams, ParsedEvent } from "../../types/events.ts"; +import { parseEventParams, addVendorExtensions } from "../../utils/ical-helpers.ts"; + +export const icalRouter: Router = Router(); + +/** + * GET /api/ical/events.ics + * Generate dynamic iCalendar feed for a single event + */ +icalRouter.get("/events.ics", (req: Request, res: Response) => { + // Parse and validate parameters + const parsed = parseEventParams(req.query as EventQueryParams); + + if ("error" in parsed) { + return res.status(400).type("text/plain").send(parsed.error); + } + + const event = parsed as ParsedEvent; + + try { + // Create calendar with proper metadata + const cal = ical({ + name: "Event Feed", + prodId: { + company: "Hello World Web", + product: "Event Feed", + language: "EN", + }, + // Set calendar-level METHOD for proper client handling + method: event.icalOptions.method, + }); + + // If timezone is specified, add VTIMEZONE by setting it on the calendar + // This is REQUIRED by RFC 5545 - when using TZID parameters in event times, + // there MUST be a corresponding VTIMEZONE component in the calendar. + // Setting timezone at calendar level ensures ical-generator creates the + // necessary VTIMEZONE component that matches our TZID references. + // See: https://stackoverflow.com/a/41073444 for why this approach is correct + if (event.timezone) { + cal.timezone(event.timezone); + } + + // Create the event + const calEvent = cal.createEvent({ + start: event.startDate, + end: event.endDate, + summary: event.title, + description: "Event generated by Hello World Web iCalendar demo", + location: "Demo Location", // Fixed for demo + organizer: { + name: "Demo Organizer", + email: "noreply@hello-world-web.local", + }, + sequence: event.icalOptions.sequence, + status: event.icalOptions.status, + // Timestamp when this iCal was generated + stamp: new Date(), + }); + + // Set UID after creation (some versions require this) + calEvent.uid(event.uid); + + // Add vendor-specific extensions for better compatibility + addVendorExtensions(calEvent, event.icalOptions); + + // Set proper content type and send + res.setHeader("Content-Type", "text/calendar; charset=utf-8"); + res.setHeader("Content-Disposition", 'inline; filename="events.ics"'); + + // Send the generated iCalendar data + res.send(cal.toString()); + } catch (error) { + console.error("Error generating iCalendar:", error); + res.status(500).type("text/plain").send("Error generating calendar data"); + } +}); diff --git a/packages/app/src/routes/api/index.ts b/packages/app/src/routes/api/index.ts index cb3bec79..0970de14 100644 --- a/packages/app/src/routes/api/index.ts +++ b/packages/app/src/routes/api/index.ts @@ -1 +1,2 @@ export * from "./api.ts"; +export * from "./ical.ts"; diff --git a/packages/app/src/routes/demos/ical.ts b/packages/app/src/routes/demos/ical.ts new file mode 100644 index 00000000..7159fcfc --- /dev/null +++ b/packages/app/src/routes/demos/ical.ts @@ -0,0 +1,181 @@ +import { Router } from "express"; +import config from "../../config.ts"; +import { renderViewToStream } from "../../support/render-view/renderView.ts"; +import { Html as HtmlComponent } from "../../views/Html.ts"; +import { html } from "lit-html"; + +const router: Router = Router(); + +/* GET /demos/ical - iCalendar demo page */ +router.get("/ical", async function (_req, res) { + const pageData = { + title: "iCalendar Demo", + basePath: config.basePath, + }; + + const bodyContent = html` +
+

iCalendar Feed Demo

+

Generate dynamic iCalendar (.ics) feeds for testing calendar client compatibility.

+ +
+
+
+
+ + +
+ +
+ + + Local time will be converted based on timezone +
+ +
+ + +
+ +
+ + +
+ +
+ + + If set to past time, event will be marked as cancelled +
+ + +
+ +
+

Feed URL:

+
+ + +
+ +
+
+ +
+

Testing Instructions

+ +

Subscribe to Feed

+
    +
  • Apple Calendar: File → New Calendar Subscription → Paste URL
  • +
  • Google Calendar: Settings → Add Calendar → From URL
  • +
  • Outlook: Add Calendar → From Internet → Paste URL
  • +
+ +

Testing Cancellations

+
    +
  1. Create an event without cancelAt
  2. +
  3. Subscribe in your calendar app
  4. +
  5. Generate same event with cancelAt in the past
  6. +
  7. Wait for calendar refresh (or trigger manually)
  8. +
  9. Event should appear cancelled/removed
  10. +
+ +

Client Refresh Rates

+
    +
  • Apple: 5 min - 1 week (configurable)
  • +
  • Google: ~24 hours (not configurable)
  • +
  • Outlook: ~3 hours
  • +
+ +

Known Issues

+
    +
  • Google Calendar has slow refresh for subscriptions
  • +
  • Some clients may cache .ics downloads
  • +
  • Timezone support varies by client
  • +
+
+
+
+ + + `; + + const view = renderViewToStream(HtmlComponent, { + htmlTitle: pageData.title, + basePath: pageData.basePath, + bodyContent, + }); + + view.pipe(res); +}); + +export default router; diff --git a/packages/app/src/types/events.ts b/packages/app/src/types/events.ts new file mode 100644 index 00000000..ee7c7c97 --- /dev/null +++ b/packages/app/src/types/events.ts @@ -0,0 +1,103 @@ +/** + * Event types for iCalendar feed generation + * + * These types follow RFC 5545 (iCalendar) specifications + * with additional fields for vendor-specific workarounds + */ + +/** + * Core event data structure + */ +export interface Event { + /** Event title/summary (RFC 5545: SUMMARY) */ + title: string; + + /** Event start datetime in ISO 8601 format */ + startAt: string; + + /** Duration in minutes */ + duration: number; + + /** IANA timezone identifier (e.g., "Europe/Zurich") */ + timezone?: string; + + /** ISO datetime when event should be considered cancelled */ + cancelAt?: string; +} + +/** + * iCalendar generation options + */ +export interface ICalOptions { + /** Calendar METHOD property (RFC 5546) */ + method: "PUBLISH" | "CANCEL" | "REQUEST"; + + /** Event STATUS property */ + status: "CONFIRMED" | "CANCELLED" | "TENTATIVE"; + + /** SEQUENCE number for updates (RFC 5545: 3.8.7.4) */ + sequence: number; + + /** Whether event is currently cancelled based on cancelAt */ + isCancelled: boolean; +} + +/** + * Parsed and validated event data + */ +export interface ParsedEvent extends Event { + /** Parsed start date */ + startDate: Date; + + /** Calculated end date */ + endDate: Date; + + /** Generated unique identifier */ + uid: string; + + /** iCalendar generation options */ + icalOptions: ICalOptions; +} + +/** + * Query parameters for the events.ics endpoint + */ +export interface EventQueryParams { + title?: string; + startAt?: string; + duration?: string; + tz?: string; + cancelAt?: string; +} + +/** + * Vendor-specific extensions for client compatibility + * These are X- properties added for specific calendar clients + */ +export interface VendorExtensions { + /** Microsoft Outlook specific properties */ + outlook?: { + /** X-MICROSOFT-CDO-BUSYSTATUS */ + busyStatus?: "FREE" | "TENTATIVE" | "BUSY" | "OOF"; + }; + + /** Apple Calendar specific properties */ + apple?: { + /** X-APPLE-STRUCTURED-LOCATION */ + structuredLocation?: string; + }; + + /** Google Calendar hints */ + google?: { + /** Refresh interval hint (not standard) */ + refreshInterval?: string; + }; +} + +/** + * Error response for invalid parameters + */ +export interface ErrorResponse { + error: string; + status: number; +} diff --git a/packages/app/src/utils/ical-helpers.test.ts b/packages/app/src/utils/ical-helpers.test.ts new file mode 100644 index 00000000..b1f709a3 --- /dev/null +++ b/packages/app/src/utils/ical-helpers.test.ts @@ -0,0 +1,184 @@ +/** + * Unit tests for iCalendar helper functions + * + * These tests can be run with Node.js built-in test runner: + * node --test src/utils/ical-helpers.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parseEventParams } from "./ical-helpers.ts"; + +describe("parseEventParams", () => { + describe("validation", () => { + it("should return error for missing title", () => { + const result = parseEventParams({ + startAt: "2025-08-01T14:30:00Z", + duration: "60", + }); + assert.ok("error" in result); + assert.equal(result.error, "Missing required parameter: title"); + }); + + it("should return error for missing startAt", () => { + const result = parseEventParams({ + title: "Test Event", + duration: "60", + }); + assert.ok("error" in result); + assert.equal(result.error, "Missing required parameter: startAt"); + }); + + it("should return error for missing duration", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + }); + assert.ok("error" in result); + assert.equal(result.error, "Missing required parameter: duration"); + }); + + it("should return error for invalid duration", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "invalid", + }); + assert.ok("error" in result); + assert.equal(result.error, "Invalid duration: must be a positive number"); + }); + + it("should return error for negative duration", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "-30", + }); + assert.ok("error" in result); + assert.equal(result.error, "Invalid duration: must be a positive number"); + }); + + it("should return error for invalid startAt date", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "invalid-date", + duration: "60", + }); + assert.ok("error" in result); + assert.equal(result.error, "Invalid startAt: must be a valid ISO 8601 datetime"); + }); + + it("should return error for invalid cancelAt date", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + cancelAt: "invalid-date", + }); + assert.ok("error" in result); + assert.equal(result.error, "Invalid cancelAt: must be a valid ISO 8601 datetime"); + }); + }); + + describe("successful parsing", () => { + it("should parse basic event parameters", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "90", + }); + + assert.ok(!("error" in result)); + assert.equal(result.title, "Test Event"); + assert.equal(result.duration, 90); + assert.equal(result.startDate.toISOString(), "2025-08-01T14:30:00.000Z"); + assert.equal(result.endDate.toISOString(), "2025-08-01T16:00:00.000Z"); + assert.ok(result.uid.includes("@hello-world-web.local")); + assert.equal(result.icalOptions.method, "PUBLISH"); + assert.equal(result.icalOptions.status, "CONFIRMED"); + assert.equal(result.icalOptions.sequence, 0); + assert.equal(result.icalOptions.isCancelled, false); + }); + + it("should parse event with timezone", () => { + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00", + duration: "60", + tz: "Europe/Zurich", + }); + + assert.ok(!("error" in result)); + assert.equal(result.timezone, "Europe/Zurich"); + }); + + it("should handle future cancelAt (not cancelled)", () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + cancelAt: futureDate.toISOString(), + }); + + assert.ok(!("error" in result)); + assert.equal(result.icalOptions.method, "PUBLISH"); + assert.equal(result.icalOptions.status, "CONFIRMED"); + assert.equal(result.icalOptions.sequence, 0); + assert.equal(result.icalOptions.isCancelled, false); + }); + + it("should handle past cancelAt (cancelled)", () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 1); + + const result = parseEventParams({ + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + cancelAt: pastDate.toISOString(), + }); + + assert.ok(!("error" in result)); + assert.equal(result.icalOptions.method, "CANCEL"); + assert.equal(result.icalOptions.status, "CANCELLED"); + assert.equal(result.icalOptions.sequence, 1); + assert.equal(result.icalOptions.isCancelled, true); + }); + + it("should generate consistent UID for same event", () => { + const params = { + title: "Test Event", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + }; + + const result1 = parseEventParams(params); + const result2 = parseEventParams(params); + + assert.ok(!("error" in result1)); + assert.ok(!("error" in result2)); + assert.equal(result1.uid, result2.uid); + }); + + it("should generate different UIDs for different events", () => { + const result1 = parseEventParams({ + title: "Event 1", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + }); + + const result2 = parseEventParams({ + title: "Event 2", + startAt: "2025-08-01T14:30:00Z", + duration: "60", + }); + + assert.ok(!("error" in result1)); + assert.ok(!("error" in result2)); + assert.notEqual(result1.uid, result2.uid); + }); + }); +}); diff --git a/packages/app/src/utils/ical-helpers.ts b/packages/app/src/utils/ical-helpers.ts new file mode 100644 index 00000000..a12dddbd --- /dev/null +++ b/packages/app/src/utils/ical-helpers.ts @@ -0,0 +1,107 @@ +import revHash from "rev-hash"; +import type { ICalEvent } from "ical-generator"; +import type { EventQueryParams, ParsedEvent, ICalOptions } from "../types/events.ts"; + +/** + * Parse and validate query parameters for event generation + * This is exported for unit testing + */ +export function parseEventParams(query: EventQueryParams): ParsedEvent | { error: string } { + const { title, startAt, duration, tz, cancelAt } = query; + + // Validate required parameters + if (!title) { + return { error: "Missing required parameter: title" }; + } + if (!startAt) { + return { error: "Missing required parameter: startAt" }; + } + if (!duration) { + return { error: "Missing required parameter: duration" }; + } + + // Parse duration + const durationMin = parseInt(duration, 10); + if (isNaN(durationMin) || durationMin <= 0) { + return { error: "Invalid duration: must be a positive number" }; + } + + // Parse dates + let startDate: Date; + try { + startDate = new Date(startAt); + if (isNaN(startDate.getTime())) { + throw new Error("Invalid date"); + } + } catch { + return { error: "Invalid startAt: must be a valid ISO 8601 datetime" }; + } + + const endDate = new Date(startDate.getTime() + durationMin * 60000); + + // Parse cancelAt if provided + let cancelAtDate: Date | null = null; + if (cancelAt) { + try { + cancelAtDate = new Date(cancelAt); + if (isNaN(cancelAtDate.getTime())) { + throw new Error("Invalid date"); + } + } catch { + return { error: "Invalid cancelAt: must be a valid ISO 8601 datetime" }; + } + } + + // Determine cancellation status + const now = new Date(); + const isCancelled = cancelAtDate ? now >= cancelAtDate : false; + + // Generate stable UID using hash of stable properties + // This ensures the same event always has the same UID + const uidBase = `${title}-${startAt}`; + const uid = `${revHash(uidBase)}@hello-world-web.local`; + + // Determine iCalendar options based on state + const icalOptions: ICalOptions = { + method: isCancelled ? "CANCEL" : "PUBLISH", + status: isCancelled ? "CANCELLED" : "CONFIRMED", + sequence: isCancelled ? 1 : 0, // Increment sequence for updates + isCancelled, + }; + + return { + title, + startAt, + duration: durationMin, + timezone: tz, + cancelAt, + startDate, + endDate, + uid, + icalOptions, + }; +} + +/** + * Add vendor-specific extensions for better client compatibility + * These are documented workarounds for known client issues + */ +export function addVendorExtensions(event: ICalEvent, options: ICalOptions): void { + // Microsoft Outlook specific + // Outlook may need explicit busy status for cancelled events + if (options.isCancelled) { + event.x("X-MICROSOFT-CDO-BUSYSTATUS", "FREE"); + } else { + event.x("X-MICROSOFT-CDO-BUSYSTATUS", "BUSY"); + } + + // Apple Calendar specific + // Apple handles cancellations better with this hint + if (options.isCancelled) { + event.x("X-APPLE-TRAVEL-ADVISORY-BEHAVIOR", "AUTOMATIC"); + } + + // Google Calendar hint for refresh interval + // Non-standard but may help with Google's slow refresh + event.x("X-GOOGLE-REFRESH-INTERVAL", "PT1H"); // 1 hour hint +} diff --git a/packages/app/src/views/Home.ts b/packages/app/src/views/Home.ts index c8648094..27042f02 100644 --- a/packages/app/src/views/Home.ts +++ b/packages/app/src/views/Home.ts @@ -38,6 +38,7 @@ export const Home = ({ title = "Title", config, client }: HomeProps) => {
  • lit-ssr-demo
  • +
  • iCalendar Demo - Generate dynamic iCal feeds
  • ${sectionApi} diff --git a/packages/e2e-tests/features/ical-api.feature b/packages/e2e-tests/features/ical-api.feature new file mode 100644 index 00000000..4d9afe5f --- /dev/null +++ b/packages/e2e-tests/features/ical-api.feature @@ -0,0 +1,86 @@ +Feature: iCalendar API + As a calendar client + I want to receive valid iCalendar data + So that I can properly display events + + Scenario: Valid iCalendar response + When I request the iCal API with parameters: + | parameter | value | + | title | API Test Event | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + Then I should receive a 200 response + And the Content-Type should be "text/calendar; charset=utf-8" + And the response should contain "BEGIN:VCALENDAR" + And the response should contain "BEGIN:VEVENT" + And the response should contain "SUMMARY:API Test Event" + + Scenario: Error handling for missing title + When I request the iCal API with parameters: + | parameter | value | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + Then I should receive a 400 response + And the response should contain "Missing required parameter: title" + + Scenario: Error handling for invalid duration + When I request the iCal API with parameters: + | parameter | value | + | title | Invalid Duration Test | + | startAt | 2025-08-01T14:30:00Z | + | duration | invalid | + Then I should receive a 400 response + And the response should contain "Invalid duration" + + Scenario: Cancelled event response + When I request the iCal API with parameters: + | parameter | value | + | title | Cancelled Event | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + | cancelAt | 2025-01-01T00:00:00Z | + Then I should receive a 200 response + And the response should contain "METHOD:CANCEL" + And the response should contain "STATUS:CANCELLED" + And the response should contain "SEQUENCE:1" + + Scenario: Event with timezone + When I request the iCal API with parameters: + | parameter | value | + | title | Timezone Event | + | startAt | 2025-08-01T14:30:00 | + | duration | 90 | + | tz | Europe/Zurich | + Then I should receive a 200 response + And the response should contain "TIMEZONE-ID:Europe/Zurich" + And the response should contain "X-WR-TIMEZONE:Europe/Zurich" + + Scenario: RFC 5545 compliance + When I request the iCal API with parameters: + | parameter | value | + | title | RFC Test Event | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + Then the iCalendar data should be RFC 5545 compliant + And the calendar should have version "2.0" + And the calendar should have a PRODID property + And the event should have a consistent UID + + Scenario: Vendor extensions for confirmed event + When I request the iCal API with parameters: + | parameter | value | + | title | Vendor Test Event | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + Then the event should have vendor extension "x-microsoft-cdo-busystatus" with value "BUSY" + And the event should have vendor extension "x-google-refresh-interval" with value "PT1H" + + Scenario: Vendor extensions for cancelled event + When I request the iCal API with parameters: + | parameter | value | + | title | Cancelled Vendor Event | + | startAt | 2025-08-01T14:30:00Z | + | duration | 60 | + | cancelAt | 2025-01-01T00:00:00Z | + Then the event should have vendor extension "x-microsoft-cdo-busystatus" with value "FREE" + And the event should have vendor extension "x-apple-travel-advisory-behavior" with value "AUTOMATIC" \ No newline at end of file diff --git a/packages/e2e-tests/features/ical.feature b/packages/e2e-tests/features/ical.feature new file mode 100644 index 00000000..9e7c7c61 --- /dev/null +++ b/packages/e2e-tests/features/ical.feature @@ -0,0 +1,68 @@ +Feature: iCalendar Feed + As a user + I want to generate iCalendar feeds + So that I can test calendar client compatibility + + Background: + Given I am on the iCalendar demo page + + Scenario: Generate basic event feed + When I fill in the event form with: + | field | value | + | title | Team Meeting | + | duration | 90 | + And I click "Generate Feed URL" + Then I should see a feed URL + And I should see a download link + And I should see a subscribe link + + Scenario: Form validation for required fields + When I clear the "title" field + And I click "Generate Feed URL" + Then the form should not submit + And I should see a validation error + + Scenario: Timezone selection + When I fill in the event form with: + | field | value | + | title | Global Meeting | + | duration | 60 | + And I select "Europe/Zurich" from the timezone dropdown + And I click "Generate Feed URL" + Then the feed URL should contain "tz=Europe%2FZurich" + + Scenario: Event cancellation + When I fill in the event form with: + | field | value | + | title | Cancelled Meeting | + | duration | 30 | + And I set the cancelAt date to yesterday + And I click "Generate Feed URL" + Then the feed URL should contain "cancelAt=" + + Scenario: Copy feed URL to clipboard + When I fill in the event form with: + | field | value | + | title | Copy Test | + | duration | 60 | + And I click "Generate Feed URL" + And I click the "Copy" button + Then the button should show "Copied!" + And the button should revert to "Copy" after 2 seconds + + Scenario: Download iCalendar file + When I fill in the event form with: + | field | value | + | title | Download Test | + | duration | 45 | + And I click "Generate Feed URL" + And I click the download link + Then an .ics file should be downloaded + + Scenario: Subscribe with webcal protocol + When I fill in the event form with: + | field | value | + | title | Subscribe Test | + | duration | 60 | + And I click "Generate Feed URL" + Then the subscribe link should use "webcal://" protocol \ No newline at end of file diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index e71e3f20..16713ff2 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -5,6 +5,8 @@ "devDependencies": { "@playwright/test": "^1.49.0", "@types/node": "^22.10.1", + "ical.js": "^2.2.0", + "node-fetch": "^3.3.2", "playwright-bdd": "^8.3.1", "tsx": "^4.20.3" }, @@ -12,7 +14,9 @@ "test": "playwright test", "test:debug": "playwright test --debug", "test:ui": "playwright test --ui", + "test:compact": "COMPACT=1 playwright test", "e2e": "playwright test", + "e2e:compact": "COMPACT=1 playwright test", "generate": "bddgen" } -} \ No newline at end of file +} diff --git a/packages/e2e-tests/pages/ical-demo-page.ts b/packages/e2e-tests/pages/ical-demo-page.ts new file mode 100644 index 00000000..1cdc72b9 --- /dev/null +++ b/packages/e2e-tests/pages/ical-demo-page.ts @@ -0,0 +1,83 @@ +import { Page } from "@playwright/test"; + +export class ICalDemoPage { + // eslint-disable-next-line no-unused-vars + constructor(private page: Page) { + // Store page instance for all methods + } + + async navigate() { + await this.page.goto("/demos/ical"); + } + + async fillField(fieldName: string, value: string) { + const fieldMap: Record = { + title: "#title", + duration: "#duration", + }; + + const selector = fieldMap[fieldName.toLowerCase()]; + if (!selector) { + throw new Error(`Unknown field: ${fieldName}`); + } + + await this.page.fill(selector, value); + } + + async clearField(fieldName: string) { + const fieldMap: Record = { + title: "#title", + duration: "#duration", + }; + + const selector = fieldMap[fieldName.toLowerCase()]; + if (!selector) { + throw new Error(`Unknown field: ${fieldName}`); + } + + await this.page.fill(selector, ""); + } + + async selectTimezone(timezone: string) { + await this.page.selectOption("#tz", timezone); + } + + async setCancelAt(date: Date) { + // Convert to datetime-local format: YYYY-MM-DDTHH:mm + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + const dateTimeLocal = `${year}-${month}-${day}T${hours}:${minutes}`; + await this.page.fill("#cancelAt", dateTimeLocal); + } + + async clickButton(buttonText: string) { + await this.page.click(`button:has-text("${buttonText}")`); + } + + async clickDownloadLink() { + await this.page.click("#download-link"); + } + + async getFeedUrl(): Promise { + await this.page.waitForSelector("#feed-url", { state: "visible" }); + return await this.page.inputValue("#feed-url"); + } + + async getDownloadLink(): Promise { + return (await this.page.getAttribute("#download-link", "href")) || ""; + } + + async getSubscribeLink(): Promise { + return (await this.page.getAttribute("#subscribe-link", "href")) || ""; + } + + async isResultVisible(): Promise { + const resultDiv = this.page.locator("#result"); + const classes = (await resultDiv.getAttribute("class")) || ""; + return !classes.includes("d-none"); + } +} diff --git a/packages/e2e-tests/playwright.config.ts b/packages/e2e-tests/playwright.config.ts index 77ff3b1b..f3af25cb 100644 --- a/packages/e2e-tests/playwright.config.ts +++ b/packages/e2e-tests/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: process.env.COMPACT ? "dot" : "html", use: { baseURL: `http://localhost:${PORT}`, trace: "on-first-retry", diff --git a/packages/e2e-tests/steps/ical-api-steps.ts b/packages/e2e-tests/steps/ical-api-steps.ts new file mode 100644 index 00000000..7acc5de3 --- /dev/null +++ b/packages/e2e-tests/steps/ical-api-steps.ts @@ -0,0 +1,118 @@ +import { expect } from "@playwright/test"; +import { createBdd, DataTable } from "playwright-bdd"; +import fetch from "node-fetch"; +import { + parseICalendarData, + validateRFC5545Compliance, + validateVendorExtensions, + ParsedCalendar, +} from "../utils/ical-parser.ts"; + +const { When, Then } = createBdd(); + +interface TestContext { + response?: Response; + responseText?: string; + parsedCalendar?: ParsedCalendar; +} + +// Store test context per scenario +const testContext: TestContext = {}; + +When("I request the iCal API with parameters:", async ({ page: _page, context: _context }, dataTable: DataTable) => { + const params = new URLSearchParams(); + const data = dataTable.hashes(); + + for (const row of data) { + if (row.parameter && row.value) { + params.append(row.parameter, row.value); + } + } + + // Get base URL from Playwright config through context + const baseUrl = "http://localhost:9999"; + const apiUrl = `${baseUrl}/api/ical/events.ics?${params.toString()}`; + + testContext.response = (await fetch(apiUrl)) as unknown as Response; + testContext.responseText = await testContext.response!.text(); + + // Parse if successful + if (testContext.response!.ok) { + try { + testContext.parsedCalendar = parseICalendarData(testContext.responseText); + } catch { + // Invalid iCal data + } + } +}); + +Then("I should receive a {int} response", async ({ page: _page }, statusCode: number) => { + expect(testContext.response?.status).toBe(statusCode); +}); + +Then("the Content-Type should be {string}", async ({ page: _page }, contentType: string) => { + const actualContentType = testContext.response?.headers.get("content-type"); + expect(actualContentType).toBe(contentType); +}); + +Then("the response should contain {string}", async ({ page: _page }, expectedContent: string) => { + expect(testContext.responseText).toContain(expectedContent); +}); + +Then("the iCalendar data should be RFC 5545 compliant", async ({ page: _page }) => { + expect(testContext.parsedCalendar).toBeTruthy(); + + const validation = validateRFC5545Compliance(testContext.parsedCalendar!); + if (!validation.isValid) { + throw new Error(`RFC 5545 validation failed:\n${validation.errors.join("\n")}`); + } + + expect(validation.isValid).toBe(true); +}); + +Then("the calendar should have version {string}", async ({ page: _page }, version: string) => { + expect(testContext.parsedCalendar?.version).toBe(version); +}); + +Then("the calendar should have a PRODID property", async ({ page: _page }) => { + expect(testContext.parsedCalendar?.prodid).toBeTruthy(); + expect(testContext.parsedCalendar?.prodid).toContain("Hello World Web"); +}); + +Then("the event should have a consistent UID", async ({ page: _page }) => { + expect(testContext.parsedCalendar?.events).toHaveLength(1); + const event = testContext.parsedCalendar!.events[0]; + if (!event) { + throw new Error("No event found in parsed calendar"); + } + + expect(event.uid).toBeTruthy(); + expect(event.uid).toMatch(/@hello-world-web\.local$/); +}); + +Then( + "the event should have vendor extension {string} with value {string}", + async ({ page: _page }, extensionName: string, expectedValue: string) => { + expect(testContext.parsedCalendar?.events).toHaveLength(1); + const event = testContext.parsedCalendar!.events[0]; + if (!event) { + throw new Error("No event found in parsed calendar"); + } + + const hasExtension = validateVendorExtensions(event, { + [extensionName]: expectedValue, + }); + + expect(hasExtension).toBe(true); + }, +); + +// Override Response type for test context +type Response = { + status: number; + headers: { + get(_name: string): string | null; + }; + text(): Promise; + ok: boolean; +}; diff --git a/packages/e2e-tests/steps/ical-steps.ts b/packages/e2e-tests/steps/ical-steps.ts new file mode 100644 index 00000000..d2c1bb2a --- /dev/null +++ b/packages/e2e-tests/steps/ical-steps.ts @@ -0,0 +1,109 @@ +import { expect } from "@playwright/test"; +import { createBdd, DataTable } from "playwright-bdd"; +import { ICalDemoPage } from "../pages/ical-demo-page.ts"; + +const { Given, When, Then } = createBdd(); +let iCalDemoPage: ICalDemoPage; + +Given("I am on the iCalendar demo page", async ({ page }) => { + iCalDemoPage = new ICalDemoPage(page); + await iCalDemoPage.navigate(); +}); + +When("I fill in the event form with:", async ({ page: _page }, dataTable: DataTable) => { + const data = dataTable.hashes(); + for (const row of data) { + if (row.field && row.value) { + await iCalDemoPage.fillField(row.field, row.value); + } + } +}); + +When("I click {string}", async ({ page: _page }, buttonText: string) => { + await iCalDemoPage.clickButton(buttonText); +}); + +When("I clear the {string} field", async ({ page: _page }, fieldName: string) => { + await iCalDemoPage.clearField(fieldName); +}); + +When("I select {string} from the timezone dropdown", async ({ page: _page }, timezone: string) => { + await iCalDemoPage.selectTimezone(timezone); +}); + +When("I set the cancelAt date to yesterday", async ({ page: _page }) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + await iCalDemoPage.setCancelAt(yesterday); +}); + +When("I click the {string} button", async ({ page: _page }, buttonText: string) => { + await iCalDemoPage.clickButton(buttonText); +}); + +When("I click the download link", async ({ page: _page }) => { + await iCalDemoPage.clickDownloadLink(); +}); + +Then("I should see a feed URL", async ({ page: _page }) => { + const feedUrl = await iCalDemoPage.getFeedUrl(); + expect(feedUrl).toBeTruthy(); + expect(feedUrl).toContain("/api/ical/events.ics"); +}); + +Then("I should see a download link", async ({ page: _page }) => { + const downloadLink = await iCalDemoPage.getDownloadLink(); + expect(downloadLink).toBeTruthy(); +}); + +Then("I should see a subscribe link", async ({ page: _page }) => { + const subscribeLink = await iCalDemoPage.getSubscribeLink(); + expect(subscribeLink).toBeTruthy(); +}); + +Then("the form should not submit", async ({ page: _page }) => { + // The result section should remain hidden + const isResultVisible = await iCalDemoPage.isResultVisible(); + expect(isResultVisible).toBe(false); +}); + +Then("I should see a validation error", async ({ page }) => { + // HTML5 validation will show browser-specific error + const titleField = page.locator("#title"); + const validationMessage = await titleField.evaluate((el: HTMLInputElement) => el.validationMessage); + expect(validationMessage).toBeTruthy(); +}); + +Then("the feed URL should contain {string}", async ({ page: _page }, expectedContent: string) => { + const feedUrl = await iCalDemoPage.getFeedUrl(); + expect(feedUrl).toContain(expectedContent); +}); + +Then("the button should show {string}", async ({ page }, expectedText: string) => { + const copyButton = page.locator("#copy-btn"); + await expect(copyButton).toHaveText(expectedText); +}); + +Then( + "the button should revert to {string} after {int} seconds", + async ({ page }, originalText: string, seconds: number) => { + const copyButton = page.locator("#copy-btn"); + await page.waitForTimeout(seconds * 1000 + 100); // Add small buffer + await expect(copyButton).toHaveText(originalText); + }, +); + +Then("an .ics file should be downloaded", async ({ page }) => { + // Set up download promise before clicking + const downloadPromise = page.waitForEvent("download"); + await iCalDemoPage.clickDownloadLink(); + const download = await downloadPromise; + + // Verify file extension + expect(download.suggestedFilename()).toBe("events.ics"); +}); + +Then("the subscribe link should use {string} protocol", async ({ page: _page }, protocol: string) => { + const subscribeLink = await iCalDemoPage.getSubscribeLink(); + expect(subscribeLink).toMatch(new RegExp(`^${protocol}`)); +}); diff --git a/packages/e2e-tests/utils/ical-parser.ts b/packages/e2e-tests/utils/ical-parser.ts new file mode 100644 index 00000000..5aa06349 --- /dev/null +++ b/packages/e2e-tests/utils/ical-parser.ts @@ -0,0 +1,225 @@ +/** + * iCalendar parsing utilities for E2E tests + * Provides wrapper functions around ical.js for testing iCal feeds + */ + +import ICAL from "ical.js"; +import fetch from "node-fetch"; + +export interface ParsedEvent { + uid: string; + summary: string; + dtstart: Date; + dtend: Date; + status: string; + sequence: number; + timezone?: string; + method?: string; + vendorExtensions: Record; +} + +export interface ParsedCalendar { + version: string; + prodid: string; + method?: string; + events: ParsedEvent[]; + hasTimezone: boolean; + timezoneId?: string; +} + +/** + * Fetches and parses an iCalendar feed from a URL + */ +export async function fetchAndParseICalFeed(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch iCal feed: ${response.status} ${response.statusText}`); + } + + const icalData = await response.text(); + return parseICalendarData(icalData); +} + +/** + * Parses raw iCalendar data into structured format + */ +export function parseICalendarData(icalData: string): ParsedCalendar { + const jcalData = ICAL.parse(icalData); + const comp = new ICAL.Component(jcalData); + + // Extract calendar properties + const version = comp.getFirstPropertyValue("version"); + const prodid = comp.getFirstPropertyValue("prodid"); + const method = comp.getFirstPropertyValue("method"); + + // Check for VTIMEZONE + const timezoneComps = comp.getAllSubcomponents("vtimezone"); + const hasTimezone = timezoneComps.length > 0; + const timezoneId = hasTimezone && timezoneComps[0] ? timezoneComps[0].getFirstPropertyValue("tzid") : undefined; + + // Parse events + const events: ParsedEvent[] = []; + const veventComps = comp.getAllSubcomponents("vevent"); + + for (const veventComp of veventComps) { + const event = new ICAL.Event(veventComp); + + // Extract vendor extensions (X- properties) + const vendorExtensions: Record = {}; + const properties = veventComp.getAllProperties(); + + for (const prop of properties) { + if (prop.name.startsWith("x-")) { + vendorExtensions[prop.name] = String(prop.getFirstValue()); + } + } + + events.push({ + uid: event.uid, + summary: event.summary, + dtstart: event.startDate.toJSDate(), + dtend: event.endDate.toJSDate(), + status: String(veventComp.getFirstPropertyValue("status") || "CONFIRMED"), + sequence: Number(veventComp.getFirstPropertyValue("sequence") || 0), + timezone: event.startDate.zone?.tzid, + vendorExtensions, + }); + } + + return { + version: String(version), + prodid: String(prodid), + method: method ? String(method) : undefined, + events, + hasTimezone, + timezoneId: timezoneId ? String(timezoneId) : undefined, + }; +} + +/** + * Validates basic RFC 5545 compliance + */ +export function validateRFC5545Compliance(calendar: ParsedCalendar): { + isValid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + // Check required calendar properties + if (calendar.version !== "2.0") { + errors.push(`Invalid VERSION: expected "2.0", got "${calendar.version}"`); + } + + if (!calendar.prodid) { + errors.push("Missing required PRODID property"); + } + + // Check events + for (const event of calendar.events) { + if (!event.uid) { + errors.push("Event missing required UID property"); + } + + if (!event.summary) { + errors.push("Event missing required SUMMARY property"); + } + + if (!event.dtstart) { + errors.push("Event missing required DTSTART property"); + } + + if (!event.dtend) { + errors.push("Event missing required DTEND property"); + } + + // Validate sequence number + if (event.sequence < 0) { + errors.push(`Invalid SEQUENCE: must be non-negative, got ${event.sequence}`); + } + + // Validate status values + const validStatuses = ["TENTATIVE", "CONFIRMED", "CANCELLED"]; + if (event.status && !validStatuses.includes(event.status)) { + errors.push(`Invalid STATUS: "${event.status}"`); + } + } + + // Check timezone requirements + for (const event of calendar.events) { + if (event.timezone && event.timezone !== "UTC" && !calendar.hasTimezone) { + errors.push(`Event uses timezone "${event.timezone}" but no VTIMEZONE component found`); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +/** + * Checks for expected vendor extensions + */ +export function validateVendorExtensions( + event: ParsedEvent, + expectedExtensions: Record, +): boolean { + for (const [key, expectedValue] of Object.entries(expectedExtensions)) { + const actualValue = event.vendorExtensions[key]; + + if (!actualValue) { + return false; + } + + if (expectedValue instanceof RegExp) { + if (!expectedValue.test(actualValue)) { + return false; + } + } else { + if (actualValue !== expectedValue) { + return false; + } + } + } + + return true; +} + +/** + * Generates test event parameters + */ +export function generateTestEventParams( + overrides?: Partial<{ + title: string; + startAt: string; + duration: number; + tz?: string; + cancelAt?: string; + }>, +): URLSearchParams { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(14, 30, 0, 0); + + const defaults = { + title: "Test Event", + startAt: tomorrow.toISOString(), + duration: 60, + ...overrides, + }; + + const params = new URLSearchParams(); + params.append("title", defaults.title); + params.append("startAt", defaults.startAt); + params.append("duration", defaults.duration.toString()); + + if (defaults.tz) { + params.append("tz", defaults.tz); + } + + if (defaults.cancelAt) { + params.append("cancelAt", defaults.cancelAt); + } + + return params; +} diff --git a/packages/e2e-tests/utils/validation.ts b/packages/e2e-tests/utils/validation.ts index db1bd166..527315ba 100644 --- a/packages/e2e-tests/utils/validation.ts +++ b/packages/e2e-tests/utils/validation.ts @@ -19,7 +19,7 @@ export function isValidSemverWithNonZeroMajor(version: string): boolean { const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); if (!match) return false; - const major = parseInt(match[1], 10); + const major = parseInt(match[1]!, 10); return major > 0; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13992a3c..8dc38316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: express: specifier: ~5.1.0 version: 5.1.0 + ical-generator: + specifier: ^9.0.0 + version: 9.0.0(@types/node@22.13.15)(luxon@3.6.1) lit-html: specifier: ^3.1.4 version: 3.2.1 @@ -96,6 +99,9 @@ importers: npm-run-all2: specifier: ^7.0.0 version: 7.0.2 + rev-hash: + specifier: ^4.1.0 + version: 4.1.0 devDependencies: '@types/cookie-parser': specifier: ^1.4.7 @@ -136,6 +142,12 @@ importers: '@types/node': specifier: ^22.10.1 version: 22.13.15 + ical.js: + specifier: ^2.2.0 + version: 2.2.0 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 playwright-bdd: specifier: ^8.3.1 version: 8.3.1(@playwright/test@1.54.1) @@ -1391,6 +1403,42 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + ical-generator@9.0.0: + resolution: {integrity: sha512-Ohpcw5fN8LhrnKyEQwISP7/EYfoafy0Nb1ISIMET0GWdsHrWpiqIdLocSi5vrYAdjF8QKy1GkaSk/n+GfzR/8A==} + engines: {node: 20 || >=22.0.0} + peerDependencies: + '@touch4it/ical-timezones': '>=1.6.0' + '@types/luxon': '>= 1.26.0' + '@types/mocha': '>= 8.2.1' + '@types/node': '*' + dayjs: '>= 1.10.0' + luxon: '>= 1.26.0' + moment: '>= 2.29.0' + moment-timezone: '>= 0.5.33' + rrule: '>= 2.6.8' + peerDependenciesMeta: + '@touch4it/ical-timezones': + optional: true + '@types/luxon': + optional: true + '@types/mocha': + optional: true + '@types/node': + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-timezone: + optional: true + rrule: + optional: true + + ical.js@2.2.0: + resolution: {integrity: sha512-P8gjWkTEd5M/SEEvBVPPO/KC+V+HRNRZh3xfCDTVWmUTEfVbL8JaK5GTWS2MJ55aLMhfXhbh7kYzd0nrBARjsA==} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1875,6 +1923,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rev-hash@4.1.0: + resolution: {integrity: sha512-e0EGnaveLY2IYpYwHNdh43WZ2M84KgW3Z/T4F6+Z/BlZI/T1ZbxTWj36xgYgUPOieGXYo2q225jTeUXn+LWYjw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -3404,6 +3456,13 @@ snapshots: human-signals@2.1.0: {} + ical-generator@9.0.0(@types/node@22.13.15)(luxon@3.6.1): + optionalDependencies: + '@types/node': 22.13.15 + luxon: 3.6.1 + + ical.js@2.2.0: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -3850,6 +3909,8 @@ snapshots: reusify@1.1.0: {} + rev-hash@4.1.0: {} + rimraf@6.0.1: dependencies: glob: 11.0.0