Files
orca/src/main/github/project-view.test.ts
T
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00

287 lines
10 KiB
TypeScript

// Why: covers the recent fixes —
// (a) network errors must NOT be misclassified as not_found ("could not
// resolve host" partially overlaps "could not resolve to a"),
// (b) repo slug validation must accept names with leading underscore
// (GitHub allows them, e.g. `_internal`),
// (c) owner slug validation must reject `.`/`_` (GitHub disallows them in
// usernames/orgs),
// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer,
// (e) project owner/capability caches stay bounded in long sessions.
import { beforeEach, describe, expect, it } from 'vitest'
import {
GITHUB_PROJECT_REF_INPUT_MAX_BYTES,
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
} from '../../shared/github/project-ref-input'
import {
PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES,
_getProjectViewCacheSizesForTests,
_getProjectViewOwnerTypeForTests,
_hasProjectViewParentFieldRetriedForTests,
_hasProjectViewParentFieldWarningLoggedForTests,
_markProjectViewParentFieldRetriedForTests,
_markProjectViewParentFieldWarningLoggedForTests,
_rememberProjectViewOwnerTypeForTests,
_resetProjectViewCachesForTests,
classifyProjectError,
isValidOwnerSlug,
isValidRepoSlug,
parseProjectPaste,
resolveProjectRef
} from './project-view'
describe('classifyProjectError', () => {
it('classifies HTTP 404 as not_found', () => {
expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found')
})
it('classifies "Could not resolve to a User" as not_found', () => {
expect(classifyProjectError('Could not resolve to a User with the login of foo', '').type).toBe(
'not_found'
)
})
it('classifies "could not resolve host" as network_error, NOT not_found', () => {
// Why: this was the bug — substring "could not resolve" overlaps. The
// network branch must run before not_found, and the not_found check
// must require "to a " to disambiguate.
expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe(
'network_error'
)
})
it('classifies "dial tcp" timeouts as network_error', () => {
expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe(
'network_error'
)
})
it('classifies rate-limit text as rate_limited', () => {
expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited')
})
it('classifies missing-scope as scope_missing', () => {
expect(
classifyProjectError('your token has not been granted the required scopes', '').type
).toBe('scope_missing')
})
it('classifies auth-required when gh is not signed in', () => {
expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required')
})
it('pins Enterprise auth and scope remediation to the selected host', () => {
expect(
classifyProjectError('gh auth login required', '', 'github.acme.test').message
).toContain('gh auth login --hostname github.acme.test')
expect(
classifyProjectError(
'your token has not been granted the required scopes',
'',
'github.acme.test'
).message
).toContain('gh auth refresh --hostname github.acme.test')
})
})
describe('isValidOwnerSlug', () => {
it('accepts plain alphanumerics and hyphens', () => {
expect(isValidOwnerSlug('acme')).toBe(true)
expect(isValidOwnerSlug('acme-co')).toBe(true)
expect(isValidOwnerSlug('user1')).toBe(true)
})
it('rejects underscore (GitHub disallows it in usernames/orgs)', () => {
expect(isValidOwnerSlug('_acme')).toBe(false)
expect(isValidOwnerSlug('acme_co')).toBe(false)
})
it('rejects leading hyphen and dot', () => {
expect(isValidOwnerSlug('-acme')).toBe(false)
expect(isValidOwnerSlug('.acme')).toBe(false)
})
it('rejects empty and slash-containing values', () => {
expect(isValidOwnerSlug('')).toBe(false)
expect(isValidOwnerSlug('a/b')).toBe(false)
expect(isValidOwnerSlug(123)).toBe(false)
})
})
describe('isValidRepoSlug', () => {
it('accepts leading underscore (GitHub allows it for repo names)', () => {
expect(isValidRepoSlug('_internal')).toBe(true)
})
it('accepts leading dot', () => {
expect(isValidRepoSlug('.github')).toBe(true)
})
it('accepts dots, dashes, underscores anywhere', () => {
expect(isValidRepoSlug('repo-name')).toBe(true)
expect(isValidRepoSlug('repo.name')).toBe(true)
expect(isValidRepoSlug('repo_name')).toBe(true)
})
it('rejects reserved single/double dot', () => {
expect(isValidRepoSlug('.')).toBe(false)
expect(isValidRepoSlug('..')).toBe(false)
})
it('rejects path separators and empty', () => {
expect(isValidRepoSlug('a/b')).toBe(false)
expect(isValidRepoSlug('')).toBe(false)
})
})
describe('parseProjectPaste', () => {
it('parses owner/number shorthand', () => {
expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 })
})
it('rejects shorthand with underscore in owner (renderer parity)', () => {
// Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9-]*` for owner
// (matches OWNER_SLUG_RE). Both sides must reject the same inputs.
expect(parseProjectPaste('co_op/45')).toBeNull()
})
it('parses org URL with view number', () => {
expect(parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')).toEqual({
kind: 'org',
owner: 'acme',
number: 42,
host: 'github.com',
viewNumber: 3
})
})
it('parses user URL', () => {
expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({
kind: 'user',
owner: 'octocat',
number: 1,
host: 'github.com'
})
})
it('rejects URLs whose owner has invalid characters', () => {
expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull()
})
it('accepts enterprise-host URLs only when that host is provided (GHES)', () => {
const url = 'https://github.corp.example/orgs/acme/projects/7/views/2'
expect(parseProjectPaste(url, 'github.corp.example')).toEqual({
kind: 'org',
owner: 'acme',
number: 7,
host: 'github.corp.example',
viewNumber: 2
})
expect(parseProjectPaste(url)).toBeNull()
// github.com URLs still parse when a GHES host is supplied.
expect(
parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example')
).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' })
})
it('preserves a GHES custom port while parsing project URLs', () => {
expect(
parseProjectPaste(
'https://github.corp.example:8443/orgs/acme/projects/7',
'github.corp.example:8443'
)
).toEqual({
kind: 'org',
owner: 'acme',
number: 7,
host: 'github.corp.example:8443'
})
})
it('rejects credentials and paths that only begin like a Project URL', () => {
expect(parseProjectPaste('https://user:token@github.com/orgs/acme/projects/1')).toBeNull()
expect(parseProjectPaste('https://github.com/orgs/acme/projects/1evil')).toBeNull()
expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/views/2evil')).toBeNull()
expect(parseProjectPaste('https://github.com/orgs/acme/projects/1/files')).toBeNull()
})
it('returns null for empty input', () => {
expect(parseProjectPaste('')).toBeNull()
expect(parseProjectPaste(' ')).toBeNull()
})
it('rejects oversized valid-looking URLs without parsing the secret-bearing tail', () => {
const secret = 'project-url-secret'
const input = [
'https://github.com/orgs/acme/projects/42?',
secret,
'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES)
].join('')
expect(parseProjectPaste(input)).toBeNull()
})
})
describe('resolveProjectRef', () => {
it('rejects oversized project refs with a metadata-only validation error', async () => {
const secret = 'project-url-secret'
const input = [
'https://github.com/orgs/acme/projects/42?',
secret,
'x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES)
].join('')
await expect(resolveProjectRef({ input })).resolves.toEqual({
ok: false,
error: {
type: 'validation_error',
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
}
})
await expect(resolveProjectRef({ input })).resolves.not.toMatchObject({
error: { message: expect.stringContaining(secret) }
})
})
})
describe('project view owner caches', () => {
beforeEach(() => {
_resetProjectViewCachesForTests()
})
it('LRU-evicts old owner type probes', () => {
for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) {
_rememberProjectViewOwnerTypeForTests(`owner-${i}`, i % 2 === 0 ? 'organization' : 'user')
}
expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(
PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES
)
expect(_getProjectViewOwnerTypeForTests('owner-0')).toBeUndefined()
expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user')
})
it('shares owner type probes between implicit and explicit github.com hosts', () => {
_rememberProjectViewOwnerTypeForTests('acme', 'organization')
expect(_getProjectViewOwnerTypeForTests('acme', 'github.com')).toBe('organization')
expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe(1)
})
it('LRU-evicts old parent-field retry and warning probes', () => {
for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) {
const scopeKey = `owner-${i}\u0000organization`
_markProjectViewParentFieldRetriedForTests(scopeKey)
_markProjectViewParentFieldWarningLoggedForTests(scopeKey)
}
expect(_getProjectViewCacheSizesForTests()).toMatchObject({
parentFieldRetries: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES,
parentFieldWarnings: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES
})
expect(_hasProjectViewParentFieldRetriedForTests('owner-0\u0000organization')).toBe(false)
expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-0\u0000organization')).toBe(false)
expect(_hasProjectViewParentFieldRetriedForTests('owner-1\u0000organization')).toBe(true)
expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-1\u0000organization')).toBe(true)
})
})