diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c2883ccf..41e506dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,10 +7,16 @@ # batch of tooling bumps, and the two workspaces that hold their own manifests # are covered explicitly, since Dependabot does not follow pnpm workspaces from # the root. +# +# Every update targets `staging`, not `main`. Work reaches production through +# staging in this repository, and a dependency bump is not an exception: a PR +# opened against `main` cannot be merged by the normal flow, so the nine that +# were open had simply accumulated there unmergeable. version: 2 updates: - package-ecosystem: npm + target-branch: staging directory: / schedule: interval: weekly @@ -27,6 +33,7 @@ updates: labels: [dependencies, security] - package-ecosystem: npm + target-branch: staging directory: /web schedule: interval: weekly @@ -40,6 +47,7 @@ updates: labels: [dependencies, frontend] - package-ecosystem: npm + target-branch: staging directory: /docs schedule: interval: weekly @@ -55,6 +63,7 @@ updates: # Workflow actions pin to tags that go stale and are a supply-chain surface in # their own right. - package-ecosystem: github-actions + target-branch: staging directory: / schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cb3fb76..45cbf63e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,102 +224,101 @@ jobs: echo "All compliance tests must pass (100% required for ATProto data sovereignty)." >> $GITHUB_STEP_SUMMARY # ============================================================================== - # Stage 5: E2E Tests (DISABLED - re-enable when infrastructure is ready) + # Stage 5: E2E Tests # ============================================================================== - - # e2e-tests: - # name: E2E Tests (${{ matrix.browser }}) - # runs-on: ubuntu-latest - # needs: [integration-tests] - # strategy: - # fail-fast: false - # matrix: - # browser: ${{ github.event_name == 'pull_request' && fromJSON('["chromium"]') || fromJSON('["chromium", "firefox", "webkit"]') }} - # - # services: - # postgres: - # image: postgres:16-alpine - # env: - # POSTGRES_DB: chive_test - # POSTGRES_USER: chive - # POSTGRES_PASSWORD: chive_test_password - # ports: - # - 5432:5432 - # options: >- - # --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # - # redis: - # image: redis:7-alpine - # ports: - # - 6379:6379 - # options: >- - # --health-cmd "redis-cli ping" - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # - # env: - # POSTGRES_HOST: localhost - # POSTGRES_PORT: 5432 - # POSTGRES_USER: chive - # POSTGRES_PASSWORD: chive_test_password - # POSTGRES_DB: chive_test - # DATABASE_URL: postgresql://chive:chive_test_password@localhost:5432/chive_test - # REDIS_URL: redis://localhost:6379 - # ELASTICSEARCH_URL: http://localhost:9200 - # NEO4J_URI: bolt://localhost:7687 - # NEO4J_USER: neo4j - # NEO4J_PASSWORD: chive_test_password - # DISABLE_RATE_LIMITING: 'true' - # - # steps: - # - uses: actions/checkout@v4 - # - uses: ./.github/actions/setup-node-pnpm - # - uses: ./.github/actions/start-services - # - # - name: Cache Playwright browsers - # uses: actions/cache@v4 - # id: playwright-cache - # with: - # path: ~/.cache/ms-playwright - # key: playwright-${{ runner.os }}-${{ matrix.browser }}-${{ hashFiles('**/pnpm-lock.yaml') }} - # - # - name: Install Playwright browsers - # if: steps.playwright-cache.outputs.cache-hit != 'true' - # run: | - # # Always install chromium (needed for setup:auth project) - # pnpm exec playwright install --with-deps chromium - # # Install matrix browser if different from chromium - # if [ "${{ matrix.browser }}" != "chromium" ]; then - # pnpm exec playwright install --with-deps ${{ matrix.browser }} - # fi - # - # - name: Install Playwright dependencies (cached) - # if: steps.playwright-cache.outputs.cache-hit == 'true' - # run: | - # pnpm exec playwright install-deps chromium - # if [ "${{ matrix.browser }}" != "chromium" ]; then - # pnpm exec playwright install-deps ${{ matrix.browser }} - # fi - # - # - name: Run database migrations - # run: pnpm db:migrate:up # - # - name: Run E2E tests - # run: pnpm test:e2e --project="${{ matrix.browser }}:*" + # Only the unauthenticated project runs here. It covers the sign-in and + # landing pages — 24 tests, about two minutes — and is green. # - # - name: Upload Playwright report - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: playwright-report-${{ matrix.browser }} - # path: | - # playwright-report/ - # tests/e2e/test-results/ - # retention-days: 14 + # The authenticated project is 487 tests and takes hours on one worker, which + # is too slow to gate every pull request and has not been triaged end to end. + # Enabling it needs its own pass: a decision about sharding, and a review of + # what currently fails. Running the part that is known good is worth more + # than running nothing, which is what happened while the whole job sat + # commented out. + + e2e-tests: + name: E2E Tests (unauthenticated) + runs-on: ubuntu-latest + needs: [integration-tests] + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: chive_test + POSTGRES_USER: chive + POSTGRES_PASSWORD: chive_test_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: chive + POSTGRES_PASSWORD: chive_test_password + POSTGRES_DB: chive_test + DATABASE_URL: postgresql://chive:chive_test_password@localhost:5432/chive_test + PGHOST: localhost + PGPORT: 5432 + PGUSER: chive + PGPASSWORD: chive_test_password + PGDATABASE: chive_test + REDIS_URL: redis://localhost:6379 + ELASTICSEARCH_URL: http://localhost:9200 + NEO4J_URI: bolt://localhost:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: chive_test_password + ADMIN_DIDS: did:plc:test-admin-did + DISABLE_RATE_LIMITING: 'true' + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-pnpm + - uses: ./.github/actions/start-services + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-chromium-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install Playwright browser + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Run E2E tests + run: pnpm test:e2e --project="chromium:unauthenticated" + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + tests/e2e/test-results/ + retention-days: 14 # ============================================================================== # Stage 6: Build Verification diff --git a/README.md b/README.md index 3101c810..71f75472 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,13 @@ cd chive # Install dependencies pnpm install +# Generate the lexicon types. +# +# `src/lexicons/generated/` and `web/lib/api/generated/` are gitignored and +# built from `lexicons/`, so on a fresh clone they do not exist yet and +# `pnpm typecheck` fails with "Cannot find module" until this has run. +pnpm lexicons:generate + # Start the development database stack ./scripts/start-test-stack.sh @@ -192,12 +199,19 @@ pnpm db:migrate:down Chive uses a hybrid plugin architecture with dependency injection (TSyringe) and event hooks (EventEmitter2). -Plugins run in isolated sandboxes with declared permissions. Built-in plugins include: +Plugins run in isolated sandboxes with declared permissions. The plugins the +running services register today are: + +- arXiv, PsyArXiv, LingBuzz and Semantics Archive preprint metadata +- OpenReview review metadata +- Cosmik backlinks, connections, follows and link removals +- Margin notes and replies -- GitHub integration -- ORCID linking -- DOI registration -- Wikidata field import +`src/plugins/builtin/` holds more than are registered — GitHub, ORCID, DOI +registration, Wikidata, Crossref, OpenAlex, ROR, Zenodo, Figshare, Dryad, OSF, +Software Heritage and others are written but not constructed by any service. +This list names the ones that actually run; the others are wired up as the +integrations they belong to are finished. See [docs.chive.pub](https://docs.chive.pub) for plugin development details. diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 90b56339..5cb901c2 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -157,11 +157,13 @@ test.describe('Authentication', () => { const signInPage = new SignInPage(page); await signInPage.goto(); - // Form should have a visible label - const label = page.getByText('Handle or DID'); - await expect(label).toBeVisible(); - - // Input should be visible and focusable + // Assert the association, not merely that the words appear somewhere: + // `getByLabel` only matches when the label is actually bound to the + // control, which is the property a screen reader depends on. The old + // `getByText('Handle or DID')` matched the label and the description + // both, and would have passed even while the input had no accessible + // name at all — which is exactly the state the page was in. + await expect(page.getByLabel('Handle or DID')).toBeVisible(); await expect(signInPage.handleInput).toBeVisible(); // Button should be focusable diff --git a/tests/e2e/eprint/author-display.spec.ts b/tests/e2e/eprint/author-display.spec.ts index 5cd99ea3..2248466c 100644 --- a/tests/e2e/eprint/author-display.spec.ts +++ b/tests/e2e/eprint/author-display.spec.ts @@ -120,13 +120,14 @@ test.describe('Author Display - Badges and Indicators', () => { .or(page.locator('[data-testid="corresponding-badge"]')) .or(page.getByRole('img', { name: /corresponding/i })); - // May or may not be present depending on data - const isVisible = await correspondingBadge.isVisible({ timeout: 3000 }).catch(() => false); - // Just verify page loaded, badge is optional - expect(true).toBe(true); + // scripts/seed-test-data.ts sets isCorrespondingAuthor on the sole author + // of every seeded eprint, so this indicator is not optional and the test + // can assert it. It used to compute the visibility and discard the answer, + // so it passed whether or not the badge rendered. + await expect(correspondingBadge.first()).toBeVisible(); }); - test('displays highlighted author indicator (co-first)', async ({ page }) => { + test('shows no highlighted-author indicator when no author is highlighted', async ({ page }) => { await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); // Look for highlighted author indicator @@ -135,10 +136,11 @@ test.describe('Author Display - Badges and Indicators', () => { .or(page.locator('[data-testid="highlighted-badge"]')) .or(page.getByText(/\u2020/)); // Dagger symbol - // May or may not be present depending on data - const isVisible = await highlightedBadge.isVisible({ timeout: 3000 }).catch(() => false); - // Just verify page loaded, badge is optional - expect(true).toBe(true); + // The seed sets isHighlighted false on every author, so absence is what + // this page can assert — and it is worth asserting: a bug rendering the + // dagger unconditionally would mark every author co-first, which the + // previous form of this test could not have noticed. + await expect(highlightedBadge.first()).toBeHidden(); }); test('displays ORCID link when available', async ({ page }) => { @@ -179,13 +181,13 @@ test.describe('Author Display - Contribution Types', () => { // Look for contribution types const contributions = page.getByText(/conceptualization|methodology|investigation|writing/i); - const isVisible = await contributions.isVisible({ timeout: 3000 }).catch(() => false); - // Just verify expansion works, contributions are optional - expect(true).toBe(true); + // The seeded authors carry an empty contributions array, so no CRediT + // role should appear. That catches an expansion rendering placeholders. + await expect(contributions.first()).toBeHidden(); } }); - test('displays contribution degree when available', async ({ page }) => { + test('shows no contribution degree when the author has no contributions', async ({ page }) => { await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); // Look for contribution degrees (lead/equal/supporting) @@ -193,13 +195,9 @@ test.describe('Author Display - Contribution Types', () => { has: page.locator('[data-testid="contribution-degree"]').or(page.locator('.contribution')), }); - // May or may not be visible depending on data structure - const isVisible = await degrees - .first() - .isVisible({ timeout: 3000 }) - .catch(() => false); - // Just verify page loads, degrees are optional - expect(true).toBe(true); + // Same reasoning: with no contributions seeded, a degree chip appearing + // would be the page inventing data. + await expect(degrees.first()).toBeHidden(); }); }); @@ -212,10 +210,9 @@ test.describe('Author Display - External Authors', () => { // External authors (if any) should not have clickable links // This is tested implicitly - if no profile link, clicking does nothing const authorName = page.getByText(SEEDED_AUTHORS.white.displayName); + // The assertion above is the whole test: the seeded author renders by + // name. The trailing expect(true) added nothing. await expect(authorName).toBeVisible({ timeout: 10000 }); - - // Just verify page displays correctly - expect(true).toBe(true); }); test('external authors can show ORCID even without DID', async ({ page }) => { @@ -227,9 +224,9 @@ test.describe('Author Display - External Authors', () => { .getByRole('link', { name: /orcid/i }) .or(page.locator('a[href*="orcid.org"]')); - // Optional - may or may not have external authors with ORCID - const isVisible = await orcidLink.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); + // The seed gives this author an ORCID, so the link is not optional here. + await expect(orcidLink.first()).toBeVisible(); + await expect(orcidLink.first()).toHaveAttribute('href', /orcid\.org/); }); }); diff --git a/tests/e2e/fixtures/page-objects.ts b/tests/e2e/fixtures/page-objects.ts index 93fa83b9..80c621ae 100644 --- a/tests/e2e/fixtures/page-objects.ts +++ b/tests/e2e/fixtures/page-objects.ts @@ -247,8 +247,14 @@ export class SignInPage { constructor(page: Page) { this.page = page; - // The login form uses a handle/DID input (accessible name comes from placeholder) - this.handleInput = page.getByRole('textbox', { name: /bsky\.social/i }); + // The field is labelled "Handle or DID", and that label is what gives the + // textbox its accessible name. The previous selector matched the + // placeholder instead, which said `bsky.social` and now says + // `yourhandle.example.com` — so it stopped matching, and every test that + // touched the sign-in form failed on a locator rather than on behaviour. + // A label is also the right thing to bind to: it is the accessible name a + // screen reader announces, where a placeholder is not. + this.handleInput = page.getByRole('textbox', { name: /handle or did/i }); this.continueButton = page.getByRole('button', { name: /continue with at protocol/i }); // Server-side errors shown in Alert (exclude Next.js route announcer) this.errorMessage = page.locator('[role="alert"]:not(#__next-route-announcer__)'); diff --git a/tests/e2e/governance/contribution-type-approval.spec.ts b/tests/e2e/governance/contribution-type-approval.spec.ts index 30a27def..9aeff5f5 100644 --- a/tests/e2e/governance/contribution-type-approval.spec.ts +++ b/tests/e2e/governance/contribution-type-approval.spec.ts @@ -158,9 +158,9 @@ trailer << /Size 4 /Root 1 0 R >> .getByText(/recently approved|just approved/i) .or(page.locator('[data-testid="recently-approved"]')); - // This may or may not be visible depending on test data - const isVisible = await recentlyApproved.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); // Just verify page loads + // Whether anything was recently approved depends on test data, so that is + // not assertable. That the page rendered is. + await expect(page.getByRole('heading').first()).toBeVisible(); }); }); diff --git a/tests/e2e/governance/contribution-type-proposal.spec.ts b/tests/e2e/governance/contribution-type-proposal.spec.ts index 4a839683..b5b011da 100644 --- a/tests/e2e/governance/contribution-type-proposal.spec.ts +++ b/tests/e2e/governance/contribution-type-proposal.spec.ts @@ -322,9 +322,11 @@ test.describe('Contribution Type Proposals - Detail View', () => { .getByText(/external.*mapping|credit|cro|ontolog/i) .or(page.locator('[data-testid="external-mappings"]')); - // Mappings are optional - const isVisible = await mappingsSection.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); // Just verify page loaded + // Mappings are genuinely optional, so their presence is not assertable. + // What is assertable is that following the link reached a proposal page + // rather than an error, which is what "verify page loaded" meant. + await expect(page).toHaveURL(/\/governance\/proposals\//); + await expect(page.getByRole('heading').first()).toBeVisible(); } }); }); @@ -356,18 +358,29 @@ test.describe('Contribution Type Proposals - Existing Types', () => { /supervision/i, ]; - // At least one CRediT role should be visible if types are loaded - let foundRole = false; + // The loop used to compute `foundRole` and the assertion discarded it, so + // the test passed whether or not any CRediT role rendered. + let matched: RegExp | undefined; for (const role of creditRoles) { - const roleElement = page.getByText(role); - if (await roleElement.isVisible({ timeout: 1000 }).catch(() => false)) { - foundRole = true; + if ( + await page + .getByText(role) + .first() + .isVisible({ timeout: 1000 }) + .catch(() => false) + ) { + matched = role; break; } } - // If governance page shows types, at least one should be found - // (this may be 0 if types are on a different page) - expect(true).toBe(true); + // The governance page itself must render. + await expect(page.getByRole('heading').first()).toBeVisible(); + + // CRediT roles may legitimately live on a different page, so requiring one + // here would be wrong. Say so in the report rather than passing silently: + // a skip is visible, an unconditional pass is not. + test.skip(matched === undefined, 'No CRediT role is rendered on /governance'); + await expect(page.getByText(matched!).first()).toBeVisible(); }); }); diff --git a/tests/e2e/governance/contribution-type-voting.spec.ts b/tests/e2e/governance/contribution-type-voting.spec.ts index acc4d0c0..b26cb959 100644 --- a/tests/e2e/governance/contribution-type-voting.spec.ts +++ b/tests/e2e/governance/contribution-type-voting.spec.ts @@ -154,9 +154,14 @@ test.describe('Contribution Type Voting - Cast Vote', () => { .getByText(/your vote|voted|already voted/i) .or(page.locator('[data-testid="user-vote"]')); - // May show auth prompt instead if not authenticated - const isVisible = await confirmation.isVisible({ timeout: 5000 }).catch(() => false); - expect(true).toBe(true); // Just verify interaction works + // After clicking, the page must land in one of the two states this + // test's own comment names: a vote confirmation, or a prompt to sign + // in. Anything else — a blank panel, an unhandled error — is a real + // failure the previous unconditional pass could not surface. + const authPrompt = page + .getByText(/sign in|log in|authentication required/i) + .or(page.getByRole('dialog')); + await expect(confirmation.first().or(authPrompt.first())).toBeVisible({ timeout: 5000 }); } } }); @@ -227,9 +232,11 @@ test.describe('Contribution Type Voting - Duplicate Prevention', () => { .getByText(/you voted|your vote|voted/i) .or(page.locator('[data-testid="user-vote-indicator"]')); - // May or may not be visible depending on vote state - const isVisible = await votedIndicator.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); + // Reaching the proposal page is the assertable outcome of following the + // link; the indicator depends on vote state, which this test does not + // control. + await expect(page).toHaveURL(/\/governance\/proposals\//); + await expect(page.getByRole('heading').first()).toBeVisible(); } }); @@ -246,12 +253,20 @@ test.describe('Contribution Type Voting - Duplicate Prevention', () => { const approveButton = page.getByRole('button', { name: /approve/i }); const rejectButton = page.getByRole('button', { name: /reject/i }); - // Check if buttons exist and their state - const approveVisible = await approveButton.isVisible({ timeout: 3000 }).catch(() => false); - const rejectVisible = await rejectButton.isVisible({ timeout: 3000 }).catch(() => false); - - // Buttons either don't exist, are disabled, or show "voted" state - expect(true).toBe(true); + // The two buttons are a pair: a proposal page offering one without the + // other is broken regardless of vote state. That is assertable where + // "are they disabled" is not, since this test does not control whether + // the user has already voted. + const approveVisible = await approveButton + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false); + const rejectVisible = await rejectButton + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false); + + expect(approveVisible).toBe(rejectVisible); } }); @@ -297,9 +312,12 @@ test.describe('Contribution Type Voting - Authentication', () => { .or(page.getByRole('dialog')) .or(page.locator('[data-testid="auth-required"]')); - // May show auth prompt or vote confirmation depending on auth state - const isVisible = await authPrompt.isVisible({ timeout: 5000 }).catch(() => false); - expect(true).toBe(true); + // Same two-state rule as above. Whichever branch the app takes, it + // must show one of them rather than swallowing the click. + const confirmation = page + .getByText(/your vote|voted|already voted/i) + .or(page.locator('[data-testid="user-vote"]')); + await expect(authPrompt.first().or(confirmation.first())).toBeVisible({ timeout: 5000 }); } } }); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts index 06061f00..1e45080d 100644 --- a/tests/e2e/home.spec.ts +++ b/tests/e2e/home.spec.ts @@ -28,8 +28,8 @@ test.describe('Home page', () => { await expect(landingPage.tagline).toContainText(/decentralized eprints/i); }); - // Skip: Browse button not present during alpha - will be restored post-alpha - test.skip('displays browse eprints button', async ({ page }) => { + test('displays browse eprints button', async ({ page }) => { + test.skip(true, 'Browse button is not on the alpha landing page; restore post-alpha'); const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -37,8 +37,8 @@ test.describe('Home page', () => { await expect(browseButton).toBeVisible(); }); - // Skip: Browse button not present during alpha - will be restored post-alpha - test.skip('browse button navigates to eprints page', async ({ page }) => { + test('browse button navigates to eprints page', async ({ page }) => { + test.skip(true, 'Browse button is not on the alpha landing page; restore post-alpha'); const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -48,8 +48,8 @@ test.describe('Home page', () => { await expect(page).toHaveURL(/\/eprints/); }); - // Skip: Submit CTA not present during alpha - will be restored post-alpha - test.skip('displays submit eprint CTA', async ({ page }) => { + test('displays submit eprint CTA', async ({ page }) => { + test.skip(true, 'Submit CTA is not on the alpha landing page; restore post-alpha'); const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -61,8 +61,8 @@ test.describe('Home page', () => { await expect(submitButton).toBeVisible(); }); - // Skip: Features section not present during alpha - will be restored post-alpha - test.skip('displays features section with Why Chive heading', async ({ page }) => { + test('displays features section with Why Chive heading', async ({ page }) => { + test.skip(true, 'Features section is not on the alpha landing page; restore post-alpha'); const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -70,8 +70,8 @@ test.describe('Home page', () => { await expect(featuresHeading).toBeVisible(); }); - // Skip: Feature cards not present during alpha - will be restored post-alpha - test.skip('displays feature cards', async ({ page }) => { + test('displays feature cards', async ({ page }) => { + test.skip(true, 'Feature cards are not on the alpha landing page; restore post-alpha'); const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -127,10 +127,23 @@ test.describe('Home page', () => { const landingPage = new AlphaLandingPage(page); await landingPage.goto(); - const signInButton = page.getByRole('button', { name: /sign in with bluesky/i }); + // The button says ATProto, not Bluesky: Chive signs in any ATProto + // identity, and naming one PDS operator in the button was wrong about the + // product as well as about the markup. + const signInButton = page.getByRole('button', { name: /sign in with atproto/i }); await expect(signInButton).toBeVisible(); }); + test('offers a handle field to sign in with', async ({ page }) => { + const landingPage = new AlphaLandingPage(page); + await landingPage.goto(); + + // Sign-in begins by resolving a handle. Nothing covered that this field + // renders, and it is the first thing a user touches. + const handleInput = page.getByRole('textbox'); + await expect(handleInput.first()).toBeVisible(); + }); + test('displays external links', async ({ page }) => { const landingPage = new AlphaLandingPage(page); await landingPage.goto(); @@ -143,8 +156,9 @@ test.describe('Home page', () => { const githubLink = page.getByRole('link', { name: /github/i }); await expect(githubLink).toBeVisible(); - // Check for Bluesky link - const blueskyLink = page.getByRole('link', { name: /bluesky/i }); - await expect(blueskyLink).toBeVisible(); + // The third link is ATProto. There is no Bluesky link on this page, and + // asserting one had been failing rather than protecting anything. + const atprotoLink = page.getByRole('link', { name: /^atproto$/i }); + await expect(atprotoLink).toBeVisible(); }); }); diff --git a/tests/e2e/submission/paper-centric-submission.spec.ts b/tests/e2e/submission/paper-centric-submission.spec.ts index 7429ee1f..3be6e2cd 100644 --- a/tests/e2e/submission/paper-centric-submission.spec.ts +++ b/tests/e2e/submission/paper-centric-submission.spec.ts @@ -31,10 +31,12 @@ test.describe('Paper-Centric Submission - Display', () => { .locator('[data-testid="paper-identity"]') .or(page.getByText(/paper account|paper identity/i)); - // May or may not be visible depending on whether seeded data has paperDid - const isVisible = await paperIdentity.isVisible({ timeout: 3000 }).catch(() => false); - // Paper-centric is optional, so just verify page loads - expect(true).toBe(true); + // scripts/seed-test-data.ts sets no paperDid on any eprint, so every + // seeded submission is traditional and the badge must be absent. That is + // worth asserting: showing a paper-identity badge on a submission that has + // no paper account would misattribute the work, and the previous form of + // this test passed either way. + await expect(paperIdentity.first()).toBeHidden(); }); test('shows "Submitted by" separately from paper identity', async ({ page }) => { @@ -60,9 +62,9 @@ test.describe('Paper-Centric Submission - Display', () => { .locator('a[href*="/papers/"]') .or(page.getByRole('link', { name: /view paper profile/i })); - // Only present for paper-centric submissions - const isVisible = await paperProfileLink.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); + // Same reasoning: no seeded eprint is paper-centric, so a link to a paper + // profile would point at something that does not exist. + await expect(paperProfileLink.first()).toBeHidden(); }); }); @@ -92,9 +94,10 @@ test.describe('Paper-Centric Submission - Blob Fetching', () => { .getByText(/supplementary|additional files/i) .or(page.locator('[data-testid="supplementary-files"]')); - // Supplementary files are optional - const isVisible = await supplementary.isVisible({ timeout: 3000 }).catch(() => false); - expect(true).toBe(true); + // No supplementary materials are seeded, so the panel should not render. + // SupplementaryPanel returns null when it has nothing to show, and this + // catches a regression that renders an empty one. + await expect(supplementary.first()).toBeHidden(); }); }); @@ -125,10 +128,9 @@ test.describe('Paper-Centric vs Traditional - Detection', () => { .locator('[data-testid="paper-identity-badge"]') .or(page.getByText(/paper account/i)); - const isPaperCentric = await paperIdentityBadge.isVisible({ timeout: 3000 }).catch(() => false); - - // Either way is valid - just verify the page handles both - expect(true).toBe(true); + // The seeded eprints are all traditional, so this resolves to the + // traditional branch rather than "either way is valid". + await expect(paperIdentityBadge.first()).toBeHidden(); }); test('shows submitter for both paper-centric and traditional', async ({ page }) => { diff --git a/web/components/auth/handle-input.tsx b/web/components/auth/handle-input.tsx index 547e695e..c74899f0 100644 --- a/web/components/auth/handle-input.tsx +++ b/web/components/auth/handle-input.tsx @@ -33,6 +33,21 @@ export interface HandleInputProps { disabled?: boolean; /** Additional CSS classes */ className?: string; + /** + * Input id. + * + * @remarks + * Supplied by shadcn's `FormControl`, which clones its child to pass `id`, + * `aria-describedby` and `aria-invalid`. Without accepting and forwarding + * them the `