From d00926cd364f4158c0153ce75e7d46bdd2b588a0 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Wed, 18 Mar 2026 21:04:31 -0400 Subject: [PATCH 01/23] chore(copilot): add GitHub Copilot skills from awesome-copilot Add three skills to .github/skills/: - context-map: generate a file context map before making changes - conventional-commit: generate standardized commit messages - github-issues: create, update, and manage GitHub issues via MCP tools, with reference docs for search, sub-issues, dependencies, issue types, projects V2, issue fields, and images Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/context-map/SKILL.md | 52 ++++ .github/skills/conventional-commit/SKILL.md | 72 +++++ .github/skills/github-issues/SKILL.md | 201 +++++++++++++ .../github-issues/references/dependencies.md | 71 +++++ .../skills/github-issues/references/images.md | 116 ++++++++ .../github-issues/references/issue-fields.md | 191 ++++++++++++ .../github-issues/references/issue-types.md | 72 +++++ .../github-issues/references/projects.md | 272 ++++++++++++++++++ .../skills/github-issues/references/search.md | 231 +++++++++++++++ .../github-issues/references/sub-issues.md | 137 +++++++++ .../github-issues/references/templates.md | 90 ++++++ 11 files changed, 1505 insertions(+) create mode 100644 .github/skills/context-map/SKILL.md create mode 100644 .github/skills/conventional-commit/SKILL.md create mode 100644 .github/skills/github-issues/SKILL.md create mode 100644 .github/skills/github-issues/references/dependencies.md create mode 100644 .github/skills/github-issues/references/images.md create mode 100644 .github/skills/github-issues/references/issue-fields.md create mode 100644 .github/skills/github-issues/references/issue-types.md create mode 100644 .github/skills/github-issues/references/projects.md create mode 100644 .github/skills/github-issues/references/search.md create mode 100644 .github/skills/github-issues/references/sub-issues.md create mode 100644 .github/skills/github-issues/references/templates.md diff --git a/.github/skills/context-map/SKILL.md b/.github/skills/context-map/SKILL.md new file mode 100644 index 0000000..45b5f07 --- /dev/null +++ b/.github/skills/context-map/SKILL.md @@ -0,0 +1,52 @@ +--- +name: context-map +description: 'Generate a map of all files relevant to a task before making changes' +--- + +# Context Map + +Before implementing any changes, analyze the codebase and create a context map. + +## Task + +{{task_description}} + +## Instructions + +1. Search the codebase for files related to this task +2. Identify direct dependencies (imports/exports) +3. Find related tests +4. Look for similar patterns in existing code + +## Output Format + +```markdown +## Context Map + +### Files to Modify +| File | Purpose | Changes Needed | +|------|---------|----------------| +| path/to/file | description | what changes | + +### Dependencies (may need updates) +| File | Relationship | +|------|--------------| +| path/to/dep | imports X from modified file | + +### Test Files +| Test | Coverage | +|------|----------| +| path/to/test | tests affected functionality | + +### Reference Patterns +| File | Pattern | +|------|---------| +| path/to/similar | example to follow | + +### Risk Assessment +- [ ] Breaking changes to public API +- [ ] Database migrations needed +- [ ] Configuration changes required +``` + +Do not proceed with implementation until this map is reviewed. \ No newline at end of file diff --git a/.github/skills/conventional-commit/SKILL.md b/.github/skills/conventional-commit/SKILL.md new file mode 100644 index 0000000..3465092 --- /dev/null +++ b/.github/skills/conventional-commit/SKILL.md @@ -0,0 +1,72 @@ +--- +name: conventional-commit +description: 'Prompt and workflow for generating conventional commit messages using a structured XML format. Guides users to create standardized, descriptive commit messages in line with the Conventional Commits specification, including instructions, examples, and validation.' +--- + +### Instructions + +```xml + This file contains a prompt template for generating conventional commit messages. It provides instructions, examples, and formatting guidelines to help users write standardized, descriptive commit messages in accordance with the Conventional Commits specification. +``` + +### Workflow + +**Follow these steps:** + +1. Run `git status` to review changed files. +2. Run `git diff` or `git diff --cached` to inspect changes. +3. Stage your changes with `git add `. +4. Construct your commit message using the following XML structure. +5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed): + +```bash +git commit -m "type(scope): description" +``` + +6. Just execute this prompt and Copilot will handle the commit for you in the terminal. + +### Commit Message Structure + +```xml + + feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert + () + A short, imperative summary of the change + (optional: more detailed explanation) +
(optional: e.g. BREAKING CHANGE: details, or issue references)
+
+``` + +### Examples + +```xml + + feat(parser): add ability to parse arrays + fix(ui): correct button alignment + docs: update README with usage instructions + refactor: improve performance of data processing + chore: update dependencies + feat!: send email on registration (BREAKING CHANGE: email service required) + +``` + +### Validation + +```xml + + Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification + Optional, but recommended for clarity. + Required. Use the imperative mood (e.g., "add", not "added"). + Optional. Use for additional context. +
Use for breaking changes or issue references.
+
+``` + +### Final Step + +```xml + + git commit -m "type(scope): description" + Replace with your constructed message. Include body and footer if needed. + +``` \ No newline at end of file diff --git a/.github/skills/github-issues/SKILL.md b/.github/skills/github-issues/SKILL.md new file mode 100644 index 0000000..4619bac --- /dev/null +++ b/.github/skills/github-issues/SKILL.md @@ -0,0 +1,201 @@ +--- +name: github-issues +description: 'Create, update, and manage GitHub issues using MCP tools. Use this skill when users want to create bug reports, feature requests, or task issues, update existing issues, add labels/assignees/milestones, set issue fields (dates, priority, custom fields), set issue types, manage issue workflows, link issues, add dependencies, or track blocked-by/blocking relationships. Triggers on requests like "create an issue", "file a bug", "request a feature", "update issue X", "set the priority", "set the start date", "link issues", "add dependency", "blocked by", "blocking", or any GitHub issue management task.' +--- + +# GitHub Issues + +Manage GitHub issues using the `@modelcontextprotocol/server-github` MCP server. + +## Available Tools + +### MCP Tools (read operations) + +| Tool | Purpose | +|------|---------| +| `mcp__github__issue_read` | Read issue details, sub-issues, comments, labels (methods: get, get_comments, get_sub_issues, get_labels) | +| `mcp__github__list_issues` | List and filter repository issues by state, labels, date | +| `mcp__github__search_issues` | Search issues across repos using GitHub search syntax | +| `mcp__github__projects_list` | List projects, project fields, project items, status updates | +| `mcp__github__projects_get` | Get details of a project, field, item, or status update | +| `mcp__github__projects_write` | Add/update/delete project items, create status updates | + +### CLI / REST API (write operations) + +The MCP server does not currently support creating, updating, or commenting on issues. Use `gh api` for these operations. + +| Operation | Command | +|-----------|---------| +| Create issue | `gh api repos/{owner}/{repo}/issues -X POST -f title=... -f body=...` | +| Update issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f title=... -f state=...` | +| Add comment | `gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body=...` | +| Close issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f state=closed` | +| Set issue type | Include `-f type=Bug` in the create call (REST API only, not supported by `gh issue create` CLI) | + +**Note:** `gh issue create` works for basic issue creation but does **not** support the `--type` flag. Use `gh api` when you need to set issue types. + +## Workflow + +1. **Determine action**: Create, update, or query? +2. **Gather context**: Get repo info, existing labels, milestones if needed +3. **Structure content**: Use appropriate template from [references/templates.md](references/templates.md) +4. **Execute**: Use MCP tools for reads, `gh api` for writes +5. **Confirm**: Report the issue URL to user + +## Creating Issues + +Use `gh api` to create issues. This supports all parameters including issue types. + +```bash +gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Issue title" \ + -f body="Issue body in markdown" \ + -f type="Bug" \ + --jq '{number, html_url}' +``` + +### Optional Parameters + +Add any of these flags to the `gh api` call: + +``` +-f type="Bug" # Issue type (Bug, Feature, Task, Epic, etc.) +-f labels[]="bug" # Labels (repeat for multiple) +-f assignees[]="username" # Assignees (repeat for multiple) +-f milestone=1 # Milestone number +``` + +**Issue types** are organization-level metadata. To discover available types, use: +```bash +gh api graphql -f query='{ organization(login: "ORG") { issueTypes(first: 10) { nodes { name } } } }' --jq '.data.organization.issueTypes.nodes[].name' +``` + +**Prefer issue types over labels for categorization.** When issue types are available (e.g., Bug, Feature, Task), use the `type` parameter instead of applying equivalent labels like `bug` or `enhancement`. Issue types are the canonical way to categorize issues on GitHub. Only fall back to labels when the org has no issue types configured. + +### Title Guidelines + +- Be specific and actionable +- Keep under 72 characters +- When issue types are set, don't add redundant prefixes like `[Bug]` +- Examples: + - `Login fails with SSO enabled` (with type=Bug) + - `Add dark mode support` (with type=Feature) + - `Add unit tests for auth module` (with type=Task) + +### Body Structure + +Always use the templates in [references/templates.md](references/templates.md). Choose based on issue type: + +| User Request | Template | +|--------------|----------| +| Bug, error, broken, not working | Bug Report | +| Feature, enhancement, add, new | Feature Request | +| Task, chore, refactor, update | Task | + +## Updating Issues + +Use `gh api` with PATCH: + +```bash +gh api repos/{owner}/{repo}/issues/{number} \ + -X PATCH \ + -f state=closed \ + -f title="Updated title" \ + --jq '{number, html_url}' +``` + +Only include fields you want to change. Available fields: `title`, `body`, `state` (open/closed), `labels`, `assignees`, `milestone`. + +## Examples + +### Example 1: Bug Report + +**User**: "Create a bug issue - the login page crashes when using SSO" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Login page crashes when using SSO" \ + -f type="Bug" \ + -f body="## Description +The login page crashes when users attempt to authenticate using SSO. + +## Steps to Reproduce +1. Navigate to login page +2. Click 'Sign in with SSO' +3. Page crashes + +## Expected Behavior +SSO authentication should complete and redirect to dashboard. + +## Actual Behavior +Page becomes unresponsive and displays error." \ + --jq '{number, html_url}' +``` + +### Example 2: Feature Request + +**User**: "Create a feature request for dark mode with high priority" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Add dark mode support" \ + -f type="Feature" \ + -f labels[]="high-priority" \ + -f body="## Summary +Add dark mode theme option for improved user experience and accessibility. + +## Motivation +- Reduces eye strain in low-light environments +- Increasingly expected by users + +## Proposed Solution +Implement theme toggle with system preference detection. + +## Acceptance Criteria +- [ ] Toggle switch in settings +- [ ] Persists user preference +- [ ] Respects system preference by default" \ + --jq '{number, html_url}' +``` + +## Common Labels + +Use these standard labels when applicable: + +| Label | Use For | +|-------|---------| +| `bug` | Something isn't working | +| `enhancement` | New feature or improvement | +| `documentation` | Documentation updates | +| `good first issue` | Good for newcomers | +| `help wanted` | Extra attention needed | +| `question` | Further information requested | +| `wontfix` | Will not be addressed | +| `duplicate` | Already exists | +| `high-priority` | Urgent issues | + +## Tips + +- Always confirm the repository context before creating issues +- Ask for missing critical information rather than guessing +- Link related issues when known: `Related to #123` +- For updates, fetch current issue first to preserve unchanged fields + +## Extended Capabilities + +The following features require REST or GraphQL APIs beyond the basic MCP tools. Each is documented in its own reference file so the agent only loads the knowledge it needs. + +| Capability | When to use | Reference | +|------------|-------------|-----------| +| Advanced search | Complex queries with boolean logic, date ranges, cross-repo search, issue field filters (`field.name:value`) | [references/search.md](references/search.md) | +| Sub-issues & parent issues | Breaking work into hierarchical tasks | [references/sub-issues.md](references/sub-issues.md) | +| Issue dependencies | Tracking blocked-by / blocking relationships | [references/dependencies.md](references/dependencies.md) | +| Issue types (advanced) | GraphQL operations beyond MCP `list_issue_types` / `type` param | [references/issue-types.md](references/issue-types.md) | +| Projects V2 | Project boards, progress reports, field management | [references/projects.md](references/projects.md) | +| Issue fields | Custom metadata: dates, priority, text, numbers (private preview) | [references/issue-fields.md](references/issue-fields.md) | +| Images in issues | Embedding images in issue bodies and comments via CLI | [references/images.md](references/images.md) | diff --git a/.github/skills/github-issues/references/dependencies.md b/.github/skills/github-issues/references/dependencies.md new file mode 100644 index 0000000..6ad7562 --- /dev/null +++ b/.github/skills/github-issues/references/dependencies.md @@ -0,0 +1,71 @@ +# Issue Dependencies (Blocked By / Blocking) + +Dependencies let you mark that an issue is blocked by another issue. This creates a formal dependency relationship visible in the UI and trackable via API. No MCP tools exist for dependencies; use REST or GraphQL directly. + +## Using REST API + +**List issues blocking this issue:** +``` +GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +``` + +**Add a blocking dependency:** +``` +POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +Body: { "issue_id": 12345 } +``` + +The `issue_id` is the numeric issue **ID** (not the issue number). + +**Remove a blocking dependency:** +``` +DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id} +``` + +## Using GraphQL + +**Read dependencies:** +```graphql +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + blockedBy(first: 10) { nodes { number title state } } + blocking(first: 10) { nodes { number title state } } + issueDependenciesSummary { blockedBy blocking totalBlockedBy totalBlocking } + } + } +} +``` + +**Add a dependency:** +```graphql +mutation { + addBlockedBy(input: { + issueId: "BLOCKED_ISSUE_NODE_ID" + blockingIssueId: "BLOCKING_ISSUE_NODE_ID" + }) { + blockingIssue { number title } + } +} +``` + +**Remove a dependency:** +```graphql +mutation { + removeBlockedBy(input: { + issueId: "BLOCKED_ISSUE_NODE_ID" + blockingIssueId: "BLOCKING_ISSUE_NODE_ID" + }) { + blockingIssue { number title } + } +} +``` + +## Tracked issues (read-only) + +Task-list tracking relationships are available via GraphQL as read-only fields: + +- `trackedIssues(first: N)` - issues tracked in this issue's task list +- `trackedInIssues(first: N)` - issues whose task lists reference this issue + +These are set automatically when issues are referenced in task lists (`- [ ] #123`). There are no mutations to manage them. diff --git a/.github/skills/github-issues/references/images.md b/.github/skills/github-issues/references/images.md new file mode 100644 index 0000000..f6dec63 --- /dev/null +++ b/.github/skills/github-issues/references/images.md @@ -0,0 +1,116 @@ +# Images in Issues and Comments + +How to embed images in GitHub issue bodies and comments programmatically via the CLI. + +## Methods (ranked by reliability) + +### 1. GitHub Contents API (recommended for private repos) + +Push image files to a branch in the same repo, then reference them with a URL that works for authenticated viewers. + +**Step 1: Create a branch** + +```bash +# Get the SHA of the default branch +SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/main --jq '.object.sha') + +# Create a new branch +gh api repos/{owner}/{repo}/git/refs -X POST \ + -f ref="refs/heads/{username}/images" \ + -f sha="$SHA" +``` + +**Step 2: Upload images via Contents API** + +```bash +# Base64-encode the image and upload +BASE64=$(base64 -i /path/to/image.png) + +gh api repos/{owner}/{repo}/contents/docs/images/my-image.png \ + -X PUT \ + -f message="Add image" \ + -f content="$BASE64" \ + -f branch="{username}/images" \ + --jq '.content.path' +``` + +Repeat for each image. The Contents API creates a commit per file. + +**Step 3: Reference in markdown** + +```markdown +![Description](https://github.com/{owner}/{repo}/raw/{username}/images/docs/images/my-image.png) +``` + +> **Important:** Use `github.com/{owner}/{repo}/raw/{branch}/{path}` format, NOT `raw.githubusercontent.com`. The `raw.githubusercontent.com` URLs return 404 for private repos. The `github.com/.../raw/...` format works because the browser sends auth cookies when the viewer is logged in and has repo access. + +**Pros:** Works for any repo the viewer has access to, images live in version control, no expiration. +**Cons:** Creates commits, viewers must be authenticated, images won't render in email notifications or for users without repo access. + +### 2. Gist hosting (public images only) + +Upload images as files in a gist. Only works for images you're comfortable making public. + +```bash +# Create a gist with a placeholder file +gh gist create --public -f description.md <<< "Image hosting gist" + +# Note: gh gist edit does NOT support binary files. +# You must use the API to add binary content to gists. +``` + +> **Limitation:** Gists don't support binary file uploads via the CLI. You'd need to base64-encode and store as text, which won't render as images. Not recommended. + +### 3. Browser upload (most reliable rendering) + +The most reliable way to get permanent image URLs is through the GitHub web UI: + +1. Open the issue/comment in a browser +2. Drag-drop or paste the image into the comment editor +3. GitHub generates a permanent `https://github.com/user-attachments/assets/{UUID}` URL +4. These URLs work for anyone, even without repo access, and render in email notifications + +> **Why the API can't do this:** GitHub's `upload/policies/assets` endpoint requires a browser session (CSRF token + cookies). It returns an HTML error page when called with API tokens. There is no public API for generating `user-attachments` URLs. + +## Taking screenshots programmatically + +Use `puppeteer-core` with local Chrome to screenshot HTML mockups: + +```javascript +const puppeteer = require('puppeteer-core'); + +const browser = await puppeteer.launch({ + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 900, height: 600, deviceScaleFactor: 2 } +}); + +const page = await browser.newPage(); +await page.setContent(htmlString); + +// Screenshot specific elements +const elements = await page.$$('.section'); +for (let i = 0; i < elements.length; i++) { + await elements[i].screenshot({ path: `mockup-${i + 1}.png` }); +} + +await browser.close(); +``` + +> **Note:** MCP Playwright may not connect to localhost due to network isolation. Use puppeteer-core with a local Chrome installation instead. + +## Quick reference + +| Method | Private repos | Permanent | No auth needed | API-only | +|--------|:---:|:---:|:---:|:---:| +| Contents API + `github.com/raw/` | ✅ | ✅ | ❌ | ✅ | +| Browser drag-drop (`user-attachments`) | ✅ | ✅ | ✅ | ❌ | +| `raw.githubusercontent.com` | ❌ (404) | ✅ | ❌ | ✅ | +| Gist | Public only | ✅ | ✅ | ❌ (no binary) | + +## Common pitfalls + +- **`raw.githubusercontent.com` returns 404 for private repos** even with a valid token in the URL. GitHub's CDN does not pass auth headers through. +- **API download URLs are temporary.** URLs returned by `gh api repos/.../contents/...` with `download_url` include a token that expires. +- **`upload/policies/assets` requires a browser session.** Do not attempt to call this endpoint from the CLI. +- **Base64 encoding for large files** can hit API payload limits. The Contents API has a ~100MB file size limit but practical limits are lower for base64-encoded payloads. +- **Email notifications** will not render images that require authentication. If email readability matters, use the browser upload method. diff --git a/.github/skills/github-issues/references/issue-fields.md b/.github/skills/github-issues/references/issue-fields.md new file mode 100644 index 0000000..4ab668c --- /dev/null +++ b/.github/skills/github-issues/references/issue-fields.md @@ -0,0 +1,191 @@ +# Issue Fields (GraphQL, Private Preview) + +> **Private preview:** Issue fields are currently in private preview. Request access at https://github.com/orgs/community/discussions/175366 + +Issue fields are custom metadata (dates, text, numbers, single-select) defined at the organization level and set per-issue. They are separate from labels, milestones, and assignees. Common examples: Start Date, Target Date, Priority, Impact, Effort. + +**Important:** All issue field queries and mutations require the `GraphQL-Features: issue_fields` HTTP header. Without it, the fields are not visible in the schema. + +**Prefer issue fields over project fields.** When you need to set metadata like dates, priority, or status on an issue, use issue fields (which live on the issue itself) rather than project fields (which live on a project item). Issue fields travel with the issue across projects and views, while project fields are scoped to a single project. Only use project fields when issue fields are not available or when the field is project-specific (e.g., sprint iterations). + +## Discovering available fields + +Fields are defined at the org level. List them before trying to set values: + +```graphql +# Header: GraphQL-Features: issue_fields +{ + organization(login: "OWNER") { + issueFields(first: 30) { + nodes { + __typename + ... on IssueFieldDate { id name } + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldSingleSelect { id name options { id name color } } + } + } + } +} +``` + +Field types: `IssueFieldDate`, `IssueFieldText`, `IssueFieldNumber`, `IssueFieldSingleSelect`. + +For single-select fields, you need the option `id` (not the name) to set values. + +## Reading field values on an issue + +```graphql +# Header: GraphQL-Features: issue_fields +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + issueFieldValues(first: 20) { + nodes { + __typename + ... on IssueFieldDateValue { + value + field { ... on IssueFieldDate { id name } } + } + ... on IssueFieldTextValue { + value + field { ... on IssueFieldText { id name } } + } + ... on IssueFieldNumberValue { + value + field { ... on IssueFieldNumber { id name } } + } + ... on IssueFieldSingleSelectValue { + name + color + field { ... on IssueFieldSingleSelect { id name } } + } + } + } + } + } +} +``` + +## Setting field values + +Use `setIssueFieldValue` to set one or more fields at once. You need the issue's node ID and the field IDs from the discovery query above. + +```graphql +# Header: GraphQL-Features: issue_fields +mutation { + setIssueFieldValue(input: { + issueId: "ISSUE_NODE_ID" + issueFields: [ + { fieldId: "IFD_xxx", dateValue: "2026-04-15" } + { fieldId: "IFT_xxx", textValue: "some text" } + { fieldId: "IFN_xxx", numberValue: 3.0 } + { fieldId: "IFSS_xxx", singleSelectOptionId: "OPTION_ID" } + ] + }) { + issue { id title } + } +} +``` + +Each entry in `issueFields` takes a `fieldId` plus exactly one value parameter: + +| Field type | Value parameter | Format | +|-----------|----------------|--------| +| Date | `dateValue` | ISO 8601 date string, e.g. `"2026-04-15"` | +| Text | `textValue` | String | +| Number | `numberValue` | Float | +| Single select | `singleSelectOptionId` | ID from the field's `options` list | + +To clear a field value, set `delete: true` instead of a value parameter. + +## Workflow for setting fields + +1. **Discover fields** - query the org's `issueFields` to get field IDs and option IDs +2. **Get the issue node ID** - from `repository.issue.id` +3. **Set values** - call `setIssueFieldValue` with the issue node ID and field entries +4. **Batch when possible** - multiple fields can be set in a single mutation call + +## Example: Set dates and priority on an issue + +```bash +gh api graphql \ + -H "GraphQL-Features: issue_fields" \ + -f query=' +mutation { + setIssueFieldValue(input: { + issueId: "I_kwDOxxx" + issueFields: [ + { fieldId: "IFD_startDate", dateValue: "2026-04-01" } + { fieldId: "IFD_targetDate", dateValue: "2026-04-30" } + { fieldId: "IFSS_priority", singleSelectOptionId: "OPTION_P1" } + ] + }) { + issue { id title } + } +}' +``` + +## Searching by field values + +### GraphQL bulk query (recommended) + +The most reliable way to find issues by field value is to fetch issues via GraphQL and filter by `issueFieldValues`. The search qualifier syntax (`field.name:value`) is not yet reliable across all environments. + +```bash +# Find all open P1 issues in a repo +gh api graphql -H "GraphQL-Features: issue_fields" -f query=' +{ + repository(owner: "OWNER", name: "REPO") { + issues(first: 100, states: OPEN) { + nodes { + number + title + updatedAt + assignees(first: 3) { nodes { login } } + issueFieldValues(first: 10) { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + field { ... on IssueFieldSingleSelect { name } } + } + } + } + } + } + } +}' --jq ' + [.data.repository.issues.nodes[] | + select(.issueFieldValues.nodes[] | + select(.field.name == "Priority" and .name == "P1") + ) | + {number, title, updatedAt, assignees: [.assignees.nodes[].login]} + ]' +``` + +**Schema notes for `IssueFieldSingleSelectValue`:** +- The selected option's display text is in `.name` (not `.value`) +- Also available: `.color`, `.description`, `.id` +- The parent field reference is in `.field` (use inline fragment to get the field name) + +### Search qualifier syntax (experimental) + +Issue fields may also be searchable using dot notation in search queries. This requires `advanced_search=true` on REST or `ISSUE_ADVANCED` search type on GraphQL, but results are inconsistent and may return 0 results even when matching issues exist. + +``` +field.priority:P0 # Single-select equals value +field.target-date:>=2026-04-01 # Date comparison +has:field.priority # Has any value set +no:field.priority # Has no value set +``` + +Field names use the **slug** (lowercase, hyphens for spaces). For example, "Target Date" becomes `target-date`. + +```bash +# REST API (may not return results in all environments) +gh api "search/issues?q=repo:owner/repo+field.priority:P0+is:open&advanced_search=true" \ + --jq '.items[] | "#\(.number): \(.title)"' +``` + +> **Warning:** The colon notation (`field:Priority:P1`) is silently ignored. If using search qualifiers, always use dot notation (`field.priority:P1`). However, the GraphQL bulk query approach above is more reliable. See [search.md](search.md) for the full search guide. diff --git a/.github/skills/github-issues/references/issue-types.md b/.github/skills/github-issues/references/issue-types.md new file mode 100644 index 0000000..f605d7b --- /dev/null +++ b/.github/skills/github-issues/references/issue-types.md @@ -0,0 +1,72 @@ +# Issue Types (Advanced GraphQL) + +Issue types (Bug, Feature, Task, Epic, etc.) are defined at the **organization** level and inherited by repositories. They categorize issues beyond labels. + +For basic usage, the MCP tools handle issue types natively. Call `mcp__github__list_issue_types` to discover types, and pass `type: "Bug"` to `mcp__github__create_issue` or `mcp__github__update_issue`. This reference covers advanced GraphQL operations. + +## GraphQL Feature Header + +All GraphQL issue type operations require the `GraphQL-Features: issue_types` HTTP header. + +## List types (org or repo level) + +```graphql +# Header: GraphQL-Features: issue_types +{ + organization(login: "OWNER") { + issueTypes(first: 20) { + nodes { id name color description isEnabled } + } + } +} +``` + +Types can also be listed per-repo via `repository.issueTypes` or looked up by name via `repository.issueType(name: "Bug")`. + +## Read an issue's type + +```graphql +# Header: GraphQL-Features: issue_types +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + issueType { id name color } + } + } +} +``` + +## Set type on an existing issue + +```graphql +# Header: GraphQL-Features: issue_types +mutation { + updateIssueIssueType(input: { + issueId: "ISSUE_NODE_ID" + issueTypeId: "IT_xxx" + }) { + issue { id issueType { name } } + } +} +``` + +## Create issue with type + +```graphql +# Header: GraphQL-Features: issue_types +mutation { + createIssue(input: { + repositoryId: "REPO_NODE_ID" + title: "Fix login bug" + issueTypeId: "IT_xxx" + }) { + issue { id number issueType { name } } + } +} +``` + +To clear the type, set `issueTypeId` to `null`. + +## Available colors + +`GRAY`, `BLUE`, `GREEN`, `YELLOW`, `ORANGE`, `RED`, `PINK`, `PURPLE` diff --git a/.github/skills/github-issues/references/projects.md b/.github/skills/github-issues/references/projects.md new file mode 100644 index 0000000..803187e --- /dev/null +++ b/.github/skills/github-issues/references/projects.md @@ -0,0 +1,272 @@ +# Projects V2 + +GitHub Projects V2 is managed via GraphQL. The MCP server provides three tools that wrap the GraphQL API, so you typically don't need raw GraphQL. + +## Using MCP tools (preferred) + +**List projects:** +Call `mcp__github__projects_list` with `method: "list_projects"`, `owner`, and `owner_type` ("user" or "organization"). + +**List project fields:** +Call `mcp__github__projects_list` with `method: "list_project_fields"` and `project_number`. + +**List project items:** +Call `mcp__github__projects_list` with `method: "list_project_items"` and `project_number`. + +**Add an issue/PR to a project:** +Call `mcp__github__projects_write` with `method: "add_project_item"`, `project_id` (node ID), and `content_id` (issue/PR node ID). + +**Update a project item field value:** +Call `mcp__github__projects_write` with `method: "update_project_item"`, `project_id`, `item_id`, `field_id`, and `value` (object with one of: `text`, `number`, `date`, `singleSelectOptionId`, `iterationId`). + +**Delete a project item:** +Call `mcp__github__projects_write` with `method: "delete_project_item"`, `project_id`, and `item_id`. + +## Workflow for project operations + +1. **Find the project** — see [Finding a project by name](#finding-a-project-by-name) below +2. **Discover fields** - use `projects_list` with `list_project_fields` to get field IDs and option IDs +3. **Find items** - use `projects_list` with `list_project_items` to get item IDs +4. **Mutate** - use `projects_write` to add, update, or delete items + +## Finding a project by name + +> **⚠️ Known issue:** `projectsV2(query: "…")` does keyword search, not exact name match, and returns results sorted by recency. Common words like "issue" or "bug" return hundreds of false positives. The actual project may be buried dozens of pages deep. + +Use this priority order: + +### 1. Direct lookup (if you know the number) +```bash +gh api graphql -f query='{ + organization(login: "ORG") { + projectV2(number: 42) { id title } + } +}' --jq '.data.organization.projectV2' +``` + +### 2. Reverse lookup from a known issue (most reliable) +If the user mentions an issue, epic, or milestone that's in the project, query that issue's `projectItems` to discover the project: + +```bash +gh api graphql -f query='{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + projectItems(first: 10) { + nodes { + id + project { number title id } + } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes[] | {number: .project.number, title: .project.title, id: .project.id}' +``` + +This is the most reliable approach for large orgs where name search fails. + +### 3. GraphQL name search with client-side filtering (fallback) +Query a large page and filter client-side for an exact title match: + +```bash +gh api graphql -f query='{ + organization(login: "ORG") { + projectsV2(first: 100, query: "search term") { + nodes { number title id } + } + } +}' --jq '.data.organization.projectsV2.nodes[] | select(.title | test("(?i)^exact name$"))' +``` + +If this returns nothing, paginate with `after` cursor or broaden the regex. Results are sorted by recency so older projects require pagination. + +### 4. MCP tool (small orgs only) +Call `mcp__github__projects_list` with `method: "list_projects"`. This works well for orgs with <50 projects but has no name filter, so you must scan all results. + +## Project discovery for progress reports + +When a user asks for a progress update on a project (e.g., "Give me a progress update for Project X"), follow this workflow: + +1. **Find the project** — use the [finding a project](#finding-a-project-by-name) strategies above. Ask the user for a known issue number if name search fails. + +2. **Discover fields** - call `projects_list` with `list_project_fields` to find the Status field (its options tell you the workflow stages) and any Iteration field (to scope to the current sprint). + +3. **Get all items** - call `projects_list` with `list_project_items`. For large projects (100+ items), paginate through all pages. Each item includes its field values (status, iteration, assignees). + +4. **Build the report** - group items by Status field value and count them. For iteration-based projects, filter to the current iteration first. Present a breakdown like: + + ``` + Project: Issue Fields (Iteration 42, Mar 2-8) + 15 actionable items: + 🎉 Done: 4 (27%) + In Review: 3 + In Progress: 3 + Ready: 2 + Blocked: 2 + ``` + +5. **Add context** - if items have sub-issues, include `subIssuesSummary` counts. If items have dependencies, note blocked items and what blocks them. + +## OAuth Scope Requirements + +| Operation | Required scope | +|-----------|---------------| +| Read projects, fields, items | `read:project` | +| Add/update/delete items, change field values | `project` | + +**Common pitfall:** The default `gh auth` token often only has `read:project`. Mutations will fail with `INSUFFICIENT_SCOPES`. To add the write scope: + +```bash +gh auth refresh -h github.com -s project +``` + +This triggers a browser-based OAuth flow. You must complete it before mutations will work. + +## Finding an Issue's Project Item ID + +When you know the issue but need its project item ID (e.g., to update its Status), query from the issue side: + +```bash +gh api graphql -f query=' +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + projectItems(first: 5) { + nodes { + id + project { title number } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes' +``` + +This returns the item ID, project info, and current field values in one query. + +## Using GraphQL via gh api (recommended) + +Use `gh api graphql` to run GraphQL queries and mutations. This is more reliable than MCP tools for write operations. + +**Find a project and its Status field options:** +```bash +gh api graphql -f query=' +{ + organization(login: "ORG") { + projectV2(number: 5) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } +}' --jq '.data.organization.projectV2' +``` + +**List all fields (including iterations):** +```bash +gh api graphql -f query=' +{ + node(id: "PROJECT_ID") { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2Field { id name } + ... on ProjectV2SingleSelectField { id name options { id name } } + ... on ProjectV2IterationField { id name configuration { iterations { id startDate } } } + } + } + } + } +}' --jq '.data.node.fields.nodes' +``` + +**Update a field value (e.g., set Status to "In Progress"):** +```bash +gh api graphql -f query=' +mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + fieldId: "FIELD_ID" + value: { singleSelectOptionId: "OPTION_ID" } + }) { + projectV2Item { id } + } +}' +``` + +Value accepts one of: `text`, `number`, `date`, `singleSelectOptionId`, `iterationId`. + +**Add an item:** +```bash +gh api graphql -f query=' +mutation { + addProjectV2ItemById(input: { + projectId: "PROJECT_ID" + contentId: "ISSUE_OR_PR_NODE_ID" + }) { + item { id } + } +}' +``` + +**Delete an item:** +```bash +gh api graphql -f query=' +mutation { + deleteProjectV2Item(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + }) { + deletedItemId + } +}' +``` + +## End-to-End Example: Set Issue Status to "In Progress" + +```bash +# 1. Get the issue's project item ID, project ID, and current status +gh api graphql -f query='{ + repository(owner: "github", name: "planning-tracking") { + issue(number: 2574) { + projectItems(first: 1) { + nodes { id project { id title } } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes[0]' + +# 2. Get the Status field ID and "In Progress" option ID +gh api graphql -f query='{ + node(id: "PROJECT_ID") { + ... on ProjectV2 { + field(name: "Status") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } +}' --jq '.data.node.field' + +# 3. Update the status +gh api graphql -f query='mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + fieldId: "FIELD_ID" + value: { singleSelectOptionId: "IN_PROGRESS_OPTION_ID" } + }) { projectV2Item { id } } +}' +``` diff --git a/.github/skills/github-issues/references/search.md b/.github/skills/github-issues/references/search.md new file mode 100644 index 0000000..9e08efa --- /dev/null +++ b/.github/skills/github-issues/references/search.md @@ -0,0 +1,231 @@ +# Advanced Issue Search + +The `search_issues` MCP tool uses GitHub's issue search query format for cross-repo searches, supporting implicit-AND queries, date ranges, and metadata filters (but not explicit OR/NOT operators). + +## When to Use Search vs List vs Advanced Search + +There are three ways to find issues, each with different capabilities: + +| Capability | `list_issues` (MCP) | `search_issues` (MCP) | Advanced search (`gh api`) | +|-----------|---------------------|----------------------|---------------------------| +| **Scope** | Single repo only | Cross-repo, cross-org | Cross-repo, cross-org | +| **Issue field filters** (`field.priority:P0`) | No | No | **Yes** (dot notation) | +| **Issue type filter** (`type:Bug`) | No | Yes | Yes | +| **Boolean logic** (AND/OR/NOT, nesting) | No | Yes (implicit AND only) | **Yes** (explicit AND/OR/NOT) | +| **Label/state/date filters** | Yes | Yes | Yes | +| **Assignee/author/mentions** | No | Yes | Yes | +| **Negation** (`-label:x`, `no:label`) | No | Yes | Yes | +| **Text search** (title/body/comments) | No | Yes | Yes | +| **`since` filter** | Yes | No | No | +| **Result limit** | No cap (paginate all) | 1,000 max | 1,000 max | +| **How to call** | MCP tool directly | MCP tool directly | `gh api` with `advanced_search=true` | + +**Decision guide:** +- **Single repo, simple filters (state, labels, recent updates):** use `list_issues` +- **Cross-repo, text search, author/assignee, issue types:** use `search_issues` +- **Issue field values (Priority, dates, custom fields) or complex boolean logic:** use `gh api` with `advanced_search=true` + +## Query Syntax + +The `query` parameter is a string of search terms and qualifiers. A space between terms is implicit AND. + +### Scoping + +``` +repo:owner/repo # Single repo (auto-added if you pass owner+repo params) +org:github # All repos in an org +user:octocat # All repos owned by user +in:title # Search only in title +in:body # Search only in body +in:comments # Search only in comments +``` + +### State & Close Reason + +``` +is:open # Open issues (auto-added: is:issue) +is:closed # Closed issues +reason:completed # Closed as completed +reason:"not planned" # Closed as not planned +``` + +### People + +``` +author:username # Created by +assignee:username # Assigned to +mentions:username # Mentions user +commenter:username # Has comment from +involves:username # Author OR assignee OR mentioned OR commenter +author:@me # Current authenticated user +team:org/team # Team mentioned +``` + +### Labels, Milestones, Projects, Types + +``` +label:"bug" # Has label (quote multi-word labels) +label:bug label:priority # Has BOTH labels (AND) +label:bug,enhancement # Has EITHER label (OR) +-label:wontfix # Does NOT have label +milestone:"v2.0" # In milestone +project:github/57 # In project board +type:"Bug" # Issue type +``` + +### Missing Metadata + +``` +no:label # No labels assigned +no:milestone # No milestone +no:assignee # Unassigned +no:project # Not in any project +``` + +### Dates + +All date qualifiers support `>`, `<`, `>=`, `<=`, and range (`..`) operators with ISO 8601 format: + +``` +created:>2026-01-01 # Created after Jan 1 +updated:>=2026-03-01 # Updated since Mar 1 +closed:2026-01-01..2026-02-01 # Closed in January +created:<2026-01-01 # Created before Jan 1 +``` + +### Linked Content + +``` +linked:pr # Issue has a linked PR +-linked:pr # Issues not yet linked to any PR +linked:issue # PR is linked to an issue +``` + +### Numeric Filters + +``` +comments:>10 # More than 10 comments +comments:0 # No comments +interactions:>100 # Reactions + comments > 100 +reactions:>50 # More than 50 reactions +``` + +### Boolean Logic & Nesting + +Use `AND`, `OR`, and parentheses (up to 5 levels deep, max 5 operators): + +``` +label:bug AND assignee:octocat +assignee:octocat OR assignee:hubot +(type:"Bug" AND label:P1) OR (type:"Feature" AND label:P1) +-author:app/dependabot # Exclude bot issues +``` + +A space between terms without an explicit operator is treated as AND. + +## Common Query Patterns + +**Unassigned bugs:** +``` +repo:owner/repo type:"Bug" no:assignee is:open +``` + +**Issues closed this week:** +``` +repo:owner/repo is:closed closed:>=2026-03-01 +``` + +**Stale open issues (no updates in 90 days):** +``` +repo:owner/repo is:open updated:<2026-01-01 +``` + +**Open issues without a linked PR (needs work):** +``` +repo:owner/repo is:open -linked:pr +``` + +**Issues I'm involved in across an org:** +``` +org:github involves:@me is:open +``` + +**High-activity issues:** +``` +repo:owner/repo is:open comments:>20 +``` + +**Issues by type and priority label:** +``` +repo:owner/repo type:"Epic" label:P1 is:open +``` + +## Issue Field Search + +> **Reliability warning:** The `field.name:value` search qualifier syntax is experimental and may return 0 results even when matching issues exist. For reliable filtering by field values, use the GraphQL bulk query approach documented in [issue-fields.md](issue-fields.md#searching-by-field-values). + +Issue fields can theoretically be searched via the `field.name:value` qualifier using **advanced search mode**. This works in the web UI but results from the API are inconsistent. + +### REST API + +Add `advanced_search=true` as a query parameter: + +```bash +gh api "search/issues?q=org:github+field.priority:P0+type:Epic+is:open&advanced_search=true" \ + --jq '.items[] | "#\(.number): \(.title)"' +``` + +### GraphQL + +Use `type: ISSUE_ADVANCED` instead of `type: ISSUE`: + +```graphql +{ + search(query: "org:github field.priority:P0 type:Epic is:open", type: ISSUE_ADVANCED, first: 10) { + issueCount + nodes { + ... on Issue { number title } + } + } +} +``` + +### Issue Field Qualifiers + +The syntax uses **dot notation** with the field's slug name (lowercase, hyphens for spaces): + +``` +field.priority:P0 # Single-select field equals value +field.priority:P1 # Different option value +field.target-date:>=2026-04-01 # Date comparison +has:field.priority # Has any value set +no:field.priority # Has no value set +``` + +**MCP limitation:** The `search_issues` MCP tool does not pass `advanced_search=true`. You must use `gh api` directly for issue field searches. + +### Common Field Search Patterns + +**P0 epics across an org:** +``` +org:github field.priority:P0 type:Epic is:open +``` + +**Issues with a target date this quarter:** +``` +org:github field.target-date:>=2026-04-01 field.target-date:<=2026-06-30 is:open +``` + +**Open bugs missing priority:** +``` +org:github no:field.priority type:Bug is:open +``` + +## Limitations + +- Query text: max **256 characters** (excluding operators/qualifiers) +- Boolean operators: max **5** AND/OR/NOT per query +- Results: max **1,000** total (use `list_issues` if you need all issues) +- Repo scan: searches up to **4,000** matching repositories +- Rate limit: **30 requests/minute** for authenticated search +- Issue field search requires `advanced_search=true` (REST) or `ISSUE_ADVANCED` (GraphQL); not available through MCP `search_issues` diff --git a/.github/skills/github-issues/references/sub-issues.md b/.github/skills/github-issues/references/sub-issues.md new file mode 100644 index 0000000..aac288e --- /dev/null +++ b/.github/skills/github-issues/references/sub-issues.md @@ -0,0 +1,137 @@ +# Sub-Issues and Parent Issues + +Sub-issues let you break down work into hierarchical tasks. Each parent issue can have up to 100 sub-issues, nested up to 8 levels deep. Sub-issues can span repositories within the same owner. + +## Recommended Workflow + +The simplest way to create a sub-issue is **two steps**: create the issue, then link it. + +```bash +# Step 1: Create the issue and capture its numeric ID +ISSUE_ID=$(gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Sub-task title" \ + -f body="Description" \ + --jq '.id') + +# Step 2: Link it as a sub-issue of the parent +# IMPORTANT: sub_issue_id must be an integer. Use --input (not -f) to send JSON. +echo "{\"sub_issue_id\": $ISSUE_ID}" | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues -X POST --input - +``` + +**Why `--input` instead of `-f`?** The `gh api -f` flag sends all values as strings, but the API requires `sub_issue_id` as an integer. Using `-f sub_issue_id=12345` will return a 422 error. + +Alternatively, use GraphQL `createIssue` with `parentIssueId` to do it in one step (see GraphQL section below). + +## Using MCP tools + +**List sub-issues:** +Call `mcp__github__issue_read` with `method: "get_sub_issues"`, `owner`, `repo`, and `issue_number`. + +**Create an issue as a sub-issue:** +There is no MCP tool for creating sub-issues directly. Use the workflow above or GraphQL. + +## Using REST API + +**List sub-issues:** +```bash +gh api repos/{owner}/{repo}/issues/{issue_number}/sub_issues +``` + +**Get parent issue:** +```bash +gh api repos/{owner}/{repo}/issues/{issue_number}/parent +``` + +**Add an existing issue as a sub-issue:** +```bash +# sub_issue_id is the numeric issue ID (not the issue number) +# Get it from the .id field when creating or fetching an issue +echo '{"sub_issue_id": 12345}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues -X POST --input - +``` + +To move a sub-issue that already has a parent, add `"replace_parent": true` to the JSON body. + +**Remove a sub-issue:** +```bash +echo '{"sub_issue_id": 12345}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issue -X DELETE --input - +``` + +**Reprioritize a sub-issue:** +```bash +echo '{"sub_issue_id": 6, "after_id": 5}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues/priority -X PATCH --input - +``` + +Use `after_id` or `before_id` to position the sub-issue relative to another. + +## Using GraphQL + +**Read parent and sub-issues:** +```graphql +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + parent { number title } + subIssues(first: 50) { + nodes { number title state } + } + subIssuesSummary { total completed percentCompleted } + } + } +} +``` + +**Add a sub-issue:** +```graphql +mutation { + addSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + }) { + issue { id } + subIssue { id number title } + } +} +``` + +You can also use `subIssueUrl` instead of `subIssueId` (pass the issue's HTML URL). Add `replaceParent: true` to move a sub-issue from another parent. + +**Create an issue directly as a sub-issue:** +```graphql +mutation { + createIssue(input: { + repositoryId: "REPO_NODE_ID" + title: "Implement login validation" + parentIssueId: "PARENT_NODE_ID" + }) { + issue { id number } + } +} +``` + +**Remove a sub-issue:** +```graphql +mutation { + removeSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + }) { + issue { id } + } +} +``` + +**Reprioritize a sub-issue:** +```graphql +mutation { + reprioritizeSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + afterId: "OTHER_CHILD_NODE_ID" + }) { + issue { id } + } +} +``` + +Use `afterId` or `beforeId` to position relative to another sub-issue. diff --git a/.github/skills/github-issues/references/templates.md b/.github/skills/github-issues/references/templates.md new file mode 100644 index 0000000..c05b408 --- /dev/null +++ b/.github/skills/github-issues/references/templates.md @@ -0,0 +1,90 @@ +# Issue Templates + +Copy and customize these templates for issue bodies. + +## Bug Report Template + +```markdown +## Description +[Clear description of the bug] + +## Steps to Reproduce +1. [First step] +2. [Second step] +3. [And so on...] + +## Expected Behavior +[What should happen] + +## Actual Behavior +[What actually happens] + +## Environment +- Browser: [e.g., Chrome 120] +- OS: [e.g., macOS 14.0] +- Version: [e.g., v1.2.3] + +## Screenshots/Logs +[If applicable] + +## Additional Context +[Any other relevant information] +``` + +## Feature Request Template + +```markdown +## Summary +[One-line description of the feature] + +## Motivation +[Why is this feature needed? What problem does it solve?] + +## Proposed Solution +[How should this feature work?] + +## Acceptance Criteria +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- [ ] [Criterion 3] + +## Alternatives Considered +[Other approaches considered and why they weren't chosen] + +## Additional Context +[Mockups, examples, or related issues] +``` + +## Task Template + +```markdown +## Objective +[What needs to be accomplished] + +## Details +[Detailed description of the work] + +## Checklist +- [ ] [Subtask 1] +- [ ] [Subtask 2] +- [ ] [Subtask 3] + +## Dependencies +[Any blockers or related work] + +## Notes +[Additional context or considerations] +``` + +## Minimal Template + +For simple issues: + +```markdown +## Description +[What and why] + +## Tasks +- [ ] [Task 1] +- [ ] [Task 2] +``` From 4b8d68960dc6ec7a79c44bdc71d550ead0c7609f Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Wed, 18 Mar 2026 21:16:44 -0400 Subject: [PATCH 02/23] chore(copilot): add session-logger hooks for Copilot CLI audit logging Adds .github/hooks/session-logger/ with hooks that log session start/end events and user prompt submissions to local JSON log files for audit and usage analytics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/hooks/session-logger/README.md | 58 +++++++++++++++++++ .github/hooks/session-logger/hooks.json | 32 ++++++++++ .github/hooks/session-logger/log-prompt.sh | 25 ++++++++ .../hooks/session-logger/log-session-end.sh | 25 ++++++++ .../hooks/session-logger/log-session-start.sh | 26 +++++++++ 5 files changed, 166 insertions(+) create mode 100644 .github/hooks/session-logger/README.md create mode 100644 .github/hooks/session-logger/hooks.json create mode 100644 .github/hooks/session-logger/log-prompt.sh create mode 100644 .github/hooks/session-logger/log-session-end.sh create mode 100644 .github/hooks/session-logger/log-session-start.sh diff --git a/.github/hooks/session-logger/README.md b/.github/hooks/session-logger/README.md new file mode 100644 index 0000000..a31a7a0 --- /dev/null +++ b/.github/hooks/session-logger/README.md @@ -0,0 +1,58 @@ +--- +name: 'Session Logger' +description: 'Logs all Copilot coding agent session activity for audit and analysis' +tags: ['logging', 'audit', 'analytics'] +--- + +# Session Logger Hook + +Comprehensive logging for GitHub Copilot coding agent sessions, tracking session starts, ends, and user prompts for audit trails and usage analytics. + +## Overview + +This hook provides detailed logging of Copilot coding agent activity: +- Session start/end times with working directory context +- User prompt submission events +- Configurable log levels + +## Features + +- **Session Tracking**: Log session start and end events +- **Prompt Logging**: Record when user prompts are submitted +- **Structured Logging**: JSON format for easy parsing +- **Privacy Aware**: Configurable to disable logging entirely + +## Installation + +1. Copy this hook folder to your repository's `.github/hooks/` directory: + ```bash + cp -r hooks/session-logger .github/hooks/ + ``` + +2. Create the logs directory: + ```bash + mkdir -p logs/copilot + ``` + +3. Ensure scripts are executable: + ```bash + chmod +x .github/hooks/session-logger/*.sh + ``` + +4. Commit the hook configuration to your repository's default branch + +## Log Format + +Session events are written to `logs/copilot/session.log` and prompt events to `logs/copilot/prompts.log` in JSON format: + +```json +{"timestamp":"2024-01-15T10:30:00Z","event":"sessionStart","cwd":"/workspace/project"} +{"timestamp":"2024-01-15T10:35:00Z","event":"sessionEnd"} +``` + +## Privacy & Security + +- Add `logs/` to `.gitignore` to avoid committing session data +- Use `LOG_LEVEL=ERROR` to only log errors +- Set `SKIP_LOGGING=true` environment variable to disable +- Logs are stored locally only \ No newline at end of file diff --git a/.github/hooks/session-logger/hooks.json b/.github/hooks/session-logger/hooks.json new file mode 100644 index 0000000..f019a4f --- /dev/null +++ b/.github/hooks/session-logger/hooks.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-start.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-end.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-prompt.sh", + "cwd": ".", + "env": { + "LOG_LEVEL": "INFO" + }, + "timeoutSec": 5 + } + ] + } +} \ No newline at end of file diff --git a/.github/hooks/session-logger/log-prompt.sh b/.github/hooks/session-logger/log-prompt.sh new file mode 100644 index 0000000..5875bfb --- /dev/null +++ b/.github/hooks/session-logger/log-prompt.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Log session end event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log session end +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"sessionEnd\"}" >> logs/copilot/session.log + +echo "📝 Session end logged" +exit 0 \ No newline at end of file diff --git a/.github/hooks/session-logger/log-session-end.sh b/.github/hooks/session-logger/log-session-end.sh new file mode 100644 index 0000000..5875bfb --- /dev/null +++ b/.github/hooks/session-logger/log-session-end.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Log session end event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log session end +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"sessionEnd\"}" >> logs/copilot/session.log + +echo "📝 Session end logged" +exit 0 \ No newline at end of file diff --git a/.github/hooks/session-logger/log-session-start.sh b/.github/hooks/session-logger/log-session-start.sh new file mode 100644 index 0000000..11c4109 --- /dev/null +++ b/.github/hooks/session-logger/log-session-start.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Log session start event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp and session info +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +CWD=$(pwd) + +# Log session start (use jq for proper JSON encoding) +jq -Rn --arg timestamp "$TIMESTAMP" --arg cwd "$CWD" '{"timestamp":$timestamp,"event":"sessionStart","cwd":$cwd}' >> logs/copilot/session.log + +echo "📝 Session logged" +exit 0 \ No newline at end of file From 341302a7a77e535f802c47f447d1d5f14e789229 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 20:15:26 -0400 Subject: [PATCH 03/23] chore(vscode): add scenario-based task system with auto-stop terminal switching - Add compound dev/test scenario tasks with split-pane terminal views - Each scenario first stops all running processes so terminals auto-close - Add (keep) variants for test scenarios to retain output for review - Fix terminal close behavior using process-group signals on the task shell - Update docs/development.md with VS Code tasks reference section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 681 ++++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 58 ++++ 2 files changed, 739 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7f65396 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,681 @@ +{ + "version": "2.0.0", + "tasks": [ + + // ============================================================ + // INDIVIDUAL TASKS — run any single command standalone + // ============================================================ + + // Backend + { + "label": "Backend: Dev", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Dev (Device)", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test", + "type": "shell", + "command": "pnpm --filter backend test", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test (Watch)", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test (Contract)", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Frontend + { + "label": "Frontend: Dev", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Dev (Device)", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Test", + "type": "shell", + "command": "pnpm --filter frontend test", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Test (Watch)", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: E2E (Playwright)", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // iOS + { + "label": "iOS: Cap Sync", + "type": "shell", + "command": "pnpm exec cap sync ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "iOS: Run Simulator", + "type": "shell", + "command": "pnpm exec cap run ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "iOS: E2E (Maestro)", + "type": "shell", + "command": "maestro test .maestro/flows/", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Android + { + "label": "Android: Cap Sync", + "type": "shell", + "command": "pnpm exec cap sync android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Android: Run Emulator", + "type": "shell", + "command": "pnpm exec cap run android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Android: E2E (Maestro)", + "type": "shell", + "command": "maestro test .maestro/flows/", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Desktop (Tauri) + { + "label": "Desktop: Dev (Tauri)", + "type": "shell", + "command": "pnpm exec tauri dev", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Desktop: Build (Tauri)", + "type": "shell", + "command": "pnpm exec tauri build", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Kills dev servers (ports 3000/4000), Tauri, Capacitor, Vitest watch, + // and Maestro. Terminals with close: true vanish automatically. + { + "label": "⚡ Stop: All", + "type": "shell", + "command": "for pid in $(lsof -ti:3000,4000 2>/dev/null) $(pgrep -f vitest 2>/dev/null) $(pgrep -f maestro 2>/dev/null) $(pgrep -f 'cargo run' 2>/dev/null) $(pgrep -f 'cap run' 2>/dev/null); do pgid=$(ps -o pgid= -p $pid 2>/dev/null | tr -d ' '); [ -n \"$pgid\" ] && kill -- -$pgid 2>/dev/null; done; sleep 0.5; echo 'All tasks stopped'", + "presentation": { "panel": "shared", "reveal": "silent", "close": true }, + "problemMatcher": [] + }, + + // ============================================================ + // SCENARIO LEAF TASKS — used only by compound tasks below. + // Each scenario gets a unique presentation.group so VS Code + // renders those terminals as split panes within one tab. + // close: true auto-closes the terminal when the process exits + // (triggered by ⚡ Stop: All Dev before switching scenarios). + // ============================================================ + + // --- ▶ Web: Dev (2 terminals) --- + { + "label": "[web-dev] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "web-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-dev] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "web-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Web: Dev + E2E (3 terminals) --- + { + "label": "[web-e2e] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-e2e] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-e2e] Playwright", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ iOS: Simulator Dev (3 terminals) --- + { + "label": "[ios-sim] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-sim] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-sim] Simulator", + "type": "shell", + "command": "pnpm exec cap run ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ iOS: Device Dev (3 terminals — real device via ngrok) --- + { + "label": "[ios-device] Backend", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-device] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-device] Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: Emulator Dev (3 terminals) --- + { + "label": "[android-emu] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-emu] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-emu] Emulator", + "type": "shell", + "command": "pnpm exec cap run android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: Device Dev (3 terminals — real device via ngrok) --- + { + "label": "[android-device] Backend", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-device] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-device] Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Desktop: Dev (2 terminals) --- + { + "label": "[desktop-dev] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "desktop-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[desktop-dev] Tauri", + "type": "shell", + "command": "pnpm exec tauri dev", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "desktop-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: Unit Watch (2 terminals) --- + { + "label": "[test-watch] Backend", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-watch] Frontend", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: All (4 terminals) --- + { + "label": "[test-all] Backend Unit", + "type": "shell", + "command": "pnpm --filter backend test", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Backend Contract", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Frontend Unit", + "type": "shell", + "command": "pnpm --filter frontend test", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Frontend E2E", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: Mobile E2E (2 terminals) --- + { + "label": "[mobile-e2e] iOS Maestro", + "type": "shell", + "command": "maestro test .maestro/flows/", + "isBackground": true, + "presentation": { "group": "mobile-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[mobile-e2e] Android Maestro", + "type": "shell", + "command": "maestro test .maestro/flows/", + "isBackground": true, + "presentation": { "group": "mobile-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: All (keep) — same as above but terminals stay open --- + { + "label": "[test-all-keep] Backend Unit", + "type": "shell", + "command": "pnpm --filter backend test", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Backend Contract", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Frontend Unit", + "type": "shell", + "command": "pnpm --filter frontend test", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Frontend E2E", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ Test: Unit Watch (keep) --- + { + "label": "[test-watch-keep] Backend", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-watch-keep] Frontend", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ Test: Mobile E2E (keep) --- + { + "label": "[mobile-e2e-keep] iOS Maestro", + "type": "shell", + "command": "maestro test .maestro/flows/", + "presentation": { "group": "mobile-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[mobile-e2e-keep] Android Maestro", + "type": "shell", + "command": "maestro test .maestro/flows/", + "presentation": { "group": "mobile-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + + // + // Dev scenarios use a two-level structure: + // Outer (sequence): ⚡ Stop: All Dev → (start) + // Inner (parallel): all leaf terminals launch simultaneously + // + // When switching scenarios, the stop task kills dev ports + // (3000, 4000) and Tauri/Capacitor processes. Because leaf + // tasks have close: true, their terminals vanish automatically, + // giving you a clean split view for the new scenario. + // ============================================================ + + // Inner parallel start tasks (not meant to be run directly) + { + "label": "▶ Web: Dev (start)", + "dependsOn": ["[web-dev] Backend", "[web-dev] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Web: Dev + E2E (start)", + "dependsOn": ["[web-e2e] Backend", "[web-e2e] Frontend", "[web-e2e] Playwright"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Simulator Dev (start)", + "dependsOn": ["[ios-sim] Backend", "[ios-sim] Frontend", "[ios-sim] Simulator"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Device Dev (start)", + "dependsOn": ["[ios-device] Backend", "[ios-device] Frontend", "[ios-device] Ngrok"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: Emulator Dev (start)", + "dependsOn": ["[android-emu] Backend", "[android-emu] Frontend", "[android-emu] Emulator"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: Device Dev (start)", + "dependsOn": ["[android-device] Backend", "[android-device] Frontend", "[android-device] Ngrok"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Desktop: Dev (start)", + "dependsOn": ["[desktop-dev] Backend", "[desktop-dev] Tauri"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + + // Outer tasks — stop existing servers, then open new split view + { + "label": "▶ Web: Dev", + "dependsOn": ["⚡ Stop: All", "▶ Web: Dev (start)"], + "dependsOrder": "sequence", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "▶ Web: Dev + E2E", + "dependsOn": ["⚡ Stop: All", "▶ Web: Dev + E2E (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Simulator Dev", + "dependsOn": ["⚡ Stop: All", "▶ iOS: Simulator Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Device Dev", + "dependsOn": ["⚡ Stop: All", "▶ iOS: Device Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: Emulator Dev", + "dependsOn": ["⚡ Stop: All", "▶ Android: Emulator Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: Device Dev", + "dependsOn": ["⚡ Stop: All", "▶ Android: Device Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Desktop: Dev", + "dependsOn": ["⚡ Stop: All", "▶ Desktop: Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + // Inner parallel start tasks for test scenarios + { + "label": "▶ Test: Unit Watch (start)", + "dependsOn": ["[test-watch] Backend", "[test-watch] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (start)", + "dependsOn": [ + "[test-all] Backend Unit", + "[test-all] Backend Contract", + "[test-all] Frontend Unit", + "[test-all] Frontend E2E" + ], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: Mobile E2E (start)", + "dependsOn": ["[mobile-e2e] iOS Maestro", "[mobile-e2e] Android Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: Unit Watch", + "dependsOn": ["⚡ Stop: All", "▶ Test: Unit Watch (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: All", + "dependsOn": ["⚡ Stop: All", "▶ Test: All (start)"], + "dependsOrder": "sequence", + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "▶ Test: Mobile E2E", + "dependsOn": ["⚡ Stop: All", "▶ Test: Mobile E2E (start)"], + "dependsOrder": "sequence", + "group": "test", + "problemMatcher": [] + }, + + // (keep) variants — same stop-then-start flow, but terminals stay + // open after completion so you can review results. + { + "label": "▶ Test: Unit Watch (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Test: Unit Watch (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Test: All (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: Mobile E2E (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Test: Mobile E2E (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: Unit Watch (keep-start)", + "dependsOn": ["[test-watch-keep] Backend", "[test-watch-keep] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (keep-start)", + "dependsOn": [ + "[test-all-keep] Backend Unit", + "[test-all-keep] Backend Contract", + "[test-all-keep] Frontend Unit", + "[test-all-keep] Frontend E2E" + ], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: Mobile E2E (keep-start)", + "dependsOn": ["[mobile-e2e-keep] iOS Maestro", "[mobile-e2e-keep] Android Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + } + ] +} diff --git a/docs/development.md b/docs/development.md index 7254378..c4d2b3b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -184,6 +184,12 @@ cd .. ### Start dev servers +The quickest way is to use the VS Code task (see [VS Code tasks](#vs-code-tasks) below): + +> `Cmd+Shift+B` → opens backend + frontend in a split terminal view + +Or from the terminal: + ```bash pnpm dev ``` @@ -199,6 +205,58 @@ cd frontend && pnpm dev # frontend only --- +## VS Code tasks + +The project ships with a full set of VS Code tasks (`.vscode/tasks.json`) that open split-terminal views and manage process lifecycle automatically. They are the recommended way to run dev servers and tests during active development. + +### How to run a task + +**Command Palette** (recommended): `Cmd+Shift+P` → *Tasks: Run Task* → pick a task +**Keyboard shortcut**: `Cmd+Shift+B` runs the default build task (`▶ Web: Dev`) + +--- + +### Scenario tasks — dev + +Each scenario task stops any running dev servers, then opens a split-terminal view with everything needed for that platform. + +| Task | Terminals opened | Use when | +|------|-----------------|----------| +| `▶ Web: Dev` | Backend · Frontend | Building or testing in a browser | +| `▶ Web: Dev + E2E` | Backend · Frontend · Playwright | Running Playwright tests against a live dev server | +| `▶ iOS: Simulator Dev` | Backend · Frontend · iOS Simulator | Testing the Capacitor iOS app in the Xcode simulator | +| `▶ iOS: Device Dev` | Backend (device) · Frontend (device) · ngrok | Testing on a physical iOS device | +| `▶ Android: Emulator Dev` | Backend · Frontend · Android Emulator | Testing the Capacitor Android app in an emulator | +| `▶ Android: Device Dev` | Backend (device) · Frontend (device) · ngrok | Testing on a physical Android device | +| `▶ Desktop: Dev` | Backend · Tauri | Working on the Tauri desktop app | + +**Switching scenarios:** just run the new scenario task — the stop step runs automatically, previous terminals close, and the new split view opens. + +**Manually stopping everything:** run `⚡ Stop: All` from the task picker. + +--- + +### Scenario tasks — testing + +These do not stop running dev servers first (test runners are independent). + +| Task | Terminals opened | Use when | +|------|-----------------|----------| +| `▶ Test: All` | Backend unit · Backend contract · Frontend unit · Frontend E2E | Full pre-commit test pass (terminals auto-close) | +| `▶ Test: All (keep)` | same | Same, but terminals stay open to review results | +| `▶ Test: Unit Watch` | Backend watch · Frontend watch | TDD / watching tests while editing (terminals auto-close) | +| `▶ Test: Unit Watch (keep)` | same | Same, but terminals stay open | +| `▶ Test: Mobile E2E` | iOS Maestro · Android Maestro | Running native mobile flow tests (terminals auto-close) | +| `▶ Test: Mobile E2E (keep)` | same | Same, but terminals stay open | + +--- + +### Individual tasks + +Every command is also available as a standalone task for one-off use (e.g. running a single test suite, syncing Capacitor, or building the Tauri app without opening a full scenario). Find them in the task picker under their platform prefix: `Backend:`, `Frontend:`, `iOS:`, `Android:`, `Desktop:`. + +--- + ## Testing scenarios ### Web (browser) From 0eb8d7c70b4921676a3e76745b5188587e53c332 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 20:51:49 -0400 Subject: [PATCH 04/23] fix(vscode): replace mobile-e2e task with per-platform iOS/Android E2E tasks Each platform now has its own 4-terminal split view: backend + frontend + simulator/emulator + maestro. Maestro waits for the device/simulator to be booted before running tests, so the tasks can all start in parallel safely. Also fixes accessibility id selector in task-complete.yaml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .maestro/flows/task-complete.yaml | 2 +- .vscode/tasks.json | 167 ++++++++++++++++++++++++++---- docs/development.md | 6 +- 3 files changed, 150 insertions(+), 25 deletions(-) diff --git a/.maestro/flows/task-complete.yaml b/.maestro/flows/task-complete.yaml index c7c59ff..f7c9565 100644 --- a/.maestro/flows/task-complete.yaml +++ b/.maestro/flows/task-complete.yaml @@ -14,7 +14,7 @@ appId: com.ordrctrl.app # Tap the first visible task's complete checkbox # Capacitor WebView exposes aria-label as accessibility ID on iOS and Android - tapOn: - accessibility id: "Mark complete" + id: "Mark complete" # Wait for state update animation - waitForAnimationToEnd diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7f65396..d43356a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -407,21 +407,69 @@ "problemMatcher": [] }, - // --- ▶ Test: Mobile E2E (2 terminals) --- + // --- ▶ iOS: E2E (4 terminals) --- { - "label": "[mobile-e2e] iOS Maestro", + "label": "[ios-e2e] Backend", "type": "shell", - "command": "maestro test .maestro/flows/", + "command": "pnpm --filter backend dev", "isBackground": true, - "presentation": { "group": "mobile-e2e", "panel": "new", "reveal": "always", "close": true }, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, { - "label": "[mobile-e2e] Android Maestro", + "label": "[ios-e2e] Frontend", "type": "shell", - "command": "maestro test .maestro/flows/", + "command": "pnpm --filter frontend dev", "isBackground": true, - "presentation": { "group": "mobile-e2e", "panel": "new", "reveal": "always", "close": true }, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e] Simulator", + "type": "shell", + "command": "pnpm exec cap run ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e] Maestro", + "type": "shell", + "command": "echo 'Waiting for booted iOS simulator...' && until xcrun simctl list devices booted 2>/dev/null | grep -q 'Booted'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: E2E (4 terminals) --- + { + "label": "[android-e2e] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Emulator", + "type": "shell", + "command": "pnpm exec cap run android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Maestro", + "type": "shell", + "command": "echo 'Waiting for Android emulator...' && until adb devices 2>/dev/null | grep -v 'List of devices' | grep -q 'device$'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -473,19 +521,69 @@ "problemMatcher": [] }, - // --- ▶ Test: Mobile E2E (keep) --- + // --- ▶ iOS: E2E (keep) --- { - "label": "[mobile-e2e-keep] iOS Maestro", + "label": "[ios-e2e-keep] Backend", "type": "shell", - "command": "maestro test .maestro/flows/", - "presentation": { "group": "mobile-e2e-keep", "panel": "new", "reveal": "always" }, + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, { - "label": "[mobile-e2e-keep] Android Maestro", + "label": "[ios-e2e-keep] Frontend", "type": "shell", - "command": "maestro test .maestro/flows/", - "presentation": { "group": "mobile-e2e-keep", "panel": "new", "reveal": "always" }, + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e-keep] Simulator", + "type": "shell", + "command": "pnpm exec cap run ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e-keep] Maestro", + "type": "shell", + "command": "echo 'Waiting for booted iOS simulator...' && until xcrun simctl list devices booted 2>/dev/null | grep -q 'Booted'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ Android: E2E (keep) --- + { + "label": "[android-e2e-keep] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Emulator", + "type": "shell", + "command": "pnpm exec cap run android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Maestro", + "type": "shell", + "command": "echo 'Waiting for Android emulator...' && until adb devices 2>/dev/null | grep -v 'List of devices' | grep -q 'device$'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, @@ -608,8 +706,14 @@ "problemMatcher": [] }, { - "label": "▶ Test: Mobile E2E (start)", - "dependsOn": ["[mobile-e2e] iOS Maestro", "[mobile-e2e] Android Maestro"], + "label": "▶ iOS: E2E (start)", + "dependsOn": ["[ios-e2e] Backend", "[ios-e2e] Frontend", "[ios-e2e] Simulator", "[ios-e2e] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (start)", + "dependsOn": ["[android-e2e] Backend", "[android-e2e] Frontend", "[android-e2e] Emulator", "[android-e2e] Maestro"], "dependsOrder": "parallel", "problemMatcher": [] }, @@ -627,8 +731,15 @@ "problemMatcher": [] }, { - "label": "▶ Test: Mobile E2E", - "dependsOn": ["⚡ Stop: All", "▶ Test: Mobile E2E (start)"], + "label": "▶ iOS: E2E", + "dependsOn": ["⚡ Stop: All", "▶ iOS: E2E (start)"], + "dependsOrder": "sequence", + "group": "test", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E", + "dependsOn": ["⚡ Stop: All", "▶ Android: E2E (start)"], "dependsOrder": "sequence", "group": "test", "problemMatcher": [] @@ -649,8 +760,14 @@ "problemMatcher": [] }, { - "label": "▶ Test: Mobile E2E (keep)", - "dependsOn": ["⚡ Stop: All", "▶ Test: Mobile E2E (keep-start)"], + "label": "▶ iOS: E2E (keep)", + "dependsOn": ["⚡ Stop: All", "▶ iOS: E2E (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Android: E2E (keep-start)"], "dependsOrder": "sequence", "problemMatcher": [] }, @@ -672,8 +789,14 @@ "problemMatcher": [] }, { - "label": "▶ Test: Mobile E2E (keep-start)", - "dependsOn": ["[mobile-e2e-keep] iOS Maestro", "[mobile-e2e-keep] Android Maestro"], + "label": "▶ iOS: E2E (keep-start)", + "dependsOn": ["[ios-e2e-keep] Backend", "[ios-e2e-keep] Frontend", "[ios-e2e-keep] Simulator", "[ios-e2e-keep] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (keep-start)", + "dependsOn": ["[android-e2e-keep] Backend", "[android-e2e-keep] Frontend", "[android-e2e-keep] Emulator", "[android-e2e-keep] Maestro"], "dependsOrder": "parallel", "problemMatcher": [] } diff --git a/docs/development.md b/docs/development.md index c4d2b3b..143f175 100644 --- a/docs/development.md +++ b/docs/development.md @@ -246,8 +246,10 @@ These do not stop running dev servers first (test runners are independent). | `▶ Test: All (keep)` | same | Same, but terminals stay open to review results | | `▶ Test: Unit Watch` | Backend watch · Frontend watch | TDD / watching tests while editing (terminals auto-close) | | `▶ Test: Unit Watch (keep)` | same | Same, but terminals stay open | -| `▶ Test: Mobile E2E` | iOS Maestro · Android Maestro | Running native mobile flow tests (terminals auto-close) | -| `▶ Test: Mobile E2E (keep)` | same | Same, but terminals stay open | +| `▶ iOS: E2E` | Backend · Frontend · iOS Simulator · Maestro | iOS Maestro flow tests — boots simulator, waits for app, then runs tests (terminals auto-close) | +| `▶ iOS: E2E (keep)` | same | Same, but terminals stay open | +| `▶ Android: E2E` | Backend · Frontend · Android Emulator · Maestro | Android Maestro flow tests — boots emulator, waits for app, then runs tests (terminals auto-close) | +| `▶ Android: E2E (keep)` | same | Same, but terminals stay open | --- From f1d877d0a5b59620e4d9426df6348c3f22d70fec Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 20:58:50 -0400 Subject: [PATCH 05/23] fix(vscode): fix Maestro Java PATH and improve sim/emu wait condition Set JAVA_HOME from SDKMAN in Maestro task commands so VS Code non-interactive shell can find Java (SDKMAN init scripts do not run in task shells). Replace xcrun simctl booted check with xcrun simctl get_app_container for iOS and adb pm list packages for Android so Maestro only starts once the app is actually installed, not just when the OS boots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d43356a..cc395af 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -435,7 +435,7 @@ { "label": "[ios-e2e] Maestro", "type": "shell", - "command": "echo 'Waiting for booted iOS simulator...' && until xcrun simctl list devices booted 2>/dev/null | grep -q 'Booted'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -468,7 +468,7 @@ { "label": "[android-e2e] Maestro", "type": "shell", - "command": "echo 'Waiting for Android emulator...' && until adb devices 2>/dev/null | grep -v 'List of devices' | grep -q 'device$'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -549,7 +549,7 @@ { "label": "[ios-e2e-keep] Maestro", "type": "shell", - "command": "echo 'Waiting for booted iOS simulator...' && until xcrun simctl list devices booted 2>/dev/null | grep -q 'Booted'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, @@ -582,7 +582,7 @@ { "label": "[android-e2e-keep] Maestro", "type": "shell", - "command": "echo 'Waiting for Android emulator...' && until adb devices 2>/dev/null | grep -v 'List of devices' | grep -q 'device$'; do sleep 5; done && sleep 3 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, From cf959f0f4079bf1983d69d0846f68a2d5972ef40 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:04:35 -0400 Subject: [PATCH 06/23] fix(maestro): add env.local credentials support for test flows Add .maestro/.env.example with MAESTRO_TEST_EMAIL and MAESTRO_TEST_PASSWORD placeholders. Add .maestro/.env.local to .gitignore so real credentials are never committed. Update iOS/Android E2E VS Code tasks to load .env.local and pass each var as --env flags to maestro test. Update docs with one-time setup instructions and Java LTS requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 +++ .maestro/.env.example | 2 ++ .vscode/tasks.json | 8 ++++---- docs/development.md | 24 +++++++++++++++++++----- 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 .maestro/.env.example diff --git a/.gitignore b/.gitignore index 59e33cd..ab931d6 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ test-results/ # Misc .turbo/ + +# Maestro test credentials +.maestro/.env.local diff --git a/.maestro/.env.example b/.maestro/.env.example new file mode 100644 index 0000000..4f8f48b --- /dev/null +++ b/.maestro/.env.example @@ -0,0 +1,2 @@ +MAESTRO_TEST_EMAIL=your-test-account@example.com +MAESTRO_TEST_PASSWORD=your-test-password diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cc395af..8c84045 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -435,7 +435,7 @@ { "label": "[ios-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -468,7 +468,7 @@ { "label": "[android-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -549,7 +549,7 @@ { "label": "[ios-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, @@ -582,7 +582,7 @@ { "label": "[android-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && maestro test .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, diff --git a/docs/development.md b/docs/development.md index 143f175..77b6abf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -409,17 +409,31 @@ cd frontend && pnpm exec playwright show-report ### Maestro (iOS / Android native) -**Prerequisites:** Maestro CLI ≥ v1.38 (`curl -Ls "https://get.maestro.mobile.dev" | bash`), a running iOS or Android simulator with the app installed. +**Prerequisites:** Maestro CLI ≥ v1.38 (`curl -Ls "https://get.maestro.mobile.dev" | bash`), a stable Java LTS (Java 21 recommended — see below), and a running iOS simulator or Android emulator with the app installed. + +**One-time setup — credentials:** + +```bash +cp .maestro/.env.example .maestro/.env.local +# Edit .maestro/.env.local with your test account credentials +``` + +The `▶ iOS: E2E` and `▶ Android: E2E` VS Code tasks load `.maestro/.env.local` automatically. ```bash -# Run all flows in order (auth → feed-load → task-complete) -MAESTRO_TEST_EMAIL= MAESTRO_TEST_PASSWORD= maestro test .maestro/flows/ +# Run all flows manually (auth → feed-load → task-complete) +maestro test --env MAESTRO_TEST_EMAIL= --env MAESTRO_TEST_PASSWORD= .maestro/flows/ # Run a single flow -maestro test .maestro/flows/auth.yaml +maestro test --env MAESTRO_TEST_EMAIL= --env MAESTRO_TEST_PASSWORD= .maestro/flows/auth.yaml ``` -Flows skip gracefully when `MAESTRO_TEST_EMAIL` is not set. +**Java version:** Maestro 2.3.0 fails to parse Java EA version strings (e.g. `27-ea`). Install a stable LTS via SDKMAN: + +```bash +sdk install java 21.0.10-tem +sdk default java 21.0.10-tem +``` --- From 7402a28b97053598ec45d66bf41db95bb046d652 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:11:49 -0400 Subject: [PATCH 07/23] fix(vscode): fix simulator network by adding livereload and --host flags iOS simulator and Android emulator cannot reach localhost on the host machine. Use cap run --livereload --external so Capacitor sets server.url to the Mac LAN IP automatically. Add --host to Vite dev for those same scenarios so Vite binds to all interfaces and is reachable from the simulator/emulator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8c84045..201b065 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -245,7 +245,7 @@ { "label": "[ios-sim] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -253,7 +253,7 @@ { "label": "[ios-sim] Simulator", "type": "shell", - "command": "pnpm exec cap run ios", + "command": "pnpm exec cap run ios --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -297,7 +297,7 @@ { "label": "[android-emu] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -305,7 +305,7 @@ { "label": "[android-emu] Emulator", "type": "shell", - "command": "pnpm exec cap run android", + "command": "pnpm exec cap run android --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -419,7 +419,7 @@ { "label": "[ios-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -427,7 +427,7 @@ { "label": "[ios-e2e] Simulator", "type": "shell", - "command": "pnpm exec cap run ios", + "command": "pnpm exec cap run ios --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -452,7 +452,7 @@ { "label": "[android-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -460,7 +460,7 @@ { "label": "[android-e2e] Emulator", "type": "shell", - "command": "pnpm exec cap run android", + "command": "pnpm exec cap run android --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -533,7 +533,7 @@ { "label": "[ios-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -541,7 +541,7 @@ { "label": "[ios-e2e-keep] Simulator", "type": "shell", - "command": "pnpm exec cap run ios", + "command": "pnpm exec cap run ios --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -566,7 +566,7 @@ { "label": "[android-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev", + "command": "pnpm --filter frontend dev --host", "isBackground": true, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -574,7 +574,7 @@ { "label": "[android-e2e-keep] Emulator", "type": "shell", - "command": "pnpm exec cap run android", + "command": "pnpm exec cap run android --livereload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] From e857b233c1b0bb61a8e7c32c21e59b0de6483f3c Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:12:48 -0400 Subject: [PATCH 08/23] fix(vscode): correct --livereload flag to --live-reload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 201b065..ca87d1d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -253,7 +253,7 @@ { "label": "[ios-sim] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --livereload --external", + "command": "pnpm exec cap run ios --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -305,7 +305,7 @@ { "label": "[android-emu] Emulator", "type": "shell", - "command": "pnpm exec cap run android --livereload --external", + "command": "pnpm exec cap run android --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -427,7 +427,7 @@ { "label": "[ios-e2e] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --livereload --external", + "command": "pnpm exec cap run ios --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -460,7 +460,7 @@ { "label": "[android-e2e] Emulator", "type": "shell", - "command": "pnpm exec cap run android --livereload --external", + "command": "pnpm exec cap run android --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -541,7 +541,7 @@ { "label": "[ios-e2e-keep] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --livereload --external", + "command": "pnpm exec cap run ios --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -574,7 +574,7 @@ { "label": "[android-e2e-keep] Emulator", "type": "shell", - "command": "pnpm exec cap run android --livereload --external", + "command": "pnpm exec cap run android --live-reload --external", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] From ea5b3b25e8bac72a766a0f4a90817052cf947961 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:13:42 -0400 Subject: [PATCH 09/23] fix(vscode): replace --external with --host for cap run live-reload --external is not a valid cap run flag. Use --host with the LAN IP detected at runtime via ipconfig getifaddr so the simulator connects to the correct Vite dev server address. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ca87d1d..0a135b1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -253,7 +253,7 @@ { "label": "[ios-sim] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --external", + "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -305,7 +305,7 @@ { "label": "[android-emu] Emulator", "type": "shell", - "command": "pnpm exec cap run android --live-reload --external", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -427,7 +427,7 @@ { "label": "[ios-e2e] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --external", + "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -460,7 +460,7 @@ { "label": "[android-e2e] Emulator", "type": "shell", - "command": "pnpm exec cap run android --live-reload --external", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -541,7 +541,7 @@ { "label": "[ios-e2e-keep] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --external", + "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -574,7 +574,7 @@ { "label": "[android-e2e-keep] Emulator", "type": "shell", - "command": "pnpm exec cap run android --live-reload --external", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] From 7981c30c79bfdbc0c1ed42760ae2c4c6968f61cf Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:23:09 -0400 Subject: [PATCH 10/23] fix(simulator): fix API URL unreachable from iOS simulator The WebView in the simulator cannot reach localhost on the host machine. Add a simulator Vite mode (.env.simulator.local) that sets VITE_API_URL and VITE_APP_URL to the Mac LAN IP so all API calls route correctly. Add dev:simulator script to frontend/package.json. Update all simulator and emulator VS Code tasks to use dev:simulator instead of dev --host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 +++ .vscode/tasks.json | 12 ++++++------ frontend/.env.simulator.example | 6 ++++++ frontend/package.json | 3 ++- 4 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 frontend/.env.simulator.example diff --git a/.gitignore b/.gitignore index ab931d6..42a5239 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ test-results/ # Maestro test credentials .maestro/.env.local + +# Simulator/emulator env overrides +frontend/.env.simulator.local diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0a135b1..4d4530f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -245,7 +245,7 @@ { "label": "[ios-sim] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -297,7 +297,7 @@ { "label": "[android-emu] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -419,7 +419,7 @@ { "label": "[ios-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -452,7 +452,7 @@ { "label": "[android-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -533,7 +533,7 @@ { "label": "[ios-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -566,7 +566,7 @@ { "label": "[android-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev --host", + "command": "pnpm --filter frontend dev:simulator", "isBackground": true, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] diff --git a/frontend/.env.simulator.example b/frontend/.env.simulator.example new file mode 100644 index 0000000..3f757b1 --- /dev/null +++ b/frontend/.env.simulator.example @@ -0,0 +1,6 @@ +# iOS Simulator / Android Emulator overrides — copy to .env.simulator.local and fill in your LAN IP +# Loaded on top of .env when running: pnpm dev:simulator (vite --mode simulator) +# Find your LAN IP: ipconfig getifaddr en0 + +VITE_API_URL=http://YOUR_LAN_IP:4000 +VITE_APP_URL=http://YOUR_LAN_IP:3000 diff --git a/frontend/package.json b/frontend/package.json index 96acb6e..66483c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "lint": "eslint src --ext ts,tsx", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "dev:simulator": "vite --mode simulator" }, "dependencies": { "@capacitor/android": "^8.2.0", From 0af8b993b66efd9858b538787e04120da85e7712 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:34:59 -0400 Subject: [PATCH 11/23] fix(simulator): fix CORS by passing LAN IP as NATIVE_APP_ORIGINS to backend The simulator WebView sends requests from http://10.0.0.x:3000 but the backend CORS allowlist only contained localhost. The live-reload origin was being blocked. Inject NATIVE_APP_ORIGINS dynamically in simulator backend tasks using the runtime LAN IP so credentials are accepted from the WebView origin. Also add --host to dev:simulator so Vite binds to all interfaces. Update backend .env.example with simulator setup instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 20 ++++++++++---------- backend/.env.example | 3 +++ frontend/package.json | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4d4530f..db82899 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -237,7 +237,7 @@ { "label": "[ios-sim] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -289,7 +289,7 @@ { "label": "[android-emu] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -411,7 +411,7 @@ { "label": "[ios-e2e] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -435,7 +435,7 @@ { "label": "[ios-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -444,7 +444,7 @@ { "label": "[android-e2e] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -468,7 +468,7 @@ { "label": "[android-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -525,7 +525,7 @@ { "label": "[ios-e2e-keep] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -549,7 +549,7 @@ { "label": "[ios-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, @@ -558,7 +558,7 @@ { "label": "[android-e2e-keep] Backend", "type": "shell", - "command": "pnpm --filter backend dev", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -582,7 +582,7 @@ { "label": "[android-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 2 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, diff --git a/backend/.env.example b/backend/.env.example index 68d1efa..0ca3585 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,9 @@ API_URL="http://localhost:4000" # Native app origins — comma-separated list of allowed webview origins for Capacitor (iOS/Android) # and Tauri (macOS/Windows). These are merged with APP_URL in the backend CORS allowlist so that # credentialed requests from native app webviews are accepted. +# For iOS simulator / Android emulator live-reload, also add your LAN IP: +# NATIVE_APP_ORIGINS=capacitor://localhost,tauri://localhost,http://tauri.localhost,http://YOUR_LAN_IP:3000 +# Find your LAN IP: ipconfig getifaddr en0 NATIVE_APP_ORIGINS=capacitor://localhost,tauri://localhost,http://tauri.localhost # Environment diff --git a/frontend/package.json b/frontend/package.json index 66483c3..62ef554 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "dev:simulator": "vite --mode simulator" + "dev:simulator": "vite --mode simulator --host" }, "dependencies": { "@capacitor/android": "^8.2.0", From caa53ac25122a5ead398268a36cee50433b9d508 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:38:31 -0400 Subject: [PATCH 12/23] fix(simulator): simplify iOS to use localhost, Android uses 10.0.2.2 iOS simulator shares the Mac network stack so localhost resolves correctly - no LAN IP or NATIVE_APP_ORIGINS needed. Android emulator uses 10.0.2.2 as the host loopback alias. Add dev:android Vite mode with .env.android.local for Android tasks. Android backend tasks keep NATIVE_APP_ORIGINS with LAN IP for CORS. Rename dev:simulator -> dev:ios and .env.simulator -> .env.ios to match the platform naming convention used by .env.android. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 ++- .vscode/tasks.json | 24 ++++++++++++------------ frontend/.env.android.example | 4 ++++ frontend/.env.ios.example | 5 +++++ frontend/.env.simulator.example | 6 ------ frontend/package.json | 3 ++- 6 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 frontend/.env.android.example create mode 100644 frontend/.env.ios.example delete mode 100644 frontend/.env.simulator.example diff --git a/.gitignore b/.gitignore index 42a5239..b9c9de1 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,5 @@ test-results/ .maestro/.env.local # Simulator/emulator env overrides -frontend/.env.simulator.local +frontend/.env.ios.local +frontend/.env.android.local diff --git a/.vscode/tasks.json b/.vscode/tasks.json index db82899..52b5764 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -237,7 +237,7 @@ { "label": "[ios-sim] Backend", "type": "shell", - "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "command": "pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -245,7 +245,7 @@ { "label": "[ios-sim] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:ios", "isBackground": true, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -253,7 +253,7 @@ { "label": "[ios-sim] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "command": "pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -297,7 +297,7 @@ { "label": "[android-emu] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:android", "isBackground": true, "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -411,7 +411,7 @@ { "label": "[ios-e2e] Backend", "type": "shell", - "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "command": "pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -419,7 +419,7 @@ { "label": "[ios-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:ios", "isBackground": true, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -427,7 +427,7 @@ { "label": "[ios-e2e] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "command": "pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -452,7 +452,7 @@ { "label": "[android-e2e] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:android", "isBackground": true, "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -525,7 +525,7 @@ { "label": "[ios-e2e-keep] Backend", "type": "shell", - "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "command": "pnpm --filter backend dev", "isBackground": true, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -533,7 +533,7 @@ { "label": "[ios-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:ios", "isBackground": true, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -541,7 +541,7 @@ { "label": "[ios-e2e-keep] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "command": "pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -566,7 +566,7 @@ { "label": "[android-e2e-keep] Frontend", "type": "shell", - "command": "pnpm --filter frontend dev:simulator", + "command": "pnpm --filter frontend dev:android", "isBackground": true, "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] diff --git a/frontend/.env.android.example b/frontend/.env.android.example new file mode 100644 index 0000000..658c0b2 --- /dev/null +++ b/frontend/.env.android.example @@ -0,0 +1,4 @@ +# Android Emulator overrides — copy to .env.android.local +# Android emulator uses 10.0.2.2 as the alias for the host Mac's localhost. +VITE_API_URL=http://10.0.2.2:4000 +VITE_APP_URL=http://10.0.2.2:3000 diff --git a/frontend/.env.ios.example b/frontend/.env.ios.example new file mode 100644 index 0000000..129ef2b --- /dev/null +++ b/frontend/.env.ios.example @@ -0,0 +1,5 @@ +# iOS Simulator overrides — copy to .env.simulator.local +# iOS simulator shares the Mac network stack, so localhost works directly. +# No changes needed from defaults — this file is a no-op placeholder. +VITE_API_URL=http://localhost:4000 +VITE_APP_URL=http://localhost:3000 diff --git a/frontend/.env.simulator.example b/frontend/.env.simulator.example deleted file mode 100644 index 3f757b1..0000000 --- a/frontend/.env.simulator.example +++ /dev/null @@ -1,6 +0,0 @@ -# iOS Simulator / Android Emulator overrides — copy to .env.simulator.local and fill in your LAN IP -# Loaded on top of .env when running: pnpm dev:simulator (vite --mode simulator) -# Find your LAN IP: ipconfig getifaddr en0 - -VITE_API_URL=http://YOUR_LAN_IP:4000 -VITE_APP_URL=http://YOUR_LAN_IP:3000 diff --git a/frontend/package.json b/frontend/package.json index 62ef554..0e87888 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "dev:simulator": "vite --mode simulator --host" + "dev:android": "vite --mode android --host", + "dev:ios": "vite --mode ios --host" }, "dependencies": { "@capacitor/android": "^8.2.0", From 4b4bbc40ead8717ab4c623a476ba0bd6ee6ad45d Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:40:00 -0400 Subject: [PATCH 13/23] docs: update env vars and iOS/Android simulator setup Document platform-specific frontend env files (.env.ios, .env.android, .env.device) with a summary table. Update iOS simulator section to reflect live-reload workflow and explain why localhost works. Clarify NATIVE_APP_ORIGINS Android emulator note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/development.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/development.md b/docs/development.md index 77b6abf..935eee0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -90,7 +90,7 @@ TOKEN_ENCRYPTION_KEY="" | `MICROSOFT_TENANT_ID` | `common` | Azure AD tenant ID — defaults to multi-tenant | | `APP_URL` | `http://localhost:3000` | Frontend URL | | `API_URL` | `http://localhost:4000` | Backend URL | -| `NATIVE_APP_ORIGINS` | `capacitor://localhost,tauri://localhost,http://tauri.localhost` | Allowed CORS origins for native webviews | +| `NATIVE_APP_ORIGINS` | `capacitor://localhost,tauri://localhost,http://tauri.localhost` | Allowed CORS origins for native webviews. For Android emulator live-reload, the VS Code tasks automatically append `http://10.0.2.2:3000` at runtime. | | `NODE_ENV` | `development` | | | `PORT` | `4000` | Backend port | | `LOG_LEVEL` | `info` | Log verbosity — `trace` \| `debug` \| `info` \| `warn` \| `error` \| `fatal` | @@ -107,6 +107,26 @@ TOKEN_ENCRYPTION_KEY="" | `VITE_DEV_APPLE_APP_SPECIFIC_PASSWORD` | *(optional)* | Pre-fills Apple CalDAV credential form locally | | `E2E_SESSION_COOKIE` | *(optional)* | Session token for authenticated Playwright e2e tests — see [E2E testing](#e2e-testing) | +#### Platform-specific frontend env overrides + +Each platform has its own env file loaded on top of `frontend/.env`. Copy the example and fill in values once: + +| Platform | Script | Example file | Local file (gitignored) | Notes | +|----------|--------|-------------|------------------------|-------| +| iOS simulator | `pnpm dev:ios` | `.env.ios.example` | `.env.ios.local` | Uses `localhost` — iOS simulator shares Mac's network stack | +| Android emulator | `pnpm dev:android` | `.env.android.example` | `.env.android.local` | Uses `10.0.2.2` — Android's alias for host loopback | +| Physical device | `pnpm dev:device` | `.env.device.example` | `.env.device.local` | Uses ngrok HTTPS URL | + +```bash +# One-time setup for iOS simulator +cp frontend/.env.ios.example frontend/.env.ios.local +# No edits needed — localhost works out of the box + +# One-time setup for Android emulator +cp frontend/.env.android.example frontend/.env.android.local +# No edits needed — 10.0.2.2 is the standard Android host alias +``` + --- ## One-time integration setup @@ -271,17 +291,17 @@ With `pnpm dev` running, open `http://localhost:3000`. Sign in with email/passwo **Prerequisites:** Xcode 15+, Capacitor iOS project initialized (run once: `cd frontend && pnpm cap add ios`) +The VS Code task `▶ iOS: Simulator Dev` handles everything automatically. To run manually: + ```bash # 1. Sync Capacitor cd frontend && pnpm cap sync ios -# 2. Open in Xcode -pnpm cap open ios - -# 3. In Xcode: select an iPhone simulator → Run (▶) +# 2. Run with live-reload (connects simulator to local Vite dev server) +pnpm cap run ios --live-reload --host localhost ``` -The app will connect to `http://localhost:4000` directly — no ngrok needed for the simulator. +The iOS simulator shares the Mac's network stack — `localhost` resolves to your Mac directly, so no ngrok or LAN IP setup is needed. **Sign in with Apple on simulator:** Ensure you have an Apple ID configured in the simulator's **Settings → Sign in to your iPhone** before testing. From 57c213105c1b85e8a27857df452a1631e6476f41 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:41:50 -0400 Subject: [PATCH 14/23] chore(tasks): shutdown existing iOS simulators before launching new one Prepend 'xcrun simctl shutdown all' to all three iOS Simulator tasks ([ios-sim], [ios-e2e], [ios-e2e-keep]) so stale simulator sessions are always cleaned up before cap run starts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 52b5764..bff9ace 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -253,7 +253,7 @@ { "label": "[ios-sim] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host localhost", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -427,7 +427,7 @@ { "label": "[ios-e2e] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host localhost", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] @@ -541,7 +541,7 @@ { "label": "[ios-e2e-keep] Simulator", "type": "shell", - "command": "pnpm exec cap run ios --live-reload --host localhost", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", "options": { "cwd": "${workspaceFolder}/frontend" }, "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] From c3461613daa16048cf80a72ff7268efe81afbfcf Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 21:44:22 -0400 Subject: [PATCH 15/23] test(maestro): add login subflow; auth flows self-contained Extract login steps into subflows/login.yaml. Update feed-load and task-complete to clearState + launchApp + runFlow the subflow so each flow is independently runnable and does not depend on auth.yaml having run first in the same session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .maestro/flows/feed-load.yaml | 5 +++-- .maestro/flows/subflows/login.yaml | 20 ++++++++++++++++++++ .maestro/flows/task-complete.yaml | 9 +++------ 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 .maestro/flows/subflows/login.yaml diff --git a/.maestro/flows/feed-load.yaml b/.maestro/flows/feed-load.yaml index d417236..91344af 100644 --- a/.maestro/flows/feed-load.yaml +++ b/.maestro/flows/feed-load.yaml @@ -2,10 +2,11 @@ appId: com.ordrctrl.app --- # Flow 2: feed-load.yaml — verify feed loads with tasks after authentication # FR-011 | maestro-flows-contract.md Flow 2 -# Assumes authenticated session established by auth.yaml -# Launch app (session persists from auth.yaml) +# Start fresh and authenticate +- clearState - launchApp +- runFlow: subflows/login.yaml # Verify feed header is visible - assertVisible: diff --git a/.maestro/flows/subflows/login.yaml b/.maestro/flows/subflows/login.yaml new file mode 100644 index 0000000..154a432 --- /dev/null +++ b/.maestro/flows/subflows/login.yaml @@ -0,0 +1,20 @@ +appId: com.ordrctrl.app +--- +# Subflow: login.yaml — authenticate without clearing state +# Intended to be called via runFlow from flows that require auth. +# Does NOT call clearState — caller controls app/session state. + +- tapOn: + text: "Email" +- inputText: ${MAESTRO_TEST_EMAIL} + +- tapOn: + text: "Password" +- inputText: ${MAESTRO_TEST_PASSWORD} + +- tapOn: + text: "Sign In" + +- waitForAnimationToEnd +- assertVisible: + text: "ordrctrl" diff --git a/.maestro/flows/task-complete.yaml b/.maestro/flows/task-complete.yaml index f7c9565..eca13c7 100644 --- a/.maestro/flows/task-complete.yaml +++ b/.maestro/flows/task-complete.yaml @@ -2,14 +2,11 @@ appId: com.ordrctrl.app --- # Flow 3: task-complete.yaml — complete a task and verify it moves to Completed section # FR-012 | maestro-flows-contract.md Flow 3 -# Assumes authenticated session and at least one task visible +# Start fresh and authenticate +- clearState - launchApp -- waitForAnimationToEnd - -# Verify we're on the feed -- assertVisible: - text: "ordrctrl" +- runFlow: subflows/login.yaml # Tap the first visible task's complete checkbox # Capacitor WebView exposes aria-label as accessibility ID on iOS and Android From a801b590ec0b8e42d7c2dec2f6bdb92f9806c125 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 22:07:40 -0400 Subject: [PATCH 16/23] test(maestro): dynamic test user setup/teardown via JS + backend test routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add e2e-suite.yaml orchestrator that runs all Maestro flows in order with a shared output.testUser context. setup.js creates a fresh pre-verified user via HTTP before flows run; teardown.js deletes it after. Credentials flow through output.testUser.email/password instead of static --env flags. - .maestro/e2e-suite.yaml — orchestrator: setup → auth → feed-load → task-complete → teardown - .maestro/scripts/setup.js — POST /api/test/create-user, store in output.testUser - .maestro/scripts/teardown.js — DELETE /api/test/delete-user - flows/auth.yaml + subflows/login.yaml — use output.testUser.email/password - backend/src/api/test.routes.ts — POST/DELETE /api/test/* (ENABLE_TEST_ROUTES guard) - backend/src/app.ts — register test routes when ENABLE_TEST_ROUTES=true - backend/.env.example — document ENABLE_TEST_ROUTES - .vscode/tasks.json — point Maestro tasks at e2e-suite.yaml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .maestro/e2e-suite.yaml | 25 ++++++++++ .maestro/flows/auth.yaml | 4 +- .maestro/flows/subflows/login.yaml | 4 +- .maestro/scripts/setup.js | 19 ++++++++ .maestro/scripts/teardown.js | 14 ++++++ .vscode/tasks.json | 12 ++--- backend/.env.example | 1 + backend/src/api/test.routes.ts | 73 ++++++++++++++++++++++++++++++ backend/src/app.ts | 7 +++ 9 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 .maestro/e2e-suite.yaml create mode 100644 .maestro/scripts/setup.js create mode 100644 .maestro/scripts/teardown.js create mode 100644 backend/src/api/test.routes.ts diff --git a/.maestro/e2e-suite.yaml b/.maestro/e2e-suite.yaml new file mode 100644 index 0000000..1644a12 --- /dev/null +++ b/.maestro/e2e-suite.yaml @@ -0,0 +1,25 @@ +appId: com.ordrctrl.app +--- +# E2E suite orchestrator — runs all flows in order with a shared test user. +# output.testUser is set by setup.js and is available to all runFlow sub-flows. +# +# Usage: maestro test .maestro/e2e-suite.yaml +# +# Requires: +# - Backend running with ENABLE_TEST_ROUTES=true +# - App installed on simulator/emulator + +# Step 1: Create a fresh test user +- runScript: scripts/setup.js + +# Step 2: Auth flow — verifies sign-in works +- runFlow: flows/auth.yaml + +# Step 3: Feed load — verifies tasks appear after auth +- runFlow: flows/feed-load.yaml + +# Step 4: Task complete — verifies task can be marked done +- runFlow: flows/task-complete.yaml + +# Step 5: Teardown — delete the test user +- runScript: scripts/teardown.js diff --git a/.maestro/flows/auth.yaml b/.maestro/flows/auth.yaml index 5173e49..13ee874 100644 --- a/.maestro/flows/auth.yaml +++ b/.maestro/flows/auth.yaml @@ -16,11 +16,11 @@ appId: com.ordrctrl.app # Step 3: Authenticate with test credentials (FR-011) - tapOn: text: "Email" -- inputText: ${MAESTRO_TEST_EMAIL} +- inputText: ${output.testUser.email} - tapOn: text: "Password" -- inputText: ${MAESTRO_TEST_PASSWORD} +- inputText: ${output.testUser.password} - tapOn: text: "Sign In" diff --git a/.maestro/flows/subflows/login.yaml b/.maestro/flows/subflows/login.yaml index 154a432..732455a 100644 --- a/.maestro/flows/subflows/login.yaml +++ b/.maestro/flows/subflows/login.yaml @@ -6,11 +6,11 @@ appId: com.ordrctrl.app - tapOn: text: "Email" -- inputText: ${MAESTRO_TEST_EMAIL} +- inputText: ${output.testUser.email} - tapOn: text: "Password" -- inputText: ${MAESTRO_TEST_PASSWORD} +- inputText: ${output.testUser.password} - tapOn: text: "Sign In" diff --git a/.maestro/scripts/setup.js b/.maestro/scripts/setup.js new file mode 100644 index 0000000..aa80970 --- /dev/null +++ b/.maestro/scripts/setup.js @@ -0,0 +1,19 @@ +// setup.js — Maestro setup script +// Creates a fresh test user via the backend test API before E2E flows run. +// Stores credentials in output.testUser so all sub-flows can reference them. +// Requires ENABLE_TEST_ROUTES=true in backend .env. + +var apiBase = maestro.platform === 'android' ? 'http://10.0.2.2:4000' : 'http://localhost:4000'; +var email = 'e2e-' + Date.now() + '@ordrctrl.test'; +var password = 'TestPass1!'; + +var response = http.post(apiBase + '/api/test/create-user', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, password: password }) +}); + +if (!response.ok) { + throw new Error('Failed to create test user (' + response.status + '): ' + response.body); +} + +output.testUser = { email: email, password: password }; diff --git a/.maestro/scripts/teardown.js b/.maestro/scripts/teardown.js new file mode 100644 index 0000000..686b028 --- /dev/null +++ b/.maestro/scripts/teardown.js @@ -0,0 +1,14 @@ +// teardown.js — Maestro teardown script +// Deletes the test user created by setup.js after all E2E flows complete. +// Requires ENABLE_TEST_ROUTES=true in backend .env. + +var apiBase = maestro.platform === 'android' ? 'http://10.0.2.2:4000' : 'http://localhost:4000'; + +var response = http.delete(apiBase + '/api/test/delete-user', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: output.testUser.email }) +}); + +if (!response.ok) { + throw new Error('Failed to delete test user (' + response.status + '): ' + response.body); +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bff9ace..f5988a2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -119,7 +119,7 @@ { "label": "iOS: E2E (Maestro)", "type": "shell", - "command": "maestro test .maestro/flows/", + "command": "maestro test .maestro/e2e-suite.yaml", "group": "test", "presentation": { "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -146,7 +146,7 @@ { "label": "Android: E2E (Maestro)", "type": "shell", - "command": "maestro test .maestro/flows/", + "command": "maestro test .maestro/e2e-suite.yaml", "group": "test", "presentation": { "panel": "new", "reveal": "always" }, "problemMatcher": [] @@ -435,7 +435,7 @@ { "label": "[ios-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && maestro test .maestro/e2e-suite.yaml", "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -468,7 +468,7 @@ { "label": "[android-e2e] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && maestro test .maestro/e2e-suite.yaml", "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, "problemMatcher": [] }, @@ -549,7 +549,7 @@ { "label": "[ios-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && maestro test .maestro/e2e-suite.yaml", "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, @@ -582,7 +582,7 @@ { "label": "[android-e2e-keep] Maestro", "type": "shell", - "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && ENV_FILE=.maestro/.env.local && maestro test $([ -f $ENV_FILE ] && grep -v '^#' $ENV_FILE | grep '=' | sed 's/^/--env /') .maestro/flows/", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && maestro test .maestro/e2e-suite.yaml", "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, "problemMatcher": [] }, diff --git a/backend/.env.example b/backend/.env.example index 0ca3585..a91b9b7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -37,6 +37,7 @@ NATIVE_APP_ORIGINS=capacitor://localhost,tauri://localhost,http://tauri.localhos # Environment NODE_ENV="development" +# ENABLE_TEST_ROUTES="true" # Uncomment to enable /api/test/* routes for E2E test setup/teardown. NEVER enable in production. PORT="4000" # LOG_LEVEL="info" # optional — trace | debug | info | warn | error | fatal diff --git a/backend/src/api/test.routes.ts b/backend/src/api/test.routes.ts new file mode 100644 index 0000000..73c0fd5 --- /dev/null +++ b/backend/src/api/test.routes.ts @@ -0,0 +1,73 @@ +// Test-only routes — only registered when ENABLE_TEST_ROUTES=true. +// NEVER enable in production. +// +// POST /api/test/create-user — creates a pre-verified test user +// DELETE /api/test/delete-user — deletes a test user by email + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { prisma } from '../lib/db.js'; +import { logger } from '../lib/logger.js'; + +const SALT_ROUNDS = 12; + +const createUserSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(8) + .regex(/[A-Z]/, 'Must contain uppercase') + .regex(/[0-9]/, 'Must contain number'), +}); + +const deleteUserSchema = z.object({ + email: z.string().email(), +}); + +export async function registerTestRoutes(app: FastifyInstance): Promise { + // POST /api/test/create-user + app.post('/api/test/create-user', async (request: FastifyRequest, reply: FastifyReply) => { + const result = createUserSchema.safeParse(request.body); + if (!result.success) { + return reply.status(422).send({ error: 'Validation Error', details: result.error.flatten() }); + } + + const { email, password } = result.data; + const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + if (existing) { + return reply.status(409).send({ error: 'Email already registered' }); + } + + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + const user = await prisma.user.create({ + data: { + email: email.toLowerCase(), + passwordHash, + authProvider: 'email', + emailVerified: true, + }, + }); + + logger.info('[test] Created test user', { userId: user.id, email: user.email }); + return reply.status(201).send({ email: user.email }); + }); + + // DELETE /api/test/delete-user + app.delete('/api/test/delete-user', async (request: FastifyRequest, reply: FastifyReply) => { + const result = deleteUserSchema.safeParse(request.body); + if (!result.success) { + return reply.status(422).send({ error: 'Validation Error', details: result.error.flatten() }); + } + + const { email } = result.data; + const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } + + await prisma.user.delete({ where: { id: user.id } }); + logger.info('[test] Deleted test user', { userId: user.id, email: user.email }); + return reply.status(204).send(); + }); +} diff --git a/backend/src/app.ts b/backend/src/app.ts index c828ded..deaf60f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { registerFeedRoutes } from './api/feed.routes.js'; import { registerTaskRoutes } from './api/tasks.routes.js'; import { registerUserRoutes } from './api/user.routes.js'; import { registerInboxRoutes } from './api/inbox.routes.js'; +import { registerTestRoutes } from './api/test.routes.js'; import { errorHandler, notFoundHandler } from './api/error-handler.js'; export async function createApp(): Promise { @@ -60,6 +61,12 @@ export async function createApp(): Promise { // Inbox routes (010-task-inbox) await registerInboxRoutes(app); + // Test-only routes (E2E setup/teardown — never enable in production) + if (process.env.ENABLE_TEST_ROUTES === 'true') { + await registerTestRoutes(app); + app.log.warn('ENABLE_TEST_ROUTES is enabled — test endpoints are active'); + } + // Error handlers app.setErrorHandler(errorHandler); app.setNotFoundHandler(notFoundHandler); From 4c56151db5f6ee9d2a1506d8287717618a7864eb Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 22:16:23 -0400 Subject: [PATCH 17/23] test(maestro): seed task in setup.js so feed-load/task-complete have items After creating the test user, login via API to get a session cookie then POST /api/tasks to seed one task. Without this feed-load fails because a fresh account has no tasks and 'Mark complete' is never visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .maestro/scripts/setup.js | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.maestro/scripts/setup.js b/.maestro/scripts/setup.js index aa80970..bb38d35 100644 --- a/.maestro/scripts/setup.js +++ b/.maestro/scripts/setup.js @@ -1,5 +1,5 @@ // setup.js — Maestro setup script -// Creates a fresh test user via the backend test API before E2E flows run. +// Creates a fresh test user, logs in, and seeds a task so the feed is non-empty. // Stores credentials in output.testUser so all sub-flows can reference them. // Requires ENABLE_TEST_ROUTES=true in backend .env. @@ -7,13 +7,38 @@ var apiBase = maestro.platform === 'android' ? 'http://10.0.2.2:4000' : 'http:// var email = 'e2e-' + Date.now() + '@ordrctrl.test'; var password = 'TestPass1!'; -var response = http.post(apiBase + '/api/test/create-user', { +// 1. Create pre-verified test user +var createResp = http.post(apiBase + '/api/test/create-user', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) }); +if (!createResp.ok) { + throw new Error('Failed to create test user (' + createResp.status + '): ' + createResp.body); +} + +// 2. Login to obtain a session cookie +var loginResp = http.post(apiBase + '/api/auth/login', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, password: password }) +}); +if (!loginResp.ok) { + throw new Error('Failed to login test user (' + loginResp.status + '): ' + loginResp.body); +} -if (!response.ok) { - throw new Error('Failed to create test user (' + response.status + '): ' + response.body); +// Extract session cookie (Set-Cookie: sessionId=xxx; Path=/; ...) +var setCookie = loginResp.headers['Set-Cookie'] || loginResp.headers['set-cookie'] || ''; +var sessionCookie = setCookie.split(';')[0]; // "sessionId=abc123" + +// 3. Seed a task so the feed has at least one item with "Mark complete" +var taskResp = http.post(apiBase + '/api/tasks', { + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookie + }, + body: JSON.stringify({ title: 'E2E test task' }) +}); +if (!taskResp.ok) { + throw new Error('Failed to seed task (' + taskResp.status + '): ' + taskResp.body); } output.testUser = { email: email, password: password }; From 2a472527650a8bcb101f9ccb1407e400107972d4 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 19 Mar 2026 22:21:55 -0400 Subject: [PATCH 18/23] fix(e2e): use HTML id attribute for Maestro selector on Mark complete button aria-label maps to iOS accessibilityLabel, but Maestro's id: selector matches accessibilityIdentifier (set by the HTML id attribute). Added id='mark-complete' / id='reopen-task' to the button in FeedItem.tsx and updated both Maestro flows to use id: 'mark-complete'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .maestro/flows/feed-load.yaml | 2 +- .maestro/flows/task-complete.yaml | 2 +- frontend/src/components/feed/FeedItem.tsx | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.maestro/flows/feed-load.yaml b/.maestro/flows/feed-load.yaml index 91344af..e496ddb 100644 --- a/.maestro/flows/feed-load.yaml +++ b/.maestro/flows/feed-load.yaml @@ -23,4 +23,4 @@ appId: com.ordrctrl.app # FR-011: verify at least one task item is visible (non-optional — core requirement) - assertVisible: - id: "Mark complete" + id: "mark-complete" diff --git a/.maestro/flows/task-complete.yaml b/.maestro/flows/task-complete.yaml index eca13c7..3ad6810 100644 --- a/.maestro/flows/task-complete.yaml +++ b/.maestro/flows/task-complete.yaml @@ -11,7 +11,7 @@ appId: com.ordrctrl.app # Tap the first visible task's complete checkbox # Capacitor WebView exposes aria-label as accessibility ID on iOS and Android - tapOn: - id: "Mark complete" + id: "mark-complete" # Wait for state update animation - waitForAnimationToEnd diff --git a/frontend/src/components/feed/FeedItem.tsx b/frontend/src/components/feed/FeedItem.tsx index 827f188..8e5a0a6 100644 --- a/frontend/src/components/feed/FeedItem.tsx +++ b/frontend/src/components/feed/FeedItem.tsx @@ -86,6 +86,7 @@ export function FeedItemRow({ item, onComplete, onUncomplete, onDismiss, onResto {/* Checkbox */} + + +
{/* Content */} diff --git a/frontend/src/components/inbox/InboxPage.tsx b/frontend/src/components/inbox/InboxPage.tsx index c382753..e187058 100644 --- a/frontend/src/components/inbox/InboxPage.tsx +++ b/frontend/src/components/inbox/InboxPage.tsx @@ -22,9 +22,9 @@ export function InboxPage() { } = useInbox(); return ( -
+
{/* Top nav */} -
+
ordrctrl @@ -37,7 +37,8 @@ export function InboxPage() {
{/* Main content */} -
+
+

Inbox

{!loading && total > 0 && ( @@ -90,6 +91,7 @@ export function InboxPage() {
)}
+
); }