diff --git a/.env.example b/.env.example index 58bc57b8..8bc615db 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Session encryption secret (must be at least 32 characters) COOKIE_SECRET=your-32-character-secret-here-change-me -# Public URL for production (leave empty for localhost development) +# Legacy CMS public URL for production (Open Lab uses LAB_PUBLIC_URL below) PUBLIC_URL= # ES256 Private Key in JWK format for confidential client authentication @@ -23,7 +23,7 @@ NEXT_PUBLIC_GA_ID= NEXT_PUBLIC_COOKIE_CONSENT=on # GraphQL indexer URL for org.plresearch.* records -# Default: https://api.hi.gainforest.app/graphql +# Default: https://plresearch-indexer-production.up.railway.app/graphql # INDEXER_URL=http://localhost:4000/graphql # Metaculus API token — enables live community-forecast readings on the /impact @@ -31,3 +31,26 @@ NEXT_PUBLIC_COOKIE_CONSENT=on # is still linked, just without a live number. Create a token at # https://www.metaculus.com/accounts/settings/ # METACULUS_API_TOKEN= + +# Open Lab: dedicated official browser OAuth, isolated from the legacy CMS. +# Exact HTTPS origin (no path, credentials, query, or custom port). +# Empty explicitly disables sign-in; browsing, local drafts, exports and public +# DID/PDS reads do not require OAuth. Never inherit production identity in previews. +# On Vercel, OMIT this variable entirely to use that deployment's VERCEL_URL. +# A non-production deployment rejects a configured origin other than VERCEL_URL. +LAB_PUBLIC_URL= +# Local development: LAB_PUBLIC_URL=http://127.0.0.1:3000 +# Open that IP-based origin (not localhost). Official SDK uses its special +# http://localhost client_id; the redirect_uri remains the loopback IP. +# Writes remain off until explicitly enabled. Enabling does NOT prove OAuth, +# consent, schema publication or PDS interoperability. Preview writes are real. +LAB_ENABLE_PUBLISH=false +# No new passwords, private keys, OAuth client secrets or CMS admin grants. +# Setup, public-record privacy and owner-mediated smoke: docs/open-lab/protocol-runbook.md + +# Legacy CMS-only page-edit credentials (not used by Open Lab). +ATPROTO_HANDLE= +ATPROTO_PASSWORD= +# Optional legacy CMS admin allowlist and standard.site publication record. +NEXT_PUBLIC_ADMIN_DIDS= +NEXT_PUBLIC_PUBLICATION_URI= diff --git a/docs/lab-explorations.md b/docs/lab-explorations.md new file mode 100644 index 00000000..27eab4fe --- /dev/null +++ b/docs/lab-explorations.md @@ -0,0 +1,55 @@ +# Open Lab entrance explorations + +Two independent, interactive entrances and a product comparison. These are prototype routes, not a second community deployment. All route content and styling is isolated under `src/app/lab/explorations/` and `src/components/lab/explorations/`. The integration owner supplies the outer Open Lab shell; no auth, server, shared stylesheet, shell, or package changes are required. + +## Routes + +- `/lab/explorations/` — A/B/C comparison with intent-based recommendations and substantive cold-start, maintenance, and trust tradeoffs. A is the main Open Lab foundation; its miniature is labeled a composition sketch, not a screenshot or live feed. +- `/lab/explorations/arcade/` — Science Arcade: a paper-like tool cabinet centered on a deterministic elementary cellular-automaton print instrument. Rule slider (0–255), presets, initial-cell selection, boundary condition, and row count change the actual lattice. Exports are real SVG and JSON files generated in the browser, not placeholder downloads. +- `/lab/explorations/observatory/` — Observatory: dark scientific-question map, with the Neurotech editorial brief selected initially. +- `/lab/explorations/observatory/{question}/` — direct, server-renderable question briefs: `verifiable-artifacts`, `portable-evaluations`, `neural-measurements`, `robust-coordination`. Unknown IDs use Next's `notFound()`. In-place selections update browser history; back/forward restores the brief. Native links remain usable without client navigation. Map/List uses the same accessible link collection. Selecting a question focuses the brief heading, allowing offscreen results to come into view. + +## Instrument contract + +`lab-explorations.ts` contains the pure model and export builder. Rule numbering follows the elementary cellular-automaton convention: neighborhood `111` is rule bit 7 and `000` is bit 0. Each row updates synchronously from the previous row. Row zero is the seed, columns run left to right. Fixed boundaries are zero outside the lattice; wrapping boundaries connect the edges. A pair seed uses the central cell and its right neighbor. + +Bounds: integer rule 0–255; width 3–241; rows 1–160. The UI uses width 121 and 40, 80, or 120 rows. No randomness or clocks participate in output. SVG and JSON derive from the same cell matrix. JSON schema identifier: `org.plrd.explorations.automaton.v1`; fields include `config`, `convention`, `limit`, `cells`. SVG embeds the configuration, convention, and limit in XML metadata. Configuration is validated and narrowed before serialization; the SVG contains no script, remote image, or external app. + +This is a synthetic educational model, not biological data, a claim of computational universality, or evidence of discovery. Downloads are explicit user gestures; feedback says the download was requested, not that it was saved or published. No configuration or result is sent to a server. Object URLs are revoked after the download request. + +## Editorial boundaries and sources + +The Observatory's four questions are editorial starting points, not active campaigns or requests from project maintainers. Lines join questions to a shared methodological lens; positions are not a quantitative embedding and do not describe social relationships. Briefs separate what exists, the proposed opening, a useful contribution, and the limit of the source. No community identities, activity counts, or peer-review claims are invented. + +Public sources inspected September 10, 2026: + +- IPFS Content Identifiers: https://docs.ipfs.tech/concepts/content-addressing/ +- EleutherAI Evaluation Harness: https://github.com/EleutherAI/lm-evaluation-harness +- Neuro Atlas public repository: https://github.com/lksbrssr/neuro-atlas +- PL Neuro published field overview: https://www.plneuro.xyz/insights/neurotech-frontier-human-flourishing/ +- PL R&D Economies & Governance: https://www.plrd.org/areas/economies-governance/ +- JupyterLite documentation: https://jupyterlite.readthedocs.io/en/stable/ +- Observable Plot: https://observablehq.com/plot/ + +The hosted Neuro Atlas is not claimed to be anonymously accessible. Tool links open only on explicit navigation, with `noopener noreferrer`; there are no iframes, automatic launches, key collection, or agent dispatch. Contribution links return to the parent lab routes and do not prepopulate, publish, or claim to create a task. + +## Verification + +Uses the existing Node test runner, TypeScript source loader, React, jsdom, and PostCSS. No dependencies or lockfiles changed. + +```sh +UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 node --test --test-concurrency=1 scripts/lab-explorations*.test.mjs +UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 node node_modules/typescript/bin/tsc --noEmit --incremental false +``` + +Vertical RED/GREEN slices covered exact lattice evolution, bounded validation, reproducible exports, actual UI controls and downloads, editorial route lookup, Observatory selection/history/list behavior, and intent-based comparison. Regression coverage checks range/row controls, download failure honesty, anonymous server rendering, all four dynamic route params, unknown-question 404, React server-render warnings, focus transfer, scoped CSS class references, and secondary touch targets. + +## Integration owner checks still required + +No full production build or independent critic was run in this lane. The parent owns integration, production build, browser QA/screenshots, security review, and the PR. In particular: + +1. Check the exact routes at 320/390/1440px within the final lab shell, in both site themes. Arcade/comparison intentionally keep a light paper surface; Observatory intentionally keeps a dark instrument surface. All use the existing Aileron/Newsreader, PL mark, and brand blue. +2. Confirm native Next.js back/forward, refresh on every direct question route, keyboard selection, offscreen focus/scroll behavior, and Map/List at narrow widths. DOM tests exercise the history handler but are not a real-browser Next navigation smoke. +3. Download both file types in a real browser, confirm the saved SVG matches the current plot and the JSON reproduces it. DOM tests inspect real generated Blob bytes, not the browser's download manager. +4. Verify inherited root/shell CSS does not change the scoped composition, and the main lab links resolve after integration. The baseline worktree does not contain the parent primary UI. +5. No real OAuth or PDS writes were exercised. These alternatives do not invoke them; any owner-consented account smoke is a separate integration gate. diff --git a/docs/open-lab/README.md b/docs/open-lab/README.md new file mode 100644 index 00000000..ce586e1d --- /dev/null +++ b/docs/open-lab/README.md @@ -0,0 +1,60 @@ +# Open Lab prototype + +A public-facing place to turn a shared bottleneck into a responsible intervention, a small test, and inspectable evidence. This branch is a product proposal and working prototype, **not a production community launch**. The existing PL R&D brochure is preserved. + +## Try the proposal + +On the deployment preview, begin at `/lab/`. + +- **Bottlenecks:** `/lab/bottlenecks/` — inspect a public-source diagnosis, refine it, or sketch an intervention with a causal hypothesis, affected parties, measurement, risks and stopping condition. Preparation is not acceptance or publication. +- **Community:** `/lab/demo/` — follow three fictional collaborations, inspect people and discussion, reply locally, and follow a notification to the exact discussion. The demo label is intentional. Switching to real/empty mode hides this activity without deleting personal drafts. +- **Apps:** `/lab/apps/` — try the synthetic signal experiment, change its sampling, and export its actual configuration/results; inspect external source-linked science tools. +- **Atlas:** `/lab/atlas/` — prepare source-linked evidence proposals. These do not automatically change an Atlas. +- **Collaborate:** `/lab/collaborate/` — export bounded researcher/reviewer packets, import their return files, retain disagreement, and export a human-resolution bundle. No agent is launched and no model-provider account is collected. +- **Your starting point:** `/lab/onboarding/` and `/lab/profile/` — choose interests and contribution style, keep an owner-scoped profile draft, and optionally provide work/publication links. LinkedIn and Scholar are self-supplied links, not verified credentials or connected accounts. +- **Effort backing:** `/lab/efforts/` — allocate and reclaim a finite illustrative local budget while recording evidence separately. No money moves and no Hypercert is issued. This experiment is separate from the demo community’s small fictional interest budget. +- **Public record inspection:** `/lab/record/?uri=` — read a deliberately shared record from its current PDS. HTTPS retrieval is not repository-signature verification or scientific review. + +The alternative entrances are at `/lab/explorations/`: **Science Arcade**, a real cellular-automaton instrument, and **Observatory**, a source-linked question map. They explore different ways in, rather than merely changing colors. + +## What is real, illustrative, and not yet verified + +| Layer | Boundary | +| --- | --- | +| Local tools and drafts | Interactive computation, saving, bounded imports, comparisons and exports run locally. Browser storage is not encrypted or cross-device sync. | +| Fictional community | People, discussion, proposals and narrative outcomes are clearly labeled fixtures. Local replies/interest never become public posts or actual notifications to other people. | +| Public sources | Editorial artifacts and attributed Bluesky reads are separate from demo activity and membership. Outages remain unavailable states, not fabricated live feeds. | +| AT Protocol | Dedicated official browser SDK, identity-only login followed by explicit per-action permissions, candidate schemas, direct PDS operations and exact current URI/CID/content readback. No CMS/admin identity reuse. | +| Account-level verification | Real account OAuth and public PDS create/update/delete need a separately approved test account and exact public text. Injected transport tests do not establish hosted account compatibility. | +| Launch operations | Global discovery/indexing, moderation, reporting/appeals, accepted-evidence workflow, founding cohort and public schema registration remain launch work. | + +`LAB_ENABLE_PUBLISH` is off by default. A configured origin is not a passed OAuth test. A public record is not accepted science. A preview is not a private PDS sandbox. + +## Local development and verification + +Use Node 22 or newer. Vercel uses the repository’s **pnpm 10 lockfile**, so a successful npm install alone is not the deployment install gate. + +```sh +npx --yes pnpm@10.17.1 install --frozen-lockfile +npm test +npx tsc --noEmit --incremental false +npm run build +npm start +``` + +The tests include local synthetic fixtures and injected protocol transport, not real public mutations. The release evidence must additionally include actual desktop/mobile interaction checks and an independent review at the final source revision. If dependencies change, maintain both lockfiles without re-resolving unrelated packages. + +For identity-only local development, the runbook documents explicit IP-loopback configuration. Do not copy production credentials, disable a preview access gate, or reuse CMS authentication to make a test pass. + +## Design and technical notes + +- [Product rationale and launch experiment](strategy.md) +- [Acceptance and evidence standard](acceptance.md) +- [Protocol, candidate schemas and owner-gated launch checklist](protocol-runbook.md) +- [Bottleneck model and permissioned-console boundary](bottleneck-co-creation.md) +- [Demo fixtures, interactions and isolation](demo-community.md) +- [Onboarding, optional links and local action inbox](social-onboarding.md) +- [Finite backing and Hypercerts design](effort-backing.md) +- [Alternative entrance comparison](../lab-explorations.md) + +The release PR is the home for the final revision, test/build receipts, screenshots, preview status and remaining limitations. diff --git a/docs/open-lab/acceptance.md b/docs/open-lab/acceptance.md new file mode 100644 index 00000000..52013adf --- /dev/null +++ b/docs/open-lab/acceptance.md @@ -0,0 +1,36 @@ +# Open Lab acceptance and launch boundary + +This is a feature-branch research-community prototype, not a launch or proof of community adoption. Production merge, real public-account publication, payments, additional data imports, and certificate issuance are separate decisions. + +## The product test + +A new visitor can do something useful before another member arrives: run a small reproducible synthetic experiment, inspect an actual source, or draft a bounded contribution. A second person can inspect a deliberately shared public record and prepare their own evidence response. The UI must not turn source discovery into invented participation. + +## Latest product center and social/demo criteria + +- Organizing loop: bottleneck → collective refinement → responsible intervention/invention proposal → bounded test → evidence → revise/continue/retire. Apps, maps, agent work and effort backing support this; they do not substitute for it. +- Every intervention proposal targets a specific bottleneck and captures the causal hypothesis, smallest useful test, success/measurement signal, affected parties, risks, and stopping/review condition. Proposals do not silently overwrite accepted state or assign other people work. +- Reuse the Console’s domain shape and source identity, but no direct exposure of a permissioned workspace. This prototype may use synthetic editorial cases; actual public projections and return reconciliation remain deliberate owner-reviewed gates. +- Top-right action notifications, optional LinkedIn/Scholar profile completion, and skippable interest/contribution-style onboarding lead to concrete, explainable starting suggestions. Self-supplied links are not verified credentials; nothing posts without review. +- The user explicitly authorized a populated demo. Clearly labeled fictional people, threads, interventions, evidence, and action notifications should form coherent stories across the app. They must be interactive and visually legible—not fake activity presented as actual usage. Switching to real/empty mode must hide demo identities/counts/events and preserve real drafts. Demo interactions never reach a public PDS. + +## Required probes + +- Preserve the existing PL R&D brochure and public focus-area pages; no unrelated preview promotion. +- Real local paths for landing, feed/map, apps/sandbox, Atlas contribution, collaboration, bench, public-record viewer, design comparisons, and effort backing. +- Desktop and small-screen rendering; visible focus, keyboard dialog dismissal, no page-width overflow, readable light/dark states. Local screenshots are labeled local; screenshots use pinned source. +- Signal experiment and alternate cellular-automaton instrument actually compute from user controls. Export contains the configuration, method, limitations and computed result—not a fabricated scientific finding. +- Editorial source cards are real and explicitly not asserted to be members. A live Bluesky source failure is not replaced by fake live posts. Map relationships have a stated meaning, never counterfeit social-network edges. +- Draft persistence, recovery, storage-failure messages, owner isolation, no automatic migration/publication on sign-in, and fresh confirmation after any change. +- Dedicated Open Lab OAuth, no reuse of CMS/admin sessions. Explicit granular permissions, per-action consent, direct PDS writes, exact URI/CID/content readback. Failure/uncertain write states preserve work and discourage duplicate retry. Mock transport tests are labeled mock; no claim of real OAuth verification without an actual authorized account flow. +- Public record reads work without Lab sign-in, preserve identities and source content, limit responses, show unavailable/missing records honestly, and never call a PDS HTTPS response a verified repository signature. +- Own-record update/deletion uses reviewed CID and fails on stale identity/version. Downstream-copy limits are explicit. Pagination or truncation is visible rather than a falsely complete notebook. +- Two bounded agent-return files can be imported, validated, compared and reviewed. Dissent remains visible. An exported review is local/unsigned—not peer review, a remote agent run, or public publication. No subscription credentials, automatic spending, or arbitrary agent execution. +- Finite local backing points enforce the displayed total; allocations can be reclaimed and exported. Corrupt storage cannot silently create a larger budget. No crowds, dollars, globally enforced identity budget, or issued Hypercert is fabricated. Support, evidence and evaluation stay distinct. +- Hypercerts documentation is cited accurately: AT Protocol activity/context/evaluation records are a future interoperable path; no tokenization or finance claim inferred from an unsigned local design sketch. + +## Evidence standard + +Frozen pnpm 10 install, project-wide tests (including new Lab suites), no-emit typecheck, production build, actual route/interaction checks, and independent exact-revision security and product/design review. Test fixtures, local behavior, public-source reads, configured capabilities, and real account/provider verification are five different evidence categories. Keep them separate in the PR. + +A passed build is not a passed community loop. A configuration flag is not a successful OAuth session. A created record is not accepted science. A preview URL is not launch approval. diff --git a/docs/open-lab/bottleneck-co-creation.md b/docs/open-lab/bottleneck-co-creation.md new file mode 100644 index 00000000..3821b769 --- /dev/null +++ b/docs/open-lab/bottleneck-co-creation.md @@ -0,0 +1,69 @@ +# Bottleneck co-creation workbench + +## What exists + +`/lab/bottlenecks/?case=reproducibility` is a compact, anonymous-first co-creation workbench, not a second operations console or a general feed. The loop is diagnose/refine → design an intervention → rally contributions → test outcomes → revise/retire. This lane prepares proposals; it does not run, staff, fund, accept, or publish them. + +Exactly one case is implemented: `reproducibility`. `neural-measurement` and `open-artifacts` are not implemented. The case is an **editorial public-source starter**, not a fixture presented as activity, not an imported Console object, and not a validated or active campaign. Its primary source is the official [scikit-learn data-leakage guidance](https://scikit-learn.org/stable/common_pitfalls.html#data-leakage), reviewed September 10, 2026. It establishes the leakage mechanism and split-first/train-only-fit practices, not the frequency of leakage or any measured improvement in an actual project. No notebook was audited. No people, measurements, or endorsements were invented. + +`editorial-reproducibility-v1` identifies the reviewed editorial brief. It is not a source commit, immutable snapshot, AT URI, or CID. The upstream `stable` URL can change. Revise that editorial identifier whenever the baseline statement, interpretation, or source changes; existing drafts then require explicit recovery/reconciliation instead of being silently retargeted. + +## UI and integration + +- `BottleneckWorkbench` defaults to `owner="guest"`. Pass a stable local owner identifier when available; the prop is storage isolation, not an authentication assertion. It is omitted from exported packets. +- `onPrepareContribution?: (initial: Record) => void` is the only contribution seam. The integration owner may open the existing `RecordEditor` with `kind="contribution"` and these initial values. This component does not import the editor, auth, or a network client. +- Mapping: `targetUrl` is the browser origin plus `/lab/bottlenecks/?case=reproducibility`; `evidenceUrl` is the primary source; `field` is the case's `ai-robotics` field, never a misleading filter value; `observation` names case/revision and includes labeled excerpts of every required proposal field. Arbitrary URL queries/fragments are stripped. HTTP/local/internal URLs cannot prepare public contributions; local JSON export still works in preview. A public HTTPS preview origin remains the browser's origin, not a claim of production deployment. +- The observation is validated at no more than 1,200 UTF-16 characters, including labels and warnings, with at most 80 characters per field excerpt and no split surrogate pair. Tests also pass the actual existing contribution validator. The full JSON is not attached automatically. The UI explicitly asks visitors to export it separately and review the summary. Preparation is not publishing, source-author approval, or acceptance. +- The default route has no callback and says contribution publishing is not connected. Parent owns any client wrapper, navigation changes, and final auth/public-write checks. Never make this callback auto-publish. +- `?case=` and `?field=` are URL-authoritative, including Back/Forward. Allowed fields are `digital-human-rights`, `economies-governance`, `ai-robotics`, `neurotech`, and `cross-field`; `all` is filter-only. The one starter appears under AI & Robotics and cross-field. Other/unknown fields and unknown case IDs show an honest empty state, not a substitute target. +- Links to `/lab/collaborate/` and `/lab/efforts/` are supporting tools only. They do not imply case-specific campaign membership, allocations, or network activity. + +The route imports a lane-owned stylesheet. Every selector is under `.open-lab` and a bottleneck class; no existing UI, shared styles, or auth code is modified. Typography follows Aileron body / `var(--font-serif)` headings, warm paper/ink/blue, and dark-mode inherited tokens. Controls have 44px minimum height; layouts collapse at 900px and 600px, including 390px and 320px. Mounted tests do not establish pixel geometry; parent must check integrated mobile screenshots and keyboard flow. + +## Draft and export contract + +One independent local draft per owner × case × proposal kind (`refinement` or `intervention`). Incomplete, valid text autosaves on change; Save local draft retries explicitly. Eight fields are required for export/preparation: + +1. Hypothesis and causal link (or a refined diagnosis). +2. Smallest useful action to test it. +3. Success signal. +4. Measurement/comparison and retained evidence. +5. People/roles to involve or consult, without assigning them. +6. Foreseeable harms, risks, and constraints. +7. Stop/review date or bounded criterion; when to revise or retire. +8. Requested evidence, reproduction, design, or review contributions; no funds or execution requests. + +All fields are bounded to 1,000 UTF-16 characters, with unsafe controls rejected. Draft parsing is an exact-shape, owner/case/kind/source-revision check with a 48,000-byte UTF-8 cap. Exports are also capped and explicitly state `not-published`, `not-executed`, and `not-accepted`. Their target includes the case identity, editorial source revision, source URL, reviewed date, and browser-origin same-case permalink. These are local proposal packets, not public records. No fictional public URI/CID is supplied. + +Baseline/source state is never patched by a proposal. Negative or inconclusive results can disconfirm a hypothesis or warrant revision/retirement; a lower corrected model score may be a useful result. This lane collects the proposed evaluation contract, not executed outcomes or accepted retros. + +Malformed, foreign-owner, unknown-version, or stale-source saved data is not overwritten. A bounded recovery textarea exposes the original as explicitly unvalidated text for manual copying; oversized originals remain in browser storage. There is no raw-document importer and no reset/delete button. Recovery is deliberately manual, not automatic source migration. Browser-storage failures remain visible; the current in-memory draft remains editable/exportable. Device storage is not encrypted or account synchronization. Guest drafts are shared within one browser profile. Concurrent same-owner tabs are not collaborative editing and can race; export a copy before parallel work. No storage events, cloud sync, or multi-draft history are promised. + +## Real Console bridge: gated design, not a live connector + +The underlying Console model has one intervention targeting exactly one bottleneck. A bottleneck carries identity, focus area, type, statement, lifecycle/validation state, affected actors, resolution signal, optional inflection links, evidence/investigation, and version/edit metadata. Interventions add hypothesis, success signal, owner/collaborators, review checkpoint, activations, and evaluation/retro. These semantics are reimplemented generically here; private content and implementation are not copied. + +Crucial storage distinction: Console bottlenecks are array objects within logical document `iv4:bottlenecks`, in private member-gated Habitat space type `org.plrd.interventions.workspace`, document collection `org.plrd.interventions.doc`. They are **not independent public PDS records**. Their private records do not belong in a public repo, public search, or the firehose. There is no anonymous query or browser proxy to that workspace. + +An authorized operator can later establish selected **public projections**, using this runbook: + +1. **Select privately.** Through existing team permissions, an authorized operator selects an object, not an entire document. Privately record the exact workspace locator, logical document key, object ID, object edit/version metadata, and current document revision/CID where the actual storage system supplies it. Preserve the source tuple and review evidence in a private mapping ledger. Do not put private workspace locators, owner identifiers, private version hashes, raw exports, or the ledger into public code or projection metadata. +2. **Whitelist a standalone brief.** Create a new public brief with only approved title/statement, public field/type, non-identifying affected-party categories, public evidence URLs with carefully scoped claims, uncertainty, proposed resolution signal, and explicitly approved status wording. Inflection descriptions are eligible only if already public and separately reviewed. Do not copy seeds, personnel/staff references, funder/deal notes, private links, owner/collaborator DIDs, evidence containing private data, unrelated fields, or whole source arrays. Private `ratified`/`binding` labels do not automatically become public validation claims. +3. **Obtain explicit owner review.** Show the exact proposed public envelope and all URLs to the appropriate source owner. Record approval of that specific revision privately. Confirm public evidence and privacy/licensing/consent implications. No inferred authorization from repository access or workspace membership. Approval must cover provenance wording; even revealing a relationship to a private source can require consent. +4. **Publish only the approved projection.** A separately authorized publishing flow writes only that envelope to the selected public surface and reads it back. Save the real returned public projection URI and CID/version. Do not invent record IDs or assume a webpage is an AT record. Add only the real public URI/CID and public revision to public provenance; bind these back to the private source tuple in the private ledger. This workbench does none of this today. +5. **Accept independent proposals, not edits.** Each author creates a separate proposal referencing exactly one public projection URI **and CID/revision**; record its author and record identity only through the real public publishing path. A proposal, contribution, local interest, and machine output are never acceptance, assignment, funding, or proof of effectiveness. Preserve competing diagnoses and negative results. Moderation and removal handling are required before public aggregation. +6. **Reconcile through human team permissions.** An authorized human compares a proposal with the publicly approved revision and privately re-reads the original object/document. If source version or the public projection CID changed, stop: rebase the proposal onto the new context and obtain renewed owner review as needed. Apply only explicitly accepted changes through the existing guarded team writer, scoped to the intended object while preserving all other array members. Use the provider's supported compare-and-swap/version preconditions where available; never pretend an `updatedAt` check alone is atomic. Without atomic write preconditions, serialize the authorized reconciliation and re-read immediately before/after; unresolved concurrency blocks automatic acceptance. +7. **Read back and retain the decision.** Verify the changed object and untouched neighboring state; keep actor, accepted proposal IDs, before/after source versions, rationale, and timestamps privately. Publish a new reviewed public revision only on fresh authorization. Retain negative/inconclusive outcomes and retirement reasons with their evidence. Never push private state back wholesale or auto-apply public proposals. Public withdrawal cannot guarantee deletion of already syndicated copies. + +There is no import UI in this implementation. If added later, it must accept only a strictly bounded, exact-schema, pre-reviewed public-brief envelope with explicit provenance and actual public URI/CID (if published), reject any raw workspace document/array or private locator fields, and stay local until explicit publication. Merely including `reviewed: true` in an uploaded file is not authorization: verify approval through the actual authorized review process. This documented gate is intentional, not a fake connector. + +## Bounded verification + +Run from the worktree: + +``` +node --test scripts/lab-bottlenecks*.test.mjs +node node_modules/typescript/bin/tsc --noEmit --incremental false +``` + +Vertical TDD covers source-linked model creation, strict parsing/export, callback schema compatibility, mounted local save/reload/download, incomplete-draft autosave, per-owner and per-kind isolation, stale recovery, blocked storage, URL fields and real history Back/Forward. Test text is synthetic proposal input, not public starter content or claimed results. No account, OAuth, PDS, payment, or live private data test is performed. No production build is required for this lane. Integration owner owns final visual QA, accessibility audit, release/account tests, and any PR/publication. diff --git a/docs/open-lab/demo-community.md b/docs/open-lab/demo-community.md new file mode 100644 index 00000000..54e2b88d --- /dev/null +++ b/docs/open-lab/demo-community.md @@ -0,0 +1,137 @@ +# Open Lab demo community + +## What this is + +An explicitly fictional community that demonstrates the loop: + +shared bottleneck → refinement → bounded intervention → contribution → uncertain/negative evidence → design revision. + +Six invented humans, three case IDs (`reproducibility`, `neural-measurement`, `open-artifacts`), three proposals, three multi-person discussions, and four small seeded support allocations. The reproducibility story is the deepest. The other cases explore a missing measurement denominator and a reuse-permission blocker. No real company affiliation, profile URLs, remote portraits, DID, AT URI, or claimed published outcome. The illustrative days are story order, not timestamps or simulated live activity. No timers create activity. + +The scenarios are original fixtures, not copied console records. Their claims are about invented trials only. Real scientific source links are deliberately not used as evidence that these fictional collaborations occurred. Proposal artifacts are descriptions within the story—not files claimed to have been executed or verified. + +## Entry point and imports + +Route: `/lab/demo/` + +```tsx +import { + DemoCommunityProvider, + useDemoCommunity, + DemoModeBanner, + DemoCommunityPanel, + DemoActivityFeed, + DemoPeople, + DemoNotifications, + DemoCommunityExperience, +} from '@/components/lab/demo'; +import type { DemoCaseId, DemoContext } from '@/lib/lab-demo'; +``` + +The route includes a provider fallback, banner, and top-right demonstration bell. A nested provider reuses an existing parent instead of resetting it. Route metadata is `noindex, nofollow`. + +## Exact component API + +- `DemoCommunityProvider({ children, initialMode = 'demo', storageScope = 'browser', storage? })` + - `initialMode: 'demo' | 'live'` is only the default when no saved preference exists. A stored real/empty choice always wins. + - `storageScope: string` is an opaque local demo partition. Use a non-identifying stable value; do not pass an authenticated DID or copy profile data into it. Changing it remounts the event store without migration. The default is browser-shared, not per-account. + - `storage?: Pick` supports tests/embedders. Keep an injected object stable across renders. No dependency installation needed. + - Rendering starts hidden until the stored preference is read. This avoids a flash of fictional profiles when the visitor chose real/empty. Blocked/invalid mode storage fails closed. +- `DemoModeBanner({ className?, showWhenLive = true })` + - Mount once visibly around any demo content. Includes mode switch, scoped confirmed reset, and storage errors. + - In real/empty mode it offers a route back to the demo. It never claims the real service is connected. +- `DemoCommunityPanel({ caseId?, context?, className?, title = 'The discussion moves the work', emptyState = null, initialExpandedThreadId?, showPeople = false })` + - `DemoActivityFeed` is an alias with identical props. It remains a discussion ledger, not a generic primary social feed. + - Filters combine with AND. `caseId` is one of the three aligned IDs above. + - `context` is `'landing' | 'feed' | 'bottleneck' | 'atlas' | 'apps' | 'agents'`. Atlas selects only neural-measurement; apps selects reproducibility and open-artifacts; the remaining contexts include all three. + - Hidden entirely in real/empty mode, except an explicitly supplied `emptyState`. +- `DemoPeople({ caseId?, context?, className?, variant = 'strip' })` + - `variant: 'strip' | 'cards'`. Fictional role, initial avatars, profile modal, and locally persisted follows. Hidden in real/empty mode. +- `DemoNotifications({ className? })` + - For a top-right header slot only. Hidden in real/empty mode. Bell count derives from unread, undismissed fixture notifications. + - Popover uses modal semantics with focus trap/Escape/return focus. Each action has an actual `/lab/demo/?discussion=…#demo-discussion-…` target. Same-route actions open/focus the discussion; elsewhere the anchor navigates normally. Read and dismiss survive reload. +- `DemoCommunityExperience({ showBanner = true, showNotifications = true })` + - Full page interior, not an extra site shell. Supports `?case=neural-measurement` and discussion deep links. Unknown discussion IDs render a truthful notice. + - The supplied route uses default props. If parent moves banner/bell into global chrome, parent should change this route composition to `showBanner={false} showNotifications={false}` to avoid duplicates. + +## Hook contract + +```ts +const { + mode, isDemo, ready, available, error, + state, activeThreadId, navigationRevision, unreadCount, + setMode, act, resetDemo, openDiscussion, counts, +} = useDemoCommunity(); + +setMode('live'); // persisted global view preference; NOT a live-service connector +act({ type: 'reply', threadId: 'split-boundary', parentId: 'r6', text: 'A smaller test…' }); +act({ type: 'save', threadId: 'split-boundary' }); // toggle +act({ type: 'follow', personId: 'mira' }); // toggle +act({ type: 'allocate', proposalId: 'split-check', delta: 1 }); // or -1 to reclaim +act({ type: 'read', notificationId: 'revision-ready' }); +act({ type: 'dismiss', notificationId: 'revision-ready' }); +openDiscussion('split-boundary'); // opens a matching mounted panel, not a router +counts('reproducibility'); // { people, discussions, messages, points } +resetDemo(true); // caller must first obtain explicit confirmation; banner does so +``` + +`setMode`, `act`, and `resetDemo` return `{ ok: boolean, error?: string }`. `openDiscussion` returns void. `navigationRevision` increments even for repeated targets, ensuring a previously collapsed discussion reopens. Hook `state` is empty, counts/unread are zero, and `activeThreadId` is null when demo is hidden. Outside a provider the hook is safe and inert (`available: false`, `mode: 'live'`); actions return failure rather than pretending to save. Standalone fixture arrays in `lab-demo.ts` are always fictional and must not be placed into a real feed. + +## Parent composition examples + +```tsx +// Client adapter mounted by the parent inside Lab layout, above LabShell. + + {children} + + +// Inside the parent's header, top-right. Keep the real next-action inbox separate. +const { isDemo } = useDemoCommunity(); +return isDemo ? : ; + +// Inside the parent's main, once (omit on /lab/demo/ if its built-in banner is used). + + +// Bottleneck surface: supplement, do NOT replace its diagnosis/draft/editor logic. + + + +// Atlas: keep real sources and evidence editing separate. + + + +// Landing or secondary feed lane. + + +``` + +Placeholder names above refer to parent's existing components, not exports from this module. Parent retains all existing auth, onboarding, canonical profile completion, drafts, real-local inbox, feeds, and bottleneck logic. No integration edits to those modules are included here. The supplemental `/lab/bottlenecks/?case=…` links require the peer route to be integrated; only the demo's own discussion targets were exercised in this worktree. + +## Persistence, safety, and reset + +Only two key forms are touched: + +- `app-demo:mode:v1`: global `'demo' | 'live'` preference. +- `app-demo:community:v1:${encodeURIComponent(storageScope)}`: replies, follows, saved discussions, allocations, read/dismissed notifications. + +No access to real profile/draft storage, session, OAuth, PDS, or network clients. User text renders as text, never HTML. A reply author is always the fixed `demo-visitor` label “Your demo note,” never an authenticated identity. The form caps replies at 2,000 characters; total replies are capped at 100; persisted JSON is bounded and structurally/reference validated. Failed saves keep input text. Corrupt or owner-mismatched demo data is preserved and not overwritten by ordinary actions. + +Reset requires explicit confirmation and removes only the selected demo event key, not all storage or even all demo scopes. It retains the global mode preference. LocalStorage writes are read back before a successful save is reported. Storage events synchronize the mode and active scope across tabs. Concurrent simultaneous writes from separate tabs are still last-writer-wins; this is not a transactional database. + +Five local fictional points are available for interest allocations and can be reclaimed. Counts always derive from the fixtures plus successful local events. Nothing is paid, pooled, dispatched, minted, or issued as a Hypercert. No one is notified. Nothing is publicly posted. The local demo reply store is not a private encrypted vault; do not type sensitive material into a shared browser. + +## Verification and limits + +Run from repository root: + +```sh +node --test scripts/lab-demo*.test.mjs +node --test scripts/lab-demo*.test.mjs scripts/lab-ui-core.test.mjs scripts/lab-record-inspector-ui.test.mjs +node node_modules/typescript/bin/tsc --noEmit --incremental false +``` + +Tests execute the real React components in jsdom and the real TypeScript fixtures/reducer using the project's existing loader. Coverage includes fixture/reference integrity, derived counts, local replies and safe text rendering, profile/follow, save, finite/reclaimable support, notification navigation/read/dismiss/reopen, direct and unknown targets, no-provider safety, context filtering, persistent hiding, confirmed reset preserving a planted genuine draft, corrupt/oversized/mismatched/blocked storage, scope remounts, cross-tab mode sync, Escape/focus return/Tab wrap, and no demo fetch or forbidden shared-store dependency. + +Actual Next dev route returned HTTP 200; browser interactions and screenshots checked at 320px, 390px, and 1440px. Existing root chrome in this base still shows the brochure header/footer: parent owns shell integration. The module uses scoped CSS, Aileron + `var(--font-serif)`, warm paper, PL-blue accents, a darker blue for accessible text/actions, and 44px controls. No animation library or generated live events. The demo stays intentionally light-paper even under the parent dark theme; no dark-specific visual QA is claimed. + +No full production build, public writes, PDS records, sends, auth tests, merge, push, or deployment. No independent nested critic: a fresh skeptical pass plus deterministic tests/browser probes was used within the specialist scope. Production/server/browser matrix QA and cross-surface composition remain the parent's responsibility. diff --git a/docs/open-lab/effort-backing.md b/docs/open-lab/effort-backing.md new file mode 100644 index 00000000..b20a9911 --- /dev/null +++ b/docs/open-lab/effort-backing.md @@ -0,0 +1,49 @@ +# Effort backing: a local allocation experiment + +## What this is + +An optional `/lab/efforts/` route, additive to Open Lab's collaboration and one-minute experiments. The Operate surface pairs a small support ledger with three editorial proposals and a work inspector. They are proposed next steps around a real open scikit-learn example, not active initiatives, staffed campaigns or endorsements. No fictional researchers, supporters, crowd totals or completed work are shown. + +`POINT_BUDGET = 100` in `src/lib/lab-efforts.ts` is an illustrative, adjustable design assumption. Initial support is zero for every effort. Setting support, adding/reclaiming a point, or reclaiming an effort's support updates the shared finite budget. Reclaim from one proposal and allocate to another to shift support. Unallocated points are allowed. No authentication, network request, money, global vote, funding commitment, automatic review or remote execution occurs. + +## Support is not evidence + +Each proposal has work scope, a human acceptance criterion, negative-result handling and real source links. The tutorial and source-audit recipes reference scikit-learn's [common pitfalls documentation](https://scikit-learn.org/stable/common_pitfalls.html). The notebook proposal also links the [Jupyter notebook format](https://nbformat.readthedocs.io/en/latest/format_description.html); its format documentation was corroborated through the [official source](https://github.com/jupyter/nbformat/blob/main/docs/format_description.rst) because Read the Docs returned 403 to the fetch client. + +One optional URL + summary per effort is saved separately, even with zero support. Only explicit http(s) URLs are accepted; credentials, whitespace/control characters and other schemes are rejected. Links use `noopener noreferrer`. Saving does not visit the URL or review its contents. A negative observation is welcome. Popularity is not scientific validity. + +## Persistence and export + +- Local storage key: `open-lab-effort-portfolio-v1`. +- Exact versioned state shape, exact effort IDs, complete allocation map, integer/nonnegative/per-effort and aggregate bounds. Malformed or unknown state is rejected as a whole, not partially restored. +- Corrupt state is visibly ignored, with zero support; no mount-time overwrite. The next intentional change replaces it. +- Storage getter/read/write/quota failures are caught. The UI remains usable in memory and says changes are not saved. Writes are read back before a saved-state claim. +- Same browser/origin only. Clearing storage or using another device creates new local state: this is not one-person-one-budget. Multiple tabs are not synchronized; the last saved edit wins. This is disclosed, not hidden behind fake identity. +- Unsaved evidence edits survive proposal selection within the current mounted component but not a reload; only explicitly saved notes are exported. +- Export downloads `open-lab-effort-portfolio.json`, titled “Open Lab — local effort portfolio,” with allocations, saved evidence, budget assumption, disclaimers, source links and proposed-work design sketches. Nothing is sent. Review private content before sharing it yourself. +- If the budget or effort catalog changes, revisit storage versioning/migration: existing state outside the new bounds is rejected. + +## From an effort to a Hypercert + +Official primary sources read on September 10, 2026: + +- [Core data model](https://docs.hypercerts.org/core-concepts/hypercerts-core-data-model) +- [AT Protocol quickstart](https://docs.hypercerts.org/getting-started/quickstart) + +A future integration should reuse `org.hypercerts.claim.activity`, not create a proprietary Open Lab effort-claim lexicon. The activity anchors work scope, contributors, time and location. Separately authored attachments, measurements and evaluations reference records using `{uri,cid}`; evaluators retain their own records on their own PDS. Current documentation says activity claims and linked records are immutable and on-chain tokenization is not implemented. + +The expandable preview is explicitly “NOT ISSUED,” a local design sketch, not a validated mint payload or AT Protocol record. It intentionally contains no `$type`, invented DID/CID, contributor identity, work dates or claimed outcome. The human-readable design-sketch envelope is not a proposed protocol schema. Actual schema validation, provenance, authorized identity and explicit publishing would be separate future work. Claims/evidence/evaluations do not themselves certify truth, allocate funds, or convey equity/IP. No OAuth scopes, account operations or certificate issuance are implemented. + +## Verification and integration + +Run from repository root: + + UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 node --test --test-concurrency=1 scripts/lab-efforts*.test.mjs + UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 node node_modules/typescript/bin/tsc --noEmit --incremental false + git diff --check + +Nine tests cover model defaults; allocate/reclaim/shift and strict bounds; 2,000 deterministic allocation attempts; corrupted reloads; evidence validation; named export/non-issued preview; actual jsdom actions; actual Blob JSON contents/download filename; and corrupt/unavailable storage UI. RED→GREEN was exercised for each slice. A fresh-skeptical review caught validation errors appearing below the evidence form; a failing regression test now requires budget errors beside the support controls. + +Plain CSS is entirely scoped to `.effort-backing`, inherits `.open-lab` tokens, and has standalone fallbacks. Numeric controls are at least 46px; keyboard focus is visible; selecting a proposal moves focus to the inspector heading. No modal, layout, nav, shared CSS, package manifest or protocol files changed. The page imports its own CSS and uses alias imports. The parent must integrate its existing `/lab/` layout and may add an optional `/lab/efforts/` link. No shared nav replacement is required. + +Standalone real-browser checks use an in-memory local bundle of the actual component (not a full Next build); integrated shell/fonts/dark mode still need the parent's final QA. No installs were needed; local `node_modules` was symlinked to the parent's existing dependencies without changing them. diff --git a/docs/open-lab/interaction-references.md b/docs/open-lab/interaction-references.md new file mode 100644 index 00000000..872448c0 --- /dev/null +++ b/docs/open-lab/interaction-references.md @@ -0,0 +1,69 @@ +# LessWrong → Open Lab: five interaction mechanics worth borrowing + +**Research date:** 11 September 2026. **Scope:** five primary-authored LessWrong documents plus two first-party source-code spot-checks; not a visual audit or comprehensive community assessment. + +**Recommendation:** borrow the structures that make contributions accumulate, not the discussion feed, cultural branding, or karma economy. For Open Lab, the central loop should remain **find an artifact → take a bounded test/build task → My bench → return evidence → improve the artifact**. The recommendations and explanations below are product hypotheses, not measured effects of LessWrong's design. + +## 1. Make topics durable workspaces, not disposable hashtags + +**Published mechanic.** The Wiki-Tag FAQ describes concept explanations linked to collections of posts, relevance voting that orders those collections, and topic filters that increase or decrease exposure in the frontpage feed.[2] Its stated goal is longevity: “we want users to read the best and most relevant content to them – whenever it was written.”[2] + +**Borrow/adapt.** Clicking a discipline or idea should open a durable page with a short explanation, notable artifacts, open tests, and recent evidence. Keep broad disciplines in the left rail and more specific idea links on artifacts. Let members suggest relevant links, with a maintainer able to merge duplicate concepts. Show *why this artifact belongs here*, not merely its popularity. + +**Avoid.** A sprawling taxonomy or an AI-heavy engagement ranking that crowds out other disciplines. Start with a maintained topic set; do not copy karma-weighted authority over categorization. **Hypothesis to test:** people can find and extend prior work rather than repeatedly starting equivalent discussions. + +## 2. Allocate attention deliberately; distinguish discovery from endorsement + +**Published mechanic.** The June 2024 frontpage announcement describes an Enriched list mixing recent posts with personalized recommendations from older material; it also offers explicit following and bookmarking views.[3] Crucially, the author says “this feature direction is still experimental and could turn out to be a bad idea.”[3] Separately, the FAQ describes editorial curation of material considered “well-written, insightful, instructive, or otherwise important.”[5] + +**Borrow/adapt.** Give Open Lab a compact default view of **work worth joining**, with clear alternatives such as Following and Recent evidence. Resurface older artifacts when a test remains open or new evidence changes their status. A small editor-selected “Worth building on” section should say *why selected*. Recommendation reasons should be plain: “You follow materials science” or “Needs your measurement skill.” + +**Avoid.** Copying the published mixing ratio, opaque personalization, endless feed tuning, or treating “featured” as scientifically validated. Begin with explicit follows and simple task/status filters; sophisticated recommendations need not precede a useful collaboration loop. + +## 3. Put contribution norms at the point of contribution + +**Documented mechanic.** LessWrong's source defines frontpage/default comment guidance including “Try to offer concrete models and predictions,” alongside explanation rather than persuasion, curiosity about disagreement, and willingness to change one's mind.[7] The FAQ also describes author-specific moderation guidelines displayed with comment areas.[5] + +**Borrow/adapt.** Put one short, contextual prompt in the response composer: **What did you try? What happened? What would distinguish the competing explanations?** Offer response types such as Question, Test result, Build update, and Correction. For a test result, ask for method, evidence attachment/link, and limitations; do not impose those fields on a simple question. Make the artifact's maintainer and discussion expectations visible. + +**Avoid.** Copying insider vocabulary, making fluency in a canon an entry requirement, or confusing agreement with contribution quality. Do not let maintainers silently erase adverse test results: preserve evidence history and provide a moderation escalation path. **Hypothesis to test:** more replies change the artifact or the next experiment, rather than merely expressing approval. + +## 4. Separate exploratory notes from focused collaboration—and preserve the result + +**Published/source mechanic.** The dialogue announcement calls a dialogue “a space for a few invited people to speak with each other,” with consent-based invitations, shared drafting, and publication of the exchange.[1] The checked Shortform component separately exposes a “New quick take” entry point; this establishes a distinct format in source, not its effect on participation.[6] + +**Borrow/adapt.** Allow lightweight **bench notes** without presenting them as achievements. From an artifact, let someone accept a concrete task or invite a consenting collaborator to a focused working thread. Keep its question, deliverable, evidence requirements, and participants attached to the same artifact. Put accepted work in My bench; the completion action is **Return evidence**, not “Post update.” Publish a concise result with contributor credit and remaining uncertainty, rather than requiring readers to reconstruct a chat. + +**Avoid.** A separate microblog feed that becomes the product, premature real-time co-editing, compulsory public drafting, or dialogue as spectacle. Use small working threads to finish things. Open Lab's proposed Question mode is an adaptation; LessWrong's current dedicated Questions UI was not verified. + +## 5. Revisit claims after use; promote demonstrated value rather than launch excitement + +**Published mechanic.** The 2024 Review announcement, published in December 2025, separates nomination, written review, and final voting.[4] Review prompts include “Is there a subclaim of this post that you can test?” and ask about effects on thinking/actions and useful follow-up work.[4] The described final-vote UI requires at least a brief skim of reviews; editorial judgment remains part of selection.[4] + +**Borrow/adapt.** After a test or build completes, ask an identified reviewer what held up, what failed, and what changed. Periodically revisit promising artifacts, not only new submissions. Keep **featured**, **tested**, and **independently reproduced** separate, with linked evidence, dates, versions, and scope. Credit the tester and the person who found a failure, not just the original inventor. + +**Avoid.** An annual voting ceremony, quadratic voting, or a popularity-derived “validated” badge. Start with lightweight review on returned evidence; add scheduled retrospectives once there is enough completed work to revisit. + +## What this means for the current compact layout + +**Proposed allocation:** left rail = disciplines, followed ideas/people, My bench; center = artifacts with one legible next action; right details = what exists, evidence/status, contributors, and bounded tasks. Show enough context to decide whether to help without expanding every discussion. Keep conversation subordinate to the artifact and its next test. + +**Success measures to test:** time to first useful contribution; accepted tasks that return evidence; artifacts improved by another person; evidence reused across disciplines; and corrections/reproductions that change artifact status. Do not use session length, comment volume, or raw upvotes as substitutes. + +## Evidence and limitations + +- Article bodies were retrieved through LessWrong's documented public GraphQL API (`https://www.lesswrong.com/graphql`, read-only queries, HTTP 200). GreaterWrong copies helped discover the documents but are **not the final cited authority**. +- The homepage and `/faq` extraction returned only `x`; the parent reported an organizational browser allowlist block. No browser bypass was attempted. **Homepage pixels, current default navigation, and signed-in interaction behavior remain unverified.** The article-form FAQ was retrieved instead.[5] +- Sources [1]–[5] are live-retrieved text, but publication dates span 2019–2025. In particular, the 2024 Enriched announcement is design history, **not confirmation of today's default**.[3] The FAQ mixes historical instructions and later updates; its exact thresholds and UI instructions should not be copied.[5] +- Code references are pinned to `f9a60450f7533c019d7734e12e8c08fcd21d1576`, the public `lw-deploy` head returned during inspection, dated 19 December 2025. Source existence is not proof of current deployment or feature exposure.[6][7] +- These sources establish published intent, described workflows, and source-level affordances. This reconnaissance did **not** measure participation quality, onboarding success, moderation fairness, or causal effectiveness. LessWrong is evidence for design possibilities, not proof that those possibilities will produce Open Lab's desired outcomes. + +## Sources + +[1] https://www.lesswrong.com/posts/kQuSZG8ibfW6fJYmo/announcing-dialogues-1 — Announcing Dialogues +[2] https://www.lesswrong.com/posts/E6CF8JCQAWqqhg7ZA/wiki-tag-faq — Wiki-Tag FAQ +[3] https://www.lesswrong.com/posts/TDMKch5qzuaac5LFF/enriched-tab-is-now-the-default-lw-frontpage-experience-for — Enriched tab is now the default LW Frontpage experience for logged-in users +[4] https://www.lesswrong.com/posts/ZpRzTr5QBT6C3Faor/the-2024-lesswrong-review — The 2024 LessWrong Review +[5] https://www.lesswrong.com/posts/2rWKkWuPrgTMpLRbp/lesswrong-faq — LessWrong FAQ +[6] https://raw.githubusercontent.com/ForumMagnum/ForumMagnum/f9a60450f7533c019d7734e12e8c08fcd21d1576/packages/lesswrong/components/shortform/ShortformSubmitForm.tsx — ShortformSubmitForm.tsx — ForumMagnum lw-deploy source +[7] https://raw.githubusercontent.com/ForumMagnum/ForumMagnum/f9a60450f7533c019d7734e12e8c08fcd21d1576/packages/lesswrong/components/comments/ModerationGuidelines/LWModerationGuidelinesContent.ts — LWModerationGuidelinesContent.ts — ForumMagnum lw-deploy source diff --git a/docs/open-lab/launch-transition.md b/docs/open-lab/launch-transition.md new file mode 100644 index 00000000..8db24eb2 --- /dev/null +++ b/docs/open-lab/launch-transition.md @@ -0,0 +1,48 @@ +# Open Lab entry transition + +## Decision and scope + +A short threshold is appropriate for entering a somewhat separate workspace; a repeated transition on routine navigation would be distracting. This change enhances only the existing home-page invitation. Its real `href="/lab/"`, copy, and classes remain unchanged. No domain/DNS, theme-toggle, preference, app-navigation, sign-in, or callback changes are included. + +`OpenLabLaunchLink` renders a native anchor on the server. With JavaScript and supported motion APIs, an ordinary current-tab activation draws one ink-colored circular cover from the pointer's viewport coordinates, or the invitation's center for keyboard/assistive activation. The cover uses the existing lab dark-surface color `#131c21`, not a theme switch. + +The Web Animations duration is **220 ms**, with `cubic-bezier(0.22, 1, 0.36, 1)` easing and a **320 ms watchdog**. Both values are configured behavior, not a browser timing measurement. Normal browser scheduling/throttling can delay timers; this is not a hard real-time guarantee. There is no spinner, minimum loading period, scroll lock, tracking, dependency, persisted flag, or global animation. A fresh explicit entry can animate again after returning; app navigation never invokes this component. + +## Public reference, inspected September 11, 2026 + +The [GainForest home page](https://www.gainforest.earth/) and its linked assets returned HTTP 200 to cookieless public HTTP requests. The mechanism is directly present in the [delivered JavaScript](https://www.gainforest.earth/_next/static/chunks/752fa7128a4d3f31.js?dpl=dpl_ChwgkF17BWtEEFBtGKbor3nNKNvF): + +- The theme action calls `startViewTransition`, then animates `clipPath` from `circle(0px at …)` to a radius computed with `Math.ceil(Math.hypot(...))` on `::view-transition-new(root)`. +- It specifies `duration:1200`, `offset:[0,.06,1]`, and `easing:"cubic-bezier(0.22, 1, 0.36, 1)"`. +- It uses the click coordinates, or the control's bounding-box center when `detail` is zero, and immediately applies the theme when reduced motion is requested or View Transitions is unavailable. + +The [delivered stylesheet](https://www.gainforest.earth/_next/static/chunks/d82c00ffd0c2140c.css?dpl=dpl_ChwgkF17BWtEEFBtGKbor3nNKNvF) disables default root View Transition animations, sets old/new snapshot stacking, and initializes the new snapshot with a circular clip-path. This identifies a circular snapshot reveal, not a fluid simulation. Its association with the user's visual reference is source-based inference; no visual timing or fidelity is claimed. + +The parent reported the headed-browser restriction exactly as “This page is blocked / Your organization doesn’t allow you to view this site.” This lane used no browser, alternate browser, authenticated request, or allowlist bypass. Research was limited to the public page and its linked JS/CSS. + +## Navigation and failure contract + +- No JavaScript: the anchor still points exactly to `/lab/` with its existing accessible text and native keyboard behavior. +- Ctrl/Meta/Shift/Alt, non-primary/middle/right activation, non-self targets (including a document base target), downloads, already-canceled events, non-HTTP URLs, and same-document links remain native. No custom keydown handler is installed. +- Reduced motion, missing/unsupported CSS clip-path or Web Animations APIs, missing media-query support, or animation setup failure leave the original click unprevented. A scoped media rule also hides the cover if reduced motion becomes active; its change event completes an active departure immediately where supported. +- Finish, asynchronous rejection, and watchdog converge on one guarded completion. The cover, animation, timer, and motion listener are cleaned before navigation. Repeated ordinary clicks do not queue more loads. Unmount, pagehide, pageshow, and popstate cancel pending work; lifecycle listeners are removed on unmount. A synchronous navigation exception leaves the next activation native. +- Navigation uses `window.location.assign` with the anchor's resolved destination, including its query/hash. It deliberately performs a full document load even for `/lab/`. It therefore does not require same-origin View Transitions and can support a future cross-subdomain href without assuming a destination-side animation. The cross-origin test uses a reserved example hostname, not a proposed or changed domain. + +This is a departure cover, not a seamless two-document reveal. The old page may briefly reappear between cover removal and the next document's paint on a slow connection. Holding an opaque overlay for an unbounded network load would turn the effect into the loading spectacle this change is meant to avoid. Parent browser QA must judge that handoff before release. + +## Verification and remaining gates + +Production behavior was added in vertical RED/GREEN cycles: a failing component test (exit 1), minimal implementation, and a passing targeted suite (exit 0). Additional cross-origin, rejection, and theme/storage regression probes exercised already-implemented paths. Tests mount the actual TSX through the repository's existing source loader, React, and jsdom, and inspect real anchor/DOM/CSS behavior. Only browser-only layout/WAAPI/media support, CSS module names, and jsdom's final document-navigation boundary are controlled. `Location.assign` URL parsing itself is real. No mocks substitute for the component. + +Verification commands: + +```sh +UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 taskset -c 1 node --test --test-concurrency=1 scripts/lab-launch-transition.test.mjs +UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1 taskset -c 1 node node_modules/typescript/bin/tsc --noEmit --incremental false +``` + +The targeted suite covers SSR/native href and name, ordinary-only activation, keyboard center/focus, reduced motion (including a mid-cover change), unsupported/setup/async/cancel failures, bounded completion, duplicate suppression, unmount/history/page restoration, exact cross-origin navigation, navigation refusal recovery, and unchanged theme/storage/body styles. A separate byte comparison against the base verifies that `page.tsx` differs only in the invitation component substitution and its import. + +Fresh-skeptical source review found and fixed the synchronous navigation-refusal latch; its regression test is included. No nested critic was run. This lane does not claim a visual pass, real tab opening, native Enter dispatch synthesis, network destination loading, bfcache behavior in a browser, or full-site build/integration success. + +Parent-owned release gates: desktop and narrow/mobile viewport appearance in both themes; pointer and Enter activation; reduced motion; native modifier/middle/new-tab behavior and JavaScript-disabled navigation; rapid clicks; slow-network cover-to-document handoff; browser Back restoration; and confirmation that routine lab routes and sign-in/callback are unaffected. diff --git a/docs/open-lab/protocol-runbook.md b/docs/open-lab/protocol-runbook.md new file mode 100644 index 00000000..a214ef70 --- /dev/null +++ b/docs/open-lab/protocol-runbook.md @@ -0,0 +1,87 @@ +# Open Lab protocol pilot runbook + +## Status and trust boundary + +Open Lab uses the official `@atproto/oauth-client-browser` SDK, separate from the existing CMS OAuth client, cookie, admin allowlist, indexer and page-writing identity. The SDK implements PKCE, DPoP, callback verification, refresh, IndexedDB storage and revocation. Open Lab accepts an existing AT Protocol handle; no password, app password, provider key or new confidential-client secret is collected. The browser session grants no CMS admin rights. + +This is a working candidate-schema protocol client, not a released or populated social network. Configuration readiness is not account validation: capabilities explicitly return `oauthVerified:false` and `schemaPublished:false`. No real account authorization or public PDS mutation was performed as part of implementation tests. Owner consent and an exact-origin account smoke are separate launch gates. + +The records are public, self-reported and potentially replicated. They do not post to the Bluesky timeline, create community membership, imply peer review, update the Neuro Atlas, or make a submission editorially featured. PDS readback is not a cryptographic repository-signature proof. A PDS deletion does not recall replicas or copies. The UI must state these distinctions before every public confirmation. + +## Configuration + +Use Node 22 or newer for the browser SDK toolchain. Both repository lockfiles are maintained; existing npm/pnpm dependency baselines otherwise remain separate. Native pnpm 10, not a conversion of the npm graph, owns pnpm-lock.yaml. + +- `LAB_PUBLIC_URL`: exact public HTTPS origin without credentials, path, query or custom port. Example: `https://lab.example.org`. Empty explicitly disables sign-in. This does not use legacy `PUBLIC_URL` or Host/Forwarded headers to choose the OAuth identity. +- On Vercel previews (`VERCEL=1`, `VERCEL_ENV=preview`), omitting `LAB_PUBLIC_URL` prefers the strict deployment-provided `VERCEL_BRANCH_URL`; if that variable is absent, use strict `VERCEL_URL`. A present malformed/empty branch value fails closed. Non-production Vercel origins must equal a validated deployment or branch URL, never inherited production or an arbitrary alias. Production does not select the preview branch URL. Access-protected previews cannot serve public OAuth metadata: use an owner-approved public deployment. See [v2 auth](v2-auth.md) for exact-alias smoke and the dedicated-domain approval path. +- `LAB_ENABLE_CONNECT`: absent or exactly `true` enables separately consented native Bluesky follow/unfollow on a configured origin; any other explicitly present value disables those controls. This is separate from custom publication. +- `LAB_ENABLE_PUBLISH`: exactly `true` enables custom Lab-record write controls. Default/off keeps custom records local; it does not disable separately consented native follows. This is a cooperative client feature gate, not a revocation of an existing PDS token. Disable/revoke grants at the account too when needed. +- Local: set `LAB_PUBLIC_URL=http://127.0.0.1:3000` and open that exact IP-based origin. IPv6 loopback also works. The official SDK loads the special `http://localhost?redirect_uri=...&scope=...` client ID; the callback remains the IP-based `/lab/oauth/return/`. Plain `http://localhost:3000` is deliberately not treated as the OAuth origin. Production/Vercel cannot use loopback config. + +`GET /api/lab/capabilities/` is no-store and checks the request origin against configured identity. `mode:ready` means the complete origin/config is usable in principle, NOT a completed external account test. The browser revalidates the full metadata identity and refreshes capabilities before every authorization/write. `GET /api/lab/oauth/client-metadata.json` must return JSON at that exact URL with no redirect or authentication wall. It permits cross-origin metadata reads, declares a public web client (`none`, PKCE/DPoP) and the five custom collection-specific maxima plus native `app.bsky.graph.follow` create/delete permissions. Metadata does not grant those permissions. + +Base login requests only `atproto`. A separate explicit Authorize action requests just one `repo:?action=`, plus `atproto`. Only profile allows update; all five allow create/delete. Native connection authorization separately requests only `repo:app.bsky.graph.follow?action=create` or `?action=delete`, plus `atproto`. There is no generic transition scope or Bluesky feed/profile write grant. Authorization returns to the draft, NEVER an automatic publish/delete continuation; review and confirm again. A new grant can replace earlier action scopes, so check actual token scope each time. + +Browser OAuth stores credentials in the SDK's IndexedDB store. They are accessible to same-origin JavaScript: prevent XSS, do not render user HTML, and do not treat CMS/third-party scripts on the same origin as isolated from these credentials. A dedicated hardened origin/BFF is a possible later hardening choice, not implemented here. Sign-out immediately removes the runtime session and invalidates pending restore/callback/authorization navigation, then asks the SDK to remove its local session with best-effort remote revocation. A late restored session is discarded and passed to SDK sign-out, never installed or redirected. The installed SDK suppresses remote revocation errors: a resolved logout confirms neither remote grant removal nor that revocation failures were observable. If SDK cleanup throws, the runtime remains signed out, but local persistent removal is unconfirmed; clear this site's browser storage. Account-side Open Lab grant revocation is the decisive remote recovery step, including after suspected compromise. Browser storage restrictions and CORS failures are honest failure states. + +The fixed `https://bsky.social` handle resolver sees the supplied handle and visitor IP. `plc.directory`, a did:web domain and the resolved PDS see public lookup traffic. Explain this in the login UI. No arbitrary server-side DID/PDS proxy exists. + +## Candidate schemas and portability + +Static JSON candidate schemas live at `public/lab/lexicons/org.plresearch.lab.{profile,note,app,contribution,participation}.json`. The `org.plresearch` namespace follows the existing project domain, but these five app-specific lexicons have NOT been published through global Lexicon/DNS resolution. Confirm domain authority before registering them. PDS calls explicitly use `validate:false` only after the app's formal Lexicon validator and stricter business validation pass. An owner smoke must check that the actual PDS accepts this candidate-schema flow. A PDS that refuses it is an incompatibility, not a reason to silently switch collections. + +All records include `$type`, stable community marker `https://www.plrd.org/lab/`, `createdAt` and kind-specific data. The stable community marker is shared by explicitly enabled pilots; a preview is NOT a private PDS sandbox. Profiles use `self`; other records use SDK TIDs. Profile updates preserve `createdAt` and atomically compare the previously reviewed CID. App records and contributions can reference public artifacts without claiming their creators have joined. + +No indexer changes, global discovery or moderation queue are included. The public record inspector reads a supplied exact AT URI. A public member notebook lists one DID/collection page at a time. It is not a list of all community members. Public workbench Bluesky discovery stays separate from this protocol client. + +## Frontend integration API + +Mount `LabAuthProvider` around the Lab app only. `useLabAuth()` returns: + +- `session:{did,handle,displayName?,avatar?}|null` — after official SDK init, the public AppView profile must match the exact SDK DID and the current handle must resolve back to it. Display name is presentation, not identity/affiliation proof. Only safe Bluesky CDN avatars are rendered. Lookup failure retains the DID fallback; generation guards prevent late lookup/restore from undoing logout. +- `oauthSession:OAuthSession|null`, `isAuthenticated`, `isLoading`, `error:string|null`, `capabilities:LabOAuthConfig|null`. +- `login(handle,returnTo?)`, `logout()`, `authorizeWrite(kind,action,returnTo?)`, and `authorizeConnection(action,returnTo?)` — all return promises; catch rejections and display the hook error. Base login remains `atproto` only. +- `isSessionCurrent(oauthSession)` — synchronous runtime guard for work that must stop on logout/session change, even before React renders. + +`BlueskyConnections` (default export from `@/components/lab/social/BlueskyConnections`) supports optional `personDid` and otherwise a public handle lookup. The new `@/lib/lab-connections` client performs native follow/unfollow via the SDK session, exact public author/subject consent, minimum per-action scopes, bounded own-repo existing-follow lookup, a standard TID, localStorage recovery journal, and exclusive Web Lock. It requires exact URI/CID/body readback or exact RecordNotFound and never retries an uncertain mutation automatically. Recovery is read-only, remains blocked on uncertain create absence, and survives reload. The component saves the target before scope escalation, returns to that draft, and requires confirmation again. Follow is one-way, not proven mutuality. Detailed contracts, limits, security boundaries and owner-gated smoke: [v2 auth](v2-auth.md). + +`@/lib/lab-protocol` exports: + +- `readLabRecord(uri:string):Promise`. +- `listLabRecords(did:string,kind:LabKind,options?:{limit?:number,cursor?:string}):Promise`. +- `parseLabUri(uri)` returns `{did,collection,kind,rkey}`. + +`LabRecordView = {uri,cid,authorDid,kind,record,data,pds,provenance:'pds-https-unverified-signature'}`. `record` has metadata and business fields; `data` contains only business fields typed by `LabDataMap`. `LabRecordPage = {records:LabRecordView[],cursor?:string,authorDid,kind}`. Defaults to 30, maximum 100. Follow each collection's cursor explicitly. A returned page is never a declared complete notebook. Failed/malformed pages throw rather than claiming an empty notebook. No local draft fallback is used for public record inspection. + +Public reads use the official DID resolver and Agent directly in the browser, without cookies/auth headers. Only valid did:plc or domain-only did:web identifiers are supported; no handles, encoded ports, did:web paths, arbitrary schemes or literal IP endpoints. PDS origins require public HTTPS, no credential-bearing URLs or private suffixes, and no redirects. Responses have a 12-second network deadline and 1 MiB streamed size bound; request smaller pages if needed. DNS rebinding/public-host DNS and the PDS operator remain trust boundaries; this is not a cryptographic DID/repo proof verifier. Server execution is refused before external fetch. + +`@/lib/lab-records` exports: + +- `publishLabRecord(oauthSession,kind,data,consent):Promise`. +- `deleteLabRecord(oauthSession,uri,consent):Promise`. +- `LabPermissionError` (`kind`, `action`) — offer an explicit Authorize button; don't silently grant/write. +- `LabWriteVerificationError` (`uri`) — the operation may already have committed. Preserve the draft and inspect that URI before retrying. Never automatically duplicate a failed-verification create. + +`LabWriteConsent = {public:true,experimental:true,did,action:'create'|'update'|'delete',expectedCid?:string}`. Obtain this from an explicit user review of the exact data and public-replication/candidate-schema notice, not from page load. Create works for all five kinds. Update is profile-only and requires `expectedCid` from the reviewed profile. Delete requires a reviewed exact own URI and `expectedCid`; it cannot delete another DID's record. + +Each mutation uses the SDK session's own DID/PDS; caller-supplied author/authority/acceptance fields are refused. Runtime validators enforce exact allowed fields, sizes, enums and safe URLs. Treat text as text, never `dangerouslySetInnerHTML` or unsanitized Markdown. Empty optional URL fields must be omitted, not submitted as empty strings. + +Credential-bearing query or fragment parameters (including hash-router queries and percent-encoded parameter names) are rejected before any write. Harmless research anchors are retained; the reviewed URL is never silently stripped or normalized. + +Authenticated mutation responses and all preflight/readback responses wrap the official session's `fetchHandler`: each request has a 12-second deadline covering response acquisition and body streaming, and a 1 MiB streamed cap before SDK buffering. A deadline also settles locally if a transport ignores abort or stream cancellation; it cannot undo a committed write. The wrapper neither extracts credentials nor adds retries; SDK token refresh/DPoP behavior remains SDK-owned. Any timeout/oversize after a mutation attempt returns `LabWriteVerificationError` with the exact target URI, never `verified:true`. Preflight failure makes no mutation. `createLabRecordWriter` optionally accepts `{timeoutMs}` as its third argument to tighten (never exceed/disable) the deadline for controlled tests; public helpers use the fixed default. + +`LabWriteReceipt = LabRecordView & {verified:true,verification:'pds-readback'}` only follows exact latest URI/CID/body readback. The read intentionally has no historical `cid` argument. `LabDeleteReceipt = {uri,deleted:true,verified:true,verification:'pds-record-not-found'}` requires the exact PDS RecordNotFound response after CAS deletion; 5xx/network failure/still-present are not successful deletion. + +There is no new authenticated `/api/lab/records/` server endpoint. Use these browser functions, not the superseded server/API contract. + +## Verification and owner-gated launch checklist + +Local tests use synthetic records and injected SDK/PDS transport; they make NO live public writes. Run `npm run test:lab` and `node node_modules/typescript/bin/tsc --noEmit --incremental false`. Constrain concurrency with `UV_THREADPOOL_SIZE=1 NODE_OPTIONS=--v8-pool-size=1`. The integration owner runs the full production build and independent UI/security review. + +Before enabling a public pilot, obtain the owner's explicit account/write consent, then: + +1. Fetch the exact public metadata URL without login/redirect; check client ID, callback and action scopes. Verify an unrelated preview/alias reports unconfigured. +2. Use an owner-approved test account for identity-only login, cancel/back/replay failure, reload/refresh, and logout. Observe the real authorization screen and token scope; never claim the synthetic tests prove these. +3. With separate approval for the exact public text, create each candidate kind; read the returned URI signed out in a different browser. Verify the latest CID and body. Check profile update conflict behavior and own deletion. Confirm no Bluesky timeline post/Atlas edit or legacy CMS access. +4. Verify draft recovery after authorization, accessibility, permissions UI, deletion notice, no raw-HTML rendering, clear outage states, public record links, and per-collection notebook pagination in the integrated UI. +5. Establish moderation/reporting/takedown policy and app-specific discovery/indexing before calling this a released community. Do not fabricate member counts, reviewers, impact or live campaigns to fill the pilot. diff --git a/docs/open-lab/review-2026-09-11/README.md b/docs/open-lab/review-2026-09-11/README.md new file mode 100644 index 00000000..ab2a1fe1 --- /dev/null +++ b/docs/open-lab/review-2026-09-11/README.md @@ -0,0 +1,41 @@ +# Open Lab — September 11 review evidence + +This packet contains **13 native localhost browser screenshots**, captured from implementation revision `4bcbeaf2debd14b7386719f9dc99ff5eae3811e9` at `http://127.0.0.1:3390`. They are local development-server captures, not hosted-preview captures or proof of authenticated provider behavior. The evidence-only commit does not change application code, tests, dependencies, or infrastructure. + +[`manifest.json`](manifest.json) records each image's SHA-256, source revision, and origin. All 13 hashes were checked before attachment. PNGs contain only IHDR/IDAT/IEND chunks; no embedded text or EXIF metadata. Visual review found only labeled local QA/demo content, public site material, and the public `bsky.app` profile/DID. No credentials, signed URLs, private team records, or authenticated account content are included. Historical-person personas are fictional examples, not mapped accounts. + +## Captions + +| File | Evidence and limits | +|---|---| +| `home-1440.png` | Compact left navigation, workshop feed, branch context, persistent account/My bench; local QA build and fictional demo activity. | +| `home-390.png` | 390×844 mobile workshop, visible Curate and first local QA row. | +| `home-320.png` | 320×812 mobile workshop with naturally wrapped controls/content. | +| `profile-320.png` | 320×812 My bench; local profile draft, not a public profile. | +| `observatory-320.png` | 320×812 tech tree defaults to List. | +| `tech-tree-light-desktop.png` | 1440×1000 light tech-tree Map and source-linked editorial brief; geometry is not a measured live relationship. | +| `tech-tree-dark-desktop.png` | Same desktop route with explicit dark appearance. | +| `first-arrival-real-login.png` | Skippable real-SDK sign-in entry UI; empty handle and no account authorization. | +| `real-bluesky-profile.png` | Public `bsky.app` lookup while signed out; Open Lab subscription is local, not a native Bluesky follow. | +| `real-public-read-provenance.png` | Four supported public collections honestly return no activity for the selected DID; bounded read, not a complete network feed. | +| `global-demo-control.png` | Explicit global Demo/real-empty separation and local-only/no-messages notice. | +| `final-media-reopened.png` | Saved local QA build retains decoded PNG/MP4, caption, and source-linked negative result after reopening; not scientific evidence or a public upload. | +| `homepage-openlab-ad.png` | Local PL R&D homepage invitation; no production launch. | + +[`shell-probes.json`](shell-probes.json) covers requested widths 1440/1024/390/320 and required shell controls. [`route-probes.json`](route-probes.json) covers eight secondary routes at 320px. [`media-final-probes.json`](media-final-probes.json) records retained image/video dimensions, duration, controls, one saved build, and negative-result presence. The accompanying responsive assertion run passed 5/5 with no skip. + +## Exact implementation validation + +At `4bcbeaf2debd14b7386719f9dc99ff5eae3811e9`, the completed isolated replay used a fresh pnpm 10 frozen install, then the full suite (**519 tests: 518 passed, 0 failed, 1 optional native-probe-file skip**), typecheck, and production build. The native-probe assertion was separately exercised, not silently counted as a pass in the full suite. Build completed with existing remote-data/cache warnings; completion is not a dependency/security clearance. + +Independent product/QA review: **PASS for draft review preview**, no reproduced material fix queue. Independent security review: **PASS CONDITIONAL for draft review preview**, no reproduced material finding. Both reviewed this exact implementation revision and completed before the preview push. The parent reproduced the reviewers' three product and ten security probes. Cancellation at the real installed SDK's final fetch boundary prevents later resource transmissions; already-transmitted writes can still commit and remain conservatively recoverable, never automatically retried. + +The earlier [`verification.md`](../verification.md) describes the previous preview iteration; use this packet and the current PR deployment readback for the September 11 update. + +## Held gates + +- Hosted configuration/metadata readiness must be read back at the exact deployed origin. Local sign-in UI is not that readback; see the current PR description for the result. +- Consented real-account authorization, callback, refresh/logout, authenticated profile completion, native follow/unfollow, and public-record receipts remain unverified. No real account was authenticated or mutated for this packet. +- Custom publication remains default-off. Local bench, media, results, bookmarks, subscriptions, and saved views are not cross-user synchronization. Populated/error public-feed cases use synthetic responses in tests, not fabricated live activity. +- Canonical schema publication, discovery/indexing, moderation, abuse handling, dedicated-origin security, and dependency/hosting review remain launch gates. +- No merge, production deployment, domain/DNS change, or `openlab.plrd.org` launch is authorized by this review. diff --git a/docs/open-lab/review-2026-09-11/final-media-reopened.png b/docs/open-lab/review-2026-09-11/final-media-reopened.png new file mode 100644 index 00000000..a3f4161b Binary files /dev/null and b/docs/open-lab/review-2026-09-11/final-media-reopened.png differ diff --git a/docs/open-lab/review-2026-09-11/first-arrival-real-login.png b/docs/open-lab/review-2026-09-11/first-arrival-real-login.png new file mode 100644 index 00000000..c34daf2a Binary files /dev/null and b/docs/open-lab/review-2026-09-11/first-arrival-real-login.png differ diff --git a/docs/open-lab/review-2026-09-11/global-demo-control.png b/docs/open-lab/review-2026-09-11/global-demo-control.png new file mode 100644 index 00000000..73224ffb Binary files /dev/null and b/docs/open-lab/review-2026-09-11/global-demo-control.png differ diff --git a/docs/open-lab/review-2026-09-11/home-1440.png b/docs/open-lab/review-2026-09-11/home-1440.png new file mode 100644 index 00000000..7d226e13 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/home-1440.png differ diff --git a/docs/open-lab/review-2026-09-11/home-320.png b/docs/open-lab/review-2026-09-11/home-320.png new file mode 100644 index 00000000..6da7b01c Binary files /dev/null and b/docs/open-lab/review-2026-09-11/home-320.png differ diff --git a/docs/open-lab/review-2026-09-11/home-390.png b/docs/open-lab/review-2026-09-11/home-390.png new file mode 100644 index 00000000..d8e2f5c7 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/home-390.png differ diff --git a/docs/open-lab/review-2026-09-11/homepage-openlab-ad.png b/docs/open-lab/review-2026-09-11/homepage-openlab-ad.png new file mode 100644 index 00000000..9a8580e0 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/homepage-openlab-ad.png differ diff --git a/docs/open-lab/review-2026-09-11/manifest.json b/docs/open-lab/review-2026-09-11/manifest.json new file mode 100644 index 00000000..d30cd575 --- /dev/null +++ b/docs/open-lab/review-2026-09-11/manifest.json @@ -0,0 +1,97 @@ +{ + "count": 13, + "screenshots": [ + { + "file": "home-1440.png", + "sha256": "25155d55a78885cf599103ba79917e4f847472717db1e6a6da25efa48054fdd6", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "home-390.png", + "sha256": "f89a2da349da8e4d405a78754f2ed1bc3771feb937154e91c59095781d34ff17", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "home-320.png", + "sha256": "40021629b69ae1071f266073ec48e2dbf928704e44e2ad128ed9b880f2410884", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "profile-320.png", + "sha256": "e55926bf79625a232d729f4efe4e08bd9595652f969d1657c228d3e4bff8f001", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "observatory-320.png", + "sha256": "d13cbbc12bb680a76fd6512fd6c8570aea3e3119c585b2bc5cdfa0fe7628b4e0", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "tech-tree-light-desktop.png", + "sha256": "357abb473834fb93dc19bd418b331241db44839dfc4782eb2262ee19d3bc1df3", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "tech-tree-dark-desktop.png", + "sha256": "96319480640c164b42d5bd62c9803f1c6a52115b581241317309f6170db4b5c7", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "first-arrival-real-login.png", + "sha256": "611b3b3fedf2da97c82e312857a77bbf08185f44998585cc84bd9248b820fee5", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "real-bluesky-profile.png", + "sha256": "0a855b933cbf41eeb53bbe4667f6e2f91a94d749cf753c3e82756beae2221077", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "real-public-read-provenance.png", + "sha256": "bd949d2277200a313272b70d865a9db40e60f45dfabf2d8120c11c3d2816cca1", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "global-demo-control.png", + "sha256": "489c7a42509df517945af98a5c7e111e33990de09acdc382c589cca9629f487f", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "final-media-reopened.png", + "sha256": "03791b30403449651f8d3cf8726896fe5e302004d46d221bfd2856f1e5389547", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + }, + { + "file": "homepage-openlab-ad.png", + "sha256": "f335e9855a3c9c4cc953e216a24f0be7ccfc557f2d72db4f38d01570b7687639", + "source_sha": "4bcbeaf2debd14b7386719f9dc99ff5eae3811e9", + "origin": "http://127.0.0.1:3390", + "kind": "native-local-browser" + } + ], + "limitations": "Not hosted OAuth/authenticated PDS-write evidence. Own local QA build and media appear on bench/feed." +} \ No newline at end of file diff --git a/docs/open-lab/review-2026-09-11/media-final-probes.json b/docs/open-lab/review-2026-09-11/media-final-probes.json new file mode 100644 index 00000000..0b410c07 --- /dev/null +++ b/docs/open-lab/review-2026-09-11/media-final-probes.json @@ -0,0 +1,17 @@ +{ + "images": [ + { + "width": 480, + "alt": "Local QA instrument photo, not research evidence" + } + ], + "videos": [ + { + "width": 320, + "duration": 1.5, + "controls": true + } + ], + "builds": 1, + "negative": true +} \ No newline at end of file diff --git a/docs/open-lab/review-2026-09-11/observatory-320.png b/docs/open-lab/review-2026-09-11/observatory-320.png new file mode 100644 index 00000000..90ca3efc Binary files /dev/null and b/docs/open-lab/review-2026-09-11/observatory-320.png differ diff --git a/docs/open-lab/review-2026-09-11/profile-320.png b/docs/open-lab/review-2026-09-11/profile-320.png new file mode 100644 index 00000000..c956465c Binary files /dev/null and b/docs/open-lab/review-2026-09-11/profile-320.png differ diff --git a/docs/open-lab/review-2026-09-11/real-bluesky-profile.png b/docs/open-lab/review-2026-09-11/real-bluesky-profile.png new file mode 100644 index 00000000..ab989e46 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/real-bluesky-profile.png differ diff --git a/docs/open-lab/review-2026-09-11/real-public-read-provenance.png b/docs/open-lab/review-2026-09-11/real-public-read-provenance.png new file mode 100644 index 00000000..d0110735 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/real-public-read-provenance.png differ diff --git a/docs/open-lab/review-2026-09-11/route-probes.json b/docs/open-lab/review-2026-09-11/route-probes.json new file mode 100644 index 00000000..cca731d9 --- /dev/null +++ b/docs/open-lab/review-2026-09-11/route-probes.json @@ -0,0 +1,58 @@ +[ + { + "route": "/lab/bottlenecks/", + "width": 320, + "scroll": 320, + "h1": "Find the blockage.Make a way through.", + "error": false + }, + { + "route": "/lab/apps/", + "width": 320, + "scroll": 320, + "h1": "Less setup.More discovery.", + "error": false + }, + { + "route": "/lab/atlas/", + "width": 320, + "scroll": 320, + "h1": "A map is onlyas good asits evidence.", + "error": false + }, + { + "route": "/lab/collaborate/", + "width": 320, + "scroll": 320, + "h1": "Bring your agent.Keep your keys.", + "error": false + }, + { + "route": "/lab/people/", + "width": 320, + "scroll": 320, + "h1": "Find someone to build with.", + "error": false + }, + { + "route": "/lab/profile/", + "width": 320, + "scroll": 320, + "h1": "My bench", + "error": false + }, + { + "route": "/lab/explorations/observatory/", + "width": 320, + "scroll": 320, + "h1": "Explore the tech tree", + "error": false + }, + { + "route": "/lab/efforts/", + "width": 320, + "scroll": 320, + "h1": "What deservesyour support?", + "error": false + } +] \ No newline at end of file diff --git a/docs/open-lab/review-2026-09-11/shell-probes.json b/docs/open-lab/review-2026-09-11/shell-probes.json new file mode 100644 index 00000000..651b2377 --- /dev/null +++ b/docs/open-lab/review-2026-09-11/shell-probes.json @@ -0,0 +1,94 @@ +[ + { + "width": 1440, + "scrollWidth": 1425, + "route": "/lab/", + "activeNavCount": 1, + "scopeVisible": true, + "accountVisible": true, + "searchVisible": true, + "sidebarVisible": true, + "menuVisible": false, + "headingSize": 22, + "firstRow": { + "x": 281.5, + "y": 374.984375, + "width": 740, + "height": 278.109375, + "top": 374.984375, + "right": 1021.5, + "bottom": 653.09375, + "left": 281.5 + }, + "profile": true + }, + { + "width": 1024, + "scrollWidth": 1009, + "route": "/lab/", + "activeNavCount": 1, + "scopeVisible": true, + "accountVisible": true, + "searchVisible": true, + "sidebarVisible": true, + "menuVisible": false, + "headingSize": 22, + "firstRow": { + "x": 234, + "y": 374.984375, + "width": 751, + "height": 278.109375, + "top": 374.984375, + "right": 985, + "bottom": 653.09375, + "left": 234 + }, + "profile": true + }, + { + "width": 390, + "scrollWidth": 390, + "route": "/lab/", + "activeNavCount": 1, + "scopeVisible": true, + "accountVisible": true, + "searchVisible": true, + "sidebarVisible": false, + "menuVisible": true, + "headingSize": 20, + "firstRow": { + "x": 14, + "y": 384.1875, + "width": 362, + "height": 317.9375, + "top": 384.1875, + "right": 376, + "bottom": 702.125, + "left": 14 + }, + "profile": true + }, + { + "width": 320, + "scrollWidth": 320, + "route": "/lab/", + "activeNavCount": 1, + "scopeVisible": true, + "accountVisible": true, + "searchVisible": true, + "sidebarVisible": false, + "menuVisible": true, + "headingSize": 20, + "firstRow": { + "x": 14, + "y": 438.1875, + "width": 292, + "height": 318.125, + "top": 438.1875, + "right": 306, + "bottom": 756.3125, + "left": 14 + }, + "profile": true + } +] \ No newline at end of file diff --git a/docs/open-lab/review-2026-09-11/tech-tree-dark-desktop.png b/docs/open-lab/review-2026-09-11/tech-tree-dark-desktop.png new file mode 100644 index 00000000..a1cf884c Binary files /dev/null and b/docs/open-lab/review-2026-09-11/tech-tree-dark-desktop.png differ diff --git a/docs/open-lab/review-2026-09-11/tech-tree-light-desktop.png b/docs/open-lab/review-2026-09-11/tech-tree-light-desktop.png new file mode 100644 index 00000000..79f5ed56 Binary files /dev/null and b/docs/open-lab/review-2026-09-11/tech-tree-light-desktop.png differ diff --git a/docs/open-lab/screenshots/apps-desktop.png b/docs/open-lab/screenshots/apps-desktop.png new file mode 100644 index 00000000..c568aa54 Binary files /dev/null and b/docs/open-lab/screenshots/apps-desktop.png differ diff --git a/docs/open-lab/screenshots/arcade-320-focused.png b/docs/open-lab/screenshots/arcade-320-focused.png new file mode 100644 index 00000000..e7728598 Binary files /dev/null and b/docs/open-lab/screenshots/arcade-320-focused.png differ diff --git a/docs/open-lab/screenshots/arcade-desktop.png b/docs/open-lab/screenshots/arcade-desktop.png new file mode 100644 index 00000000..7c806042 Binary files /dev/null and b/docs/open-lab/screenshots/arcade-desktop.png differ diff --git a/docs/open-lab/screenshots/bottlenecks-desktop.png b/docs/open-lab/screenshots/bottlenecks-desktop.png new file mode 100644 index 00000000..cdb6b05c Binary files /dev/null and b/docs/open-lab/screenshots/bottlenecks-desktop.png differ diff --git a/docs/open-lab/screenshots/entrances-desktop.png b/docs/open-lab/screenshots/entrances-desktop.png new file mode 100644 index 00000000..877fa88e Binary files /dev/null and b/docs/open-lab/screenshots/entrances-desktop.png differ diff --git a/docs/open-lab/screenshots/evidence-recovery.png b/docs/open-lab/screenshots/evidence-recovery.png new file mode 100644 index 00000000..053960de Binary files /dev/null and b/docs/open-lab/screenshots/evidence-recovery.png differ diff --git a/docs/open-lab/screenshots/inbox-mobile.png b/docs/open-lab/screenshots/inbox-mobile.png new file mode 100644 index 00000000..9a230e33 Binary files /dev/null and b/docs/open-lab/screenshots/inbox-mobile.png differ diff --git a/docs/open-lab/screenshots/landing-desktop.png b/docs/open-lab/screenshots/landing-desktop.png new file mode 100644 index 00000000..d641b813 Binary files /dev/null and b/docs/open-lab/screenshots/landing-desktop.png differ diff --git a/docs/open-lab/screenshots/landing-mobile.png b/docs/open-lab/screenshots/landing-mobile.png new file mode 100644 index 00000000..be742e01 Binary files /dev/null and b/docs/open-lab/screenshots/landing-mobile.png differ diff --git a/docs/open-lab/screenshots/observatory-desktop.png b/docs/open-lab/screenshots/observatory-desktop.png new file mode 100644 index 00000000..32031a1f Binary files /dev/null and b/docs/open-lab/screenshots/observatory-desktop.png differ diff --git a/docs/open-lab/screenshots/v3/agent-handoff.png b/docs/open-lab/screenshots/v3/agent-handoff.png new file mode 100644 index 00000000..9b2fb7a6 Binary files /dev/null and b/docs/open-lab/screenshots/v3/agent-handoff.png differ diff --git a/docs/open-lab/screenshots/v3/app-drawer-390.png b/docs/open-lab/screenshots/v3/app-drawer-390.png new file mode 100644 index 00000000..0095a8cc Binary files /dev/null and b/docs/open-lab/screenshots/v3/app-drawer-390.png differ diff --git a/docs/open-lab/screenshots/v3/lab-1440.png b/docs/open-lab/screenshots/v3/lab-1440.png new file mode 100644 index 00000000..bae6b0a7 Binary files /dev/null and b/docs/open-lab/screenshots/v3/lab-1440.png differ diff --git a/docs/open-lab/screenshots/v3/lab-390.png b/docs/open-lab/screenshots/v3/lab-390.png new file mode 100644 index 00000000..5afa4640 Binary files /dev/null and b/docs/open-lab/screenshots/v3/lab-390.png differ diff --git a/docs/open-lab/screenshots/v3/lab-apps-1440.png b/docs/open-lab/screenshots/v3/lab-apps-1440.png new file mode 100644 index 00000000..55f41e9b Binary files /dev/null and b/docs/open-lab/screenshots/v3/lab-apps-1440.png differ diff --git a/docs/open-lab/screenshots/v3/lab-bottlenecks-1440.png b/docs/open-lab/screenshots/v3/lab-bottlenecks-1440.png new file mode 100644 index 00000000..d7b3ab1c Binary files /dev/null and b/docs/open-lab/screenshots/v3/lab-bottlenecks-1440.png differ diff --git a/docs/open-lab/screenshots/v3/science-map-dark.png b/docs/open-lab/screenshots/v3/science-map-dark.png new file mode 100644 index 00000000..0a92d945 Binary files /dev/null and b/docs/open-lab/screenshots/v3/science-map-dark.png differ diff --git a/docs/open-lab/screenshots/v3/science-map-root.png b/docs/open-lab/screenshots/v3/science-map-root.png new file mode 100644 index 00000000..83490c5c Binary files /dev/null and b/docs/open-lab/screenshots/v3/science-map-root.png differ diff --git a/docs/open-lab/screenshots/v3/science-topic-detail.png b/docs/open-lab/screenshots/v3/science-topic-detail.png new file mode 100644 index 00000000..72afe1d6 Binary files /dev/null and b/docs/open-lab/screenshots/v3/science-topic-detail.png differ diff --git a/docs/open-lab/social-onboarding.md b/docs/open-lab/social-onboarding.md new file mode 100644 index 00000000..0a203f3b --- /dev/null +++ b/docs/open-lab/social-onboarding.md @@ -0,0 +1,68 @@ +# Open Lab: local onboarding and next actions + +This lane implements local self-prompts and useful profile context, not a social-network notification service. The primary path is bottleneck → responsible collective intervention → outcome evidence. Apps and work packets are supporting starting places. + +## Public component APIs + +```tsx +import { LabActionInbox } from '@/components/lab/social/LabActionInbox' +import { ProfileCompletion } from '@/components/lab/social/ProfileCompletion' +import { InterestOnboarding } from '@/components/lab/social/InterestOnboarding' +import { useLabSocial, LAB_SOCIAL_CHANGED } from '@/components/lab/social/useLabSocial' + +// In LabShell's .lab-header-actions, after identity restoration: + + +// In ProfileWorkbench, using the same actual Open Lab DID: + + +// Standalone starting-choices surface: + +``` + +All three default `ownerId` to `guest`. Do not render a guest editor while an authenticated identity is still restoring; wait for `isLoading` to finish. Components remount their inner state on owner changes, and never transfer guest drafts. + +`LabActionInbox` also accepts `onResumeDraft?: (slot: string) => void`. If supplied, a draft action calls it with the exact canonical draft slot, for example `contribution:`. Parent can set RecordEditor `draftId` to that slot and derive the record kind from `slot.split(':')[0]`. Without this callback the real link opens `/lab/profile/`, where the existing bench lists the saved draft. It does not pretend to deep-open a draft without an integration handler. + +The actual `/lab/onboarding/` route contains both the editable onboarding flow and a profile-completion form (`#profile-completion`). It supplies `LabAuthProvider` because the base tree has no Open Lab layout. The existing auth provider reuses its browser runtime if also present in the integrated layout. Parent may remove this route-local provider after verifying the layout supplies it. It does not use legacy CMS identity. Only the provider performs its existing read-only readiness/session initialization; the social components themselves do not fetch. + +## One profile draft, not two + +`src/lib/lab-social.ts` imports `draftKey`, `loadDraft`, and `saveDraft` from the existing canonical draft foundation. It reads and patches: + +- `draftKey('profile', ownerId)` → existing `{version: 1, data, savedAt}` envelope. +- `draftKey('social', ownerId)` → ONLY mode, onboarding-skip choice, skipped optional links, read action IDs, and dismissed action IDs. No profile copy or public record lives here. + +The current UI RecordEditor persists `EntryValues` with comma-separated `interests` strings. This adapter accepts either strings or arrays on read and writes the compatible comma-separated string. It merges against the latest stored profile, preserving unrelated fields and drafts. Onboarding preserves custom existing interests. Bounds failures do not silently truncate interests. Public payload conversion remains the bench's responsibility. + +`useLabSocial(ownerId)` returns `profile`, `meta`, `drafts`, `ready`, `error`, `readError`, `refresh()`, `saveProfile(patch)`, and `saveMeta(patch)`. Save results are `{ok, error?}`. Saves are explicit, local-only and read back before success. Corrupt envelopes are not overwritten. Errors are visible; there is no destructive recovery button. + +Same-tab updates made here emit `window.dispatchEvent(new Event(LAB_SOCIAL_CHANGED))`. Parent should emit this after successful canonical saves in RecordEditor and refresh its displayed bench draft list on this event. Components also refresh on native storage events and window focus; the inbox refreshes when opened. No polling. A simultaneously open external editor still needs this event to update immediately. + +## LinkedIn schema integration + +Only the profile lexicon gains optional `linkedinUrl` (URI string, maxLength 2048). Required fields, record key and all existing properties are unchanged. `LabDataMap.profile` adds the optional property. Runtime business validation requires a public HTTPS `linkedin.com/in/...` or `www.linkedin.com/in/...` URL, without a query or fragment; existing URL safety rules also apply. Other record kinds still reject this field. + +Parent/bridge owns `lab-entry`, `lab-types`, `ProfileWorkbench`, and `RecordEditor`; this lane does not edit them. To publish/display the new optional link through the bench, add `linkedinUrl` to the profile entry defaults/field descriptor, profile payload optional-URL mapping, profile type, initial public-profile-to-entry mapping, and displayed link list. Preserve all existing fields when doing so. Until that bridge lands the new URL is functional locally and validates in the protocol layer, but the older bench editor does not necessarily include it in its public payload. These links are self-supplied, never verified credentials or synced connections. + +Do not automatically seed a new local profile from a guest or public record. If adding an explicit public-profile-to-local-draft edit action, copy the complete profile only after that user action so omitted fields are not lost. + +## Behavior and acceptance + +- Completion uses the actual profile validator for field validity. Denominator: three useful core fields plus optional nonempty links that were not skipped. Omitted/skipped links confer no bonus and carry no penalty. This is not an expertise score, prestige ranking, or access gate. +- Optional links offer a skip path. Skipping clears only that local draft URL and stores the local preference, never modifying any public record. Invalid URLs may stay as unfinished local drafts but earn no completion credit. +- Recommendations use an explainable fixed ordering, known field IDs only, and contribution mode. They link to `/lab/bottlenecks/?field=`, `/lab/bottlenecks/?case=reproducibility`, `/lab/apps/`, and `/lab/collaborate/`. They claim neither AI matching nor active community participation. +- Inbox prompts derive from canonical profile/interests and actual saved note/app/contribution/participation drafts. A saved contribution is labeled a local evidence proposal, not an accepted or submitted intervention. Any future bottleneck-specific proposal store needs a separate parent adapter; this lane does not guess that store's schema. +- Read and dismiss are different explicit actions. Opening the inbox does not mark anything read. Completion removes the corresponding prompt. Dismissal survives mounts; restoring dismissed prompts is explicit. Draft IDs are stable by slot, so editing a dismissed draft does not nag again automatically. +- No peers, likes, invitations, online counts, browser Notification permission requests, external messages, automatic public posting, or remote execution. +- CSS is confined to `.open-lab` plus new `lab-social-*` names in the owned stylesheet. It uses existing paper/ink/blue tokens with fallbacks, Aileron body, serif `var(--font-serif)`, 44px controls, and narrow-screen rules. No shared style or existing component changes. + +## Verification and integration limits + +Tests: `node --test scripts/lab-social*.test.mjs scripts/lab-protocol-validation.test.mjs` + +Types: `./node_modules/.bin/tsc --noEmit --incremental false` + +The tests execute real React components in JSDOM, the actual route, the canonical draft helpers, and actual schema validation. They cover save/reload/recommendations, invalid and skipped links, owner changes, blocked dismiss, corruption/no overwrite, persistent read/dismiss, Escape/focus return, and no public writes. Synthetic test data is test-only, not seeded into product UI. + +No production build, browser OAuth/account smoke, PDS write, browser mobile screenshot validation, or deployment was performed. Parent owns integrated 320px/390px/header/dark-mode visual QA, shared editor event wiring, the LinkedIn bench bridge, destination route integration, and final feature PR/release gating. Local storage is not multi-tab transactional storage: simultaneous edits to the same field still require a future conflict UX. diff --git a/docs/open-lab/strategy.md b/docs/open-lab/strategy.md new file mode 100644 index 00000000..c95dee28 --- /dev/null +++ b/docs/open-lab/strategy.md @@ -0,0 +1,117 @@ +# Open Lab: co-create what helps science move + +**Product proposal · September 2026 · not a launch announcement** + +## The bet + +The platform’s center is **collective, responsible invention and field acceleration**: make a bottleneck legible, refine the diagnosis together, co-design an intervention, rally useful contributions, and test whether it helped. The social network exists to make that work easier—not to maximize time spent in a science-themed feed. + +The central object is a **bottleneck with an opening for action**, linked to a proposed intervention and evidence. Keep the Interventions Console’s useful invariant: no intervention without a bottleneck. A proposal names its causal hypothesis, affected people, smallest useful test, success signal, risks, and stopping/review conditions. Apps, Atlas evidence, agents, effort backing and profiles help people contribute to that same chain. PL focus areas provide a starting community, not a boundary excluding other scientific fields. + +The invitation is **“What’s holding progress back—and what could we build together to change it?”** A playful entrance can still welcome someone who is not ready to design an intervention. Rough tools, negative results, replication attempts, and precise questions belong here. Thoughtfulness means consulting affected people and testing a causal claim, not decorating a popular idea with an impact score. + +## Why somebody joins + +A builder gets a legible home for a useful science app and a way to ask for a specific next contribution. A researcher gets collaborators around the problem they are working on, not an institution-shaped directory. A curious person can improve a source, test a tool, or reproduce a small result without claiming to be a principal investigator. Someone with an agent can contribute a bounded piece of work without giving a platform their account keys. + +Browsing should be useful before signing in. One low-friction entrance is a small synthetic signal experiment: change the sampling rate, discover an alias, and keep the exact configuration and result. It is an educational instrument, not a scientific measurement. Real source-linked apps provide the broader entrance; the Atlas source-audit pilot separately tests collaboration. Sign-in comes when a person wants a durable public identity and contribution record. Existing Bluesky identity is a reduction in friction, not the value proposition itself. + +### A first-session loop + +1. Enter through a tool or an open question, not a blank feed. +2. See what it makes possible, its source, what is unverified, and what help would matter. +3. Choose a small action: try it, add evidence, offer help, or take a work packet. +4. Use an existing AT Protocol account when ready to publish. Preview the exact public content first. +5. Return because another person used or responded to the contribution—not because an engagement counter went up. + +The organizing loop is **bottleneck → collective refinement → intervention proposal → responsible test → evidence → revise, continue or retire**. The application should make each relationship, contribution and unresolved disagreement inspectable. + +## How the destination fits together + +**The landing page** is an invitation with a window into the work. Its editorial typography and PL blue remain related to the public site, while the wider canvas and inspectable map make the transition into a working space apparent. The brochure remains the home for institutional context; Open Lab is the home for participation. + +**The bottleneck workbench** is the main action surface: what is blocked, whom it affects, what evidence supports the diagnosis, and what removal would look like. Refinements and intervention designs are proposals alongside the baseline, never silent overwrites or assignments. The science feed supplies context and entry points; it is not the organizing system. + +**Apps** provide the most immediate reason to visit. Open the thing, understand what it enables, inspect its code when available, and see what its maker needs. The desired energy is “I built a weirdly useful thing over the weekend,” not procurement. But a public URL does not imply open-source licensing, safety, reproducibility, or endorsement. External apps remain external unless deliberately reviewed for integration. + +**Atlas contributions** turn passive maps into answerable questions. “Add a source for this observation” is more actionable than “Contribute to the ecosystem.” A submission is proposed evidence, not an accepted change to the Atlas. Its source, target, author, and review status must remain distinct. + +**Collaborate** starts with bounded work, not a theatrical “launch 10,000 agents” button. A person can inspect a task, select a role, set a budget in their own environment, and export a work packet with clear outputs and stopping conditions. A future coordinator can assign and reconcile those packets once there is a working verification method. + +**My bench** is a research identity: what I am working on, what I can contribute, what I need, and optional links to LinkedIn, GitHub or Google Scholar. Links should be labeled links, not implied account integrations or proof of credentials. Institutional prestige should not determine who can contribute. + +**The map** is an alternate way to discover work, not decorative proof that a community exists. A point must correspond to an inspectable artifact. A line must have an explainable meaning, such as a shared topic or an explicit contribution. Topic similarity is not a collaboration relationship. A curated starter map and a live community map must not be presented as the same thing. + +## Make the community legible before it exists + +A deliberately populated **demo community** is part of this proposal. Fictional people, substantive discussion, competing intervention designs, evidence, and notifications should tell a few coherent stories across the app. Show a disagreement that improves a diagnosis, a narrower responsible test, and a useful negative result—not just celebratory comments. The overall demo label and smaller module chips distinguish invented activity from actual public records without burying the experience in repetitive disclaimers. + +Let someone open a fictional profile, reply locally to a thread, and follow a demo notification to its real on-page target. None of that creates real members or posts publicly. A visible demo/real-empty switch preserves the actual empty-start path and real local drafts. Use synthetic scenarios rather than private workspace notes; selective console publication can come later. + +For the genuine visitor, interest and contribution-style onboarding should suggest a particular bottleneck, tool or source-checking task and explain the fit. A small, optional completion checklist makes research interests, useful context and LinkedIn/Scholar links worth supplying; the links stay self-described, not verified credentials. A top-right inbox distinguishes actual local next actions from illustrative demo replies. No expertise score, admission gate, or profile-completion nag should get between someone and a useful contribution. + +## Back an effort without confusing conviction with evidence + +A finite points budget is worth testing: let people spend a finite attention budget on work they want done, rather than adding unlimited likes. The experimental choice is **100 local points**, a design parameter rather than an approved community policy. Allocating more to one effort leaves less for others; reallocation is explicit. It elicits priorities, but free points are not cash at risk and must never be presented as a real funding commitment. + +Keep three independent things visible: **support** (what people want pursued), **evidence** (what happened, with source/method), and **evaluation** (someone’s accountable assessment of that evidence). Neither a large allocation nor a Hypercert proves scientific usefulness. Preserve negative results and disagreement rather than optimizing for an always-upward impact score. The proposed first test is whether allocating points and then reading new evidence changes someone’s allocation and leads to one bounded contribution. + +Hypercerts are especially relevant because their current core data model already uses AT Protocol: an activity claim anchors work scope, contributors and dates; attachments, measurements and evaluations are separate records with strong references (URI plus CID). An evaluator’s record can remain on their own PDS.[2] Reuse those interoperable schemas for an eventual effort/evidence layer rather than inventing a competing impact-certificate format. The quickstart documents that path.[3] The core-model docs distinguish it from on-chain tokenization, which they say is not yet implemented.[2] No token, ownership right, investment return or grant award is implied here. + +The `/lab/efforts/` experiment is intentionally local: no enforced one-person budget, crowd totals, payments, certificate issuance, or publicly posted allocations. A real pilot needs an admitted-member roster, a defined budget period and aggregation rule, tamper-resistant accounting, Sybil/coordination safeguards, conflict disclosures, moderation, and a clear statement of whether any actual grantmaker will use the signal. Identity alone does not provide those guarantees. Start with a small named cohort and observable research contributions, not a speculative token economy. + +## Science-shaped, without counterfeit science + +A light touch of humor helps: “Promising,” “Needs a second pair of eyes,” or “Worth an experiment.” Renaming a heart “Peer reviewed” would be a mistake. It turns a social gesture into a scientific assertion, even if intended as a joke. + +Use separate meanings: + +- **Interest:** I want to follow this, try it, or save it. Not scientific validation. +- **Evidence:** here is an observation, source, run, or counterexample. +- **Review:** a named person checked a stated part of the work, with a linked record of what they checked. +- **Replication:** a specified procedure was rerun, with its result and limitations. + +A result should not graduate through those stages by accumulating votes. Negative findings and useful corrections should be first-class contributions. No reputation score is needed at the start. + +## Pool work, not credentials + +OpenAI’s September 8 announcement reports a proposed Navier–Stokes solution from a group on the order of 10,000 concurrent agents, about 88 hours, and a further Lean formalization/verification phase. Its Navier–Stokes claim uses a smooth external force; do not confuse that with its separate unforced Euler result. It also says the research used an internal model significantly more capable than GPT‑6 Astra and that it does not intend to claim the Millennium Prize.[1] These are OpenAI’s reported results, not independently audited resource figures or proof of completed mathematical acceptance. They are not evidence that a pile of interchangeable consumer tokens produces the same result. + +Nothing fundamental prevents people from contributing separately funded agent work to a common project. The difficult parts are making the work decomposable, preventing duplicated effort, checking outputs cheaply, and combining useful intermediate results. Provider tokens are not a common currency: models, context limits, tools, prices, licenses, and account terms differ. + +The first experiment is narrow: trace the size claim in the NIH’s account of the 2024 FlyWire connectome to the original study, and identify what a structural map does not establish about brain function.[4] Paired research/review packets pin that same claim and source. Each return includes an exact quote/location, a judgment of support, and limitations. The local workbench imports both returns, preserves disagreement, and exports an attributed human-resolution bundle. Names and independence remain self-reported; an export is neither an accepted Atlas edit nor a publication. This is Open Lab’s proposed exercise, not a recruited FlyWire campaign. + +A later execution service needs task leases, idempotency, checkpointed artifacts, provenance, a review queue, contributor-controlled spending limits, and defenses against malicious instructions in source material. Downloading a work packet is not execution. Publishing an intent is not a reserved task. Uploading a result is not verification. Keep these boundaries visible. + +**Bring your agent. Keep your keys.** Contributors run tools in their own controlled environment. Open Lab does not collect model-provider credentials, pool subscriptions, debit a card, or execute arbitrary submitted code. Human acceptance and automated checks are separate records. + +## A launch that can learn something + +Do not open with an empty public network and hope the graph fills in. Start with a deliberately small, consented founding cohort: builders of useful science tools, researchers with answerable asks, and people willing to reproduce or review. These are proposed roles, not recruited participants. + +Before inviting that cohort: + +- Seed a small source-checked editorial collection, visibly separate from members’ contributions. +- Obtain permission from featured makers before describing them as participants or using their names as social proof. +- Have an accountable curator and an explicit weekly time allocation. A feed is not a substitute for facilitation. +- Put a small next action on every founding project. Avoid giant unsplittable challenges. +- Run one complete contribution cycle through independent acceptance and correction. +- Establish public-content, moderation, reporting, deletion, and appeals expectations. Public AT Protocol records can be copied by others; deleting a local view cannot promise universal deletion. +- Instrument completed useful contributions, not just sign-ins. Do not infer membership from an imported Bluesky post. + +**Primary proposed metric:** weekly artifacts improved by a contribution from someone other than the original author. Keep the denominator and acceptance criterion visible. Supporting measures are visitor-to-first-useful-action, time to first qualified response, and contributors who return to complete another piece of work. + +**A proposed six-week decision:** continue only if people independently bring new work and other people improve it without the curator manufacturing every interaction. If people enjoy browsing apps but do not collaborate, narrow into a genuinely good science-tool directory. If agent runs generate volumes of unverifiable text, stop expanding compute and repair the task/evaluation design. If contributors use Bluesky for the whole loop and Open Lab adds no artifact-level value, keep the useful views and drop the new destination ambition. + +## What this PR should prove—and what it cannot + +The first PR should let someone experience the invitation, explore real source-labeled work, try a small actual app, draft contributions, prepare a research profile, and export a concrete agent work packet. It should contain a genuine AT Protocol implementation rather than an email-signup façade, with readiness and unverified deployment steps stated precisely in its technical notes. + +It cannot manufacture a community, confer peer review, promise scientific breakthroughs, or make new record types globally discoverable merely by writing them to a personal data server. Global discovery, moderation operations, and evidence acceptance are explicit launch work. The interactive PR is a product bet to evaluate—not an announcement that those systems already exist. + +## Sources + +[1] https://openai.com/index/navier-stokes-solution/ +[2] https://docs.hypercerts.org/core-concepts/hypercerts-core-data-model +[3] https://docs.hypercerts.org/getting-started/quickstart +[4] https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain diff --git a/docs/open-lab/v2-auth.md b/docs/open-lab/v2-auth.md new file mode 100644 index 00000000..73e514ff --- /dev/null +++ b/docs/open-lab/v2-auth.md @@ -0,0 +1,102 @@ +# Open Lab v2: authentication and native connections + +## What is implemented — and what is not verified + +The Lab uses the official `@atproto/oauth-client-browser` public client, independent of CMS auth. Identity-only login is real SDK authorization, not a demo account toggle. After SDK init, public Bluesky profile data is read by the exact authenticated DID and its handle is forward-resolved to that DID. A profile read failure retains the authenticated DID fallback. Display name and avatar are self-reported presentation, not proof of a human's name, affiliation, or Open Lab membership. + +The new default-export `BlueskyConnections` component supports `personDid?: string`; without it, it presents a public handle lookup. Parent/feed lane mounts it in PeopleWorkbench. It is not a fixture-person connector. Native follow/unfollow uses the official SDK session and the standard `app.bsky.graph.follow` collection, not a custom Lab graph or a CMS proxy. + +All authorization/mutation tests in this lane are LOCAL tests with injected synthetic transport. No real account authorization, follow, unfollow, post, PDS mutation, deployment, external configuration change, or DNS change was performed. `oauthVerified:false` and `schemaPublished:false` remain literal truth markers. A successful local test is not an owner-authorized account smoke. + +## Preview origin fix + +At investigation time the existing deployed alias returned `canSignIn:false`, `mode:unconfigured`, and “Sign-in is configured for a different origin”; its exact metadata URL returned HTTP 404. The root cause is that the prior helper selected the unique `VERCEL_URL` even while the browser was on the stable branch alias. + +Configuration precedence: + +1. An explicitly supplied `LAB_PUBLIC_URL` is authoritative. An empty value disables login; an invalid origin fails closed. It is NOT replaced by an automatic fallback. +2. Only when `VERCEL=1` and `VERCEL_ENV=preview`, the deployment-provided `VERCEL_BRANCH_URL`, if present, is preferred. It must be a strict single-label `*.vercel.app` generated host: no scheme, path, port, credentials, query, fragment, whitespace, leading/trailing hyphens, or overlong label. A present but invalid/empty value fails closed rather than falling through. +3. Otherwise use strict deployment-provided `VERCEL_URL`. A missing branch variable can therefore use the unique deployment URL, but that does not authorize a different alias. +4. Outside trusted Vercel context, require explicit `LAB_PUBLIC_URL` (or the existing explicit loopback development setup). + +Non-production Vercel explicitly configured origins must equal the validated deployment URL or validated preview branch URL. Production origins cannot be inherited into preview. `Host`, `Forwarded`, `X-Forwarded-Host`, legacy `PUBLIC_URL`, and `VERCEL_PROJECT_PRODUCTION_URL` never define the OAuth identity. Request/browser origins are comparison inputs only; matching a `.vercel.app` suffix does not confer trust. An arbitrary alias remains unconfigured. The generic helper contains no repository/team/branch name. + +The exact branch fixture exercised locally is: + +- origin: `https://plrdorg-git-feat-open-lab-protocol.vercel.app` +- client ID/metadata: `https://plrdorg-git-feat-open-lab-protocol.vercel.app/api/lab/oauth/client-metadata.json` +- callback: `https://plrdorg-git-feat-open-lab-protocol.vercel.app/lab/oauth/return/` + +Parent must integrate and deploy before this can fix the live alias. Verify `VERCEL_BRANCH_URL` actually equals the desired alias in that deployment's exposed system variables. If it is missing, a branch-scoped `LAB_PUBLIC_URL` only works if it equals one of the trusted deployment-provided origins; do not add arbitrary alias trust or change legacy CMS `PUBLIC_URL` as a workaround. The system variable must be exposed/correct, or use the exact trusted unique URL. An access-protected deployment cannot serve public OAuth metadata: parent must choose an approved public preview; do not transmit bypass secrets in client IDs. + +[Vercel documents the generated deployment and branch URL system variables](https://vercel.com/docs/environment-variables/system-environment-variables). Readiness at one URL never validates another URL. + +## Permissions and public action boundary + +[AT Protocol permissions](https://atproto.com/specs/permission) define `repo` write permissions with `create`, `update`, and `delete`; repository reads are public. [Identity-only OAuth](https://atproto.com/guides/permission-sets) needs only `atproto`. + +- Login: `atproto` only. No user content writes, auto-follow, auto-post, or profile writes. +- Follow permission: `atproto repo:app.bsky.graph.follow?action=create`. +- Unfollow permission: `atproto repo:app.bsky.graph.follow?action=delete`. +- Maximum metadata adds `repo:app.bsky.graph.follow?action=create&action=delete` alongside the existing five exact custom collection maxima. Metadata is not a grant. No native update, feed post, blob, generic transition, wildcard, account-management, or authenticated AppView RPC permission is requested. +- `canConnect` is independent of `canPublish`. Valid origin configuration enables connection controls by default. `LAB_ENABLE_CONNECT=true` explicitly enables them; any explicitly present other value, including empty/false/typos, disables them. `LAB_ENABLE_PUBLISH` still requires exactly `true`; native follows do not turn custom publication on. +- Capability flags are cooperative app gates, not revocation of an existing token. Revoke account grants when access must actually end. + +Public profile lookup uses the fixed `https://public.api.bsky.app`, with unauthenticated official Agent calls to `resolveHandle` and `getProfile`. The public AppView observes the lookup/IP. Profile-by-DID must return that exact DID, and the current handle must resolve back to it. Avatars are limited to canonical, query/fragment/credential-free HTTPS `cdn.bsky.app/img/avatar/` URLs; unsafe URLs are omitted. Images use no-referrer. These are HTTPS AppView checks, not cryptographic repository proofs. The lane also exercised the actual reader against the public `bsky.app` profile without authenticating. + +## Connection execution and recovery + +`createLabConnectionClient(oauthSession, {isCurrent, signal?, ...optionalTestDependencies})` exports `inspect(subjectDid)`, `follow(subjectDid, consent)`, `unfollow(subjectDid, consent)`, `pending(subjectDid)`, and `recover(subjectDid)`. + +The caller must supply a synchronous live-session guard; the component uses `useLabAuth().isSessionCurrent(session)`, which reads runtime state, not a possibly stale React render. That pre-SDK check alone is NOT a transmission guarantee: the installed SDK subsequently awaits token, hash/key/proof and nonce work and can retry an OAuth challenge. + +### F1: lifecycle cancellation at the actual SDK HTTP boundary + +The runtime now aborts a captured account `AbortSignal` synchronously on logout, matching SDK invalidation, or a new authorization, before notifying React or awaiting SDK sign-out. Connection clients capture that account lifetime plus the optional caller `signal`; `boundedLabFetch` combines them with its request deadline and passes the signal to the official session. Public profile lookup/enrichment also captures the applicable account/panel lifetime. + +`BrowserOAuthClient.load` receives a supported `fetch` option (`createLabOAuthFetch`). This thin transport checks the effective Request signal immediately before calling native fetch. The installed SDK propagates it through DPoP construction and its nonce/token retry paths. A canceled Request therefore cannot start a later resource transmission, even if non-abortable proof/token work finishes later. The app does not replace PKCE, DPoP, key storage, token refresh, or the official session. No permissions, metadata, credentials, or publication flags were widened. + +The component aborts its panel lifetime during layout cleanup (unmount/account or person-card replacement), immediately on lookup-target edits/replacement, and through `Cancel pending work` (connection checks/actions only, not a promise to cancel the provider authorization flow). Previously captured clients remain canceled; later explicit lookup/recovery creates a fresh client. Generation guards still suppress stale UI completion. The compact `Account details` disclosure and unchecked exact public confirmation are unchanged. + +**Cancellation is not rollback.** Before transmission, an aborted signal prevents the resource request (including a queued native retry) from starting. After transmission, the server may already have committed; closing the panel, aborting fetch, or signing out cannot recall that write, copies, or notifications. Once a mutation attempt has a recovery journal, cancellation remains conservatively `LabConnectionUnknownError` and retains its exact URI/body/CID and target draft—even if a local test proves its synthetic transport was never called. No automatic retry, new record key, journal deletion, or success claim follows cancellation. A later explicit recovery only reads the exact record; unresolved absence still does not release a create hold. Work canceled before the mutation/journal needs a fresh review and consent. + +Local regression: `scripts/lab-connection-cancellation.test.mjs` uses the **installed official `OAuthSession` constructor**, its real DPoP/retry implementation, and the runtime-provided final fetch. Only token/key/nonce storage, signing output, revocation and final HTTP responses are synthetic; live fetch is prohibited. It pauses token, hash, proof, nonce lookup/store and native nonce/token retry boundaries; tests synchronous logout/SDK invalidation, mounted unmount/target change/cancel, public lookup cancellation, create/delete ambiguity and read-only recovery. It drains delayed SDK continuations before asserting zero new transmissions. Healthy controls still complete through the official SDK, including challenge retries. + +Probe integration caveat: the original external critic fixture manually assigns `session.dpopFetch` with a signal-blind fake fetch and ignores the runtime's supported final-fetch option. Rebinding only its paths still counts one fake invocation with `abortedAtSend:true`; that bypassed transport is not the configured production boundary. Preserve its zero-transmission assertion, but instrument the HTTP stub **behind the same runtime-supplied fetch option**, as the portable regression does. The F1 lane does not claim the unmodified external fixture passed; parent owns independent acceptance of this boundary integration, the final full suite and browser/account smoke. + +Consent is `{public:true,did,subject,action:'create'|'delete'}`. Unfollow additionally requires the reviewed exact own native follow `uri` and `expectedCid`. Both DIDs are strictly validated; self-follow, account mismatch, wrong subject, wrong collection, missing permission, and stale origin are refused. The user sees both author and target handle/DID, the public/replicated/notification consequences, and an unchecked explicit confirmation. No matching display name is used as account proof. + +The native follow schema declares `key:'tid'`. Rather than inventing a nonstandard hash key, the client scans existing own-PDS follow records before allocating a standard SDK TID. An existing match is fetched by exact URI and CID before it is accepted; no duplicate record is created. Scans are bounded to ten pages of 100, reject repeated/malformed cursors, and check a 20-second budget between pages; each call still has its own fixed deadline. Incomplete scans fail closed instead of treating a partial result as absence. Accounts beyond this limit can manage connections in Bluesky. No full graph is fetched on login. + +Mutations hold an exclusive, nonqueued Web Lock for the author/subject, plus an in-tab guard. Browsers without Web Locks cannot mutate via this UI; do not silently degrade duplicate protection. This coordinates this origin's tabs, not another client/device. A competing outside client can still create duplicates; deletion rechecks for remaining follows and never calls that state “not following.” There is no cross-device atomic uniqueness guarantee for the subject in the AT Protocol repository API. + +Before sending a mutation, a small author/subject-scoped recovery journal is persisted and read back from localStorage. It contains only the public action, intended exact record/URI, and known CID, never OAuth secrets. Storage failure blocks the mutation. Native create uses `validate:true`; delete uses `swapRecord` with the reviewed CID. PDS responses go through the existing `boundedLabFetch`: official session credential/DPoP/refresh handling, no application retry, 12-second acquisition/body deadline and 1 MiB streamed size limit. No legacy protocol writer was changed. + +Create success requires the latest exact URI/CID/body readback, without a historical CID query. Delete success requires exact PDS `400 RecordNotFound`, followed by checking for another follow to the subject. Network errors, 5xx, timeouts, oversized/malformed responses, or mismatch after a mutation attempt yield `LabConnectionUnknownError(uri)` and retain the journal. They do not yield a false success or create a new record key on retry. + +Recovery is explicit and READ-ONLY. It loads the persisted exact target, validates own DID/subject/action, and checks latest state. A recovered create must match the intended entire body and known CID when available. A recovered delete must be absent and checks for remaining duplicates. Merely observing absence after an uncertain create does NOT prove a delayed request cannot still commit, so the pending journal remains and retry stays blocked. An existing record after uncertain deletion also remains unresolved. Inspect/manage the account on Bluesky or seek owner-assisted recovery; do not clear storage to bypass the hold. Repeated automatic writes are never the recovery strategy. + +Follow is one-way. No reverse relationship is fetched; the UI makes no mutual-connection claim. PDS readback is not AppView indexing confirmation or cryptographic repo-signature proof. Public copies/notifications cannot be recalled by unfollowing. + +## Permission-return UX + +`authorizeConnection(action, returnTo)` uses the official OAuth client with `prompt:'consent'`, `atproto`, and only the requested exact action scope. It is separate from `authorizeWrite` and `canPublish`. + +Before navigating for authorization AND before a confirmed mutation with an already-authorized token, the UI saves a per-author draft containing the target DID, action, and safe return destination. Thus an ambiguous action restores its target after reload even when no scope-escalation round trip was needed. It stores no checked confirmation and no automatic-write continuation. The allowed `/lab/people/` route is additive; existing safe Lab destinations and validated queries remain allowed. On return, the target is looked up again and its current follow is freshly inspected. Confirmation starts unchecked and must be performed again. Failed/canceled authorization leaves the draft and shows an honest error. The component also provides a handle-only login form for signed-out users; never a password or app-key field. + +## Parent-owned exact-origin smoke + +1. Integrate auth before feed; mount `BlueskyConnections` in PeopleWorkbench. Update the old out-of-lane `scripts/lab-protocol-config.test.mjs` exact metadata expectation to add the native follow maximum (the old test intentionally knows only the five custom collections). Run all Lab tests, typecheck, production webpack build, and independent integrated product/security/browser QA. +2. After an approved deployment, fetch the exact alias capabilities and metadata without credentials or redirects. Require `canSignIn:true`, `canConnect:true`, `canPublish:false` unless explicitly approved, `oauthVerified:false`, exact client ID/redirect, `token_endpoint_auth_method:none`, and DPoP binding. Wrong unique URL/arbitrary alias must fail if branch URL is the chosen canonical identity. No authentication or deployment-protection HTML in metadata. +3. Only with owner-approved account consent: open the exact canonical alias, sign in using a handle, inspect the real provider consent showing identity only, and verify actual DID/handle enrichment. Check cancel/back/replay, reload/refresh, logout, storage restrictions, and that login performs NO create/put/delete PDS calls or CMS-account activity. Do not collect or log access tokens. +4. Only after separate exact target + author consent: search the target's public handle, verify its DID/profile, inspect current follow, confirm the public notification/replication notice, and request the separate follow scope. On callback verify the same draft and unchecked confirmation; it must not have followed yet. Confirm again, then inspect exact native record URI/CID/body in the PDS. AppView/UI indexing may lag. +5. Only after separate exact own-record unfollow consent: recheck current follow URI/CID, request delete-only permission if absent, return and reconfirm, execute CAS delete, and verify exact RecordNotFound plus any remaining follows. Do not claim copy retraction, mutuality, or Open Lab membership. +6. Run synthetic fault injection for unknown outcomes rather than deliberately creating ambiguous real public writes. Verify reload preserves recovery, retry stays blocked, and recovery only reads. + +## Dedicated domain: approval path, not a performed migration + +`openlab.plrd.org` is the user's desired eventual domain. This lane has made no DNS/domain/deployment changes. Parent should first obtain approval for domain ownership, hosting, routing, public access, and the security/isolation model. A new origin is a new OAuth client and new browser-storage space: it does not inherit preview grants or drafts. Offer explicit local draft export/import; never copy SDK IndexedDB credentials. + +For an approved dedicated deployment in a production context, explicitly configure `LAB_PUBLIC_URL=https://openlab.plrd.org` (not the CMS `PUBLIC_URL`), keep custom publication off unless separately approved, and expose public metadata at the same `/api/lab/oauth/client-metadata.json` path with callback `/lab/oauth/return/`. Verify TLS, public metadata and the full owner-gated smoke before linking users there. A custom-domain preview is deliberately not admitted by the current strict generated-Vercel-host rule; supporting that would require a separately reviewed explicit trust configuration, not a suffix wildcard or request-derived origin. + +DNS alone does not isolate browser OAuth secrets: all same-origin JavaScript can access the SDK's IndexedDB. A dedicated hardened Lab deployment should exclude CMS/admin routes and unnecessary third-party scripts; deploying this whole existing site under another hostname does not accomplish that isolation. Review CSP, XSS surfaces, account revocation and moderation/abuse handling before calling this a released social service. diff --git a/docs/open-lab/v2-design.md b/docs/open-lab/v2-design.md new file mode 100644 index 00000000..b72b5690 --- /dev/null +++ b/docs/open-lab/v2-design.md @@ -0,0 +1,47 @@ +# Open Lab v2 — shell design + +## Product surface + +Open Lab is an operating surface: persistent actions on the left, real work in the center, source/detail inspection on the right. The home route renders the actual `FeedWorkbench` through `Landing`; its only introductory content is the slim “Made something that makes science easier?” invitation with two useful links. It does not repeat the former marketing sections. + +The shell owns no feed, profile, social, authentication, or demo state implementation. Peer workbenches and their source/provenance copy remain intact. `lab-app-shell.css` is an explicit scoped composition layer, not a new global site theme. + +## Layout and visual rules + +- Desktop navigation: 210px, persistent, labeled actions. `/lab/` and `/lab/feed/` both select only “Follow science.” The other actions are Work on ideas, Try tools, Improve the Atlas, Pool your efforts, Find people, and My bench. +- Content: up to 740px feed with a 280px context rail and 24px gap. Existing feed filters move to the right; below 1200px they become an inline reachable filter group rather than disappearing. Below 800px the left navigation becomes an explicit labeled menu, not an unlabeled horizontal icon strip. +- Toolbar: real GET search to `/lab/feed/?q=…`, notification affordance, and an accessible account button in guest, restoring, and authenticated states. Notifications keep their place while identity restores. +- One global Demo control: pinned bottom-left on desktop, in the toolbar on mobile. Its on/off label is visible without opening anything. The disclosure explains example people/activity, local demo actions, no messages sent, no scientific-evidence claim, and the separation from real identity/drafts. Switching off does not claim to connect a backend. Per-item provenance is retained; only the redundant chip inside the notification button is visually removed, with its demo accessible name and disclosure retained. +- Light-first neutral surfaces: paper `#f7f8f7`, white work surfaces, ink `#202729`, muted `#596469`, border `#dde2e3`, restrained blue `#1267bd`. Saved dark appearance remains supported. Aileron is the deliberate work UI face; Newsreader is reserved for brief display headings. +- Type: 32px maximum page headings, 20px section headings, 17–18px entry headings, 14–15px body, 12–13px metadata. Narrower spacing, dividers, and flatter work areas replace page-sized panels. Existing synthetic signal instrument keeps its own dark experiment surface; both ResearchMap and Observatory genuinely switch all instrument surfaces with the theme. + +## Drawer and identity contracts + +`LabDialog` preserves `title`, `children`, `onClose`, and `wide`. New `variant?: "drawer" | "centered"` defaults to `drawer`; login explicitly requests `centered`. It remains a native modal dialog with named heading, `aria-modal`, Escape, backdrop dismissal, focus containment/return, and nested-dialog-safe body scroll locking. Default detail width is 560px; wide is 760px; narrow viewports use the available width. + +The initial login invitation waits for identity restoration and capability discovery. A sessionStorage marker prevents repetition; OAuth/return routes and callback fragments are excluded. Continue browsing is an explicit choice. The form calls the existing `useLabIdentity().login` with the current return URL. Unconfigured sign-in is disabled and honestly explained; provider errors remain visible. No auth configuration or permission scope changed. + +`LabOnboardingGate` is imported from `@/components/lab/social/LabOnboardingGate` and mounted once inside Lab context after authenticated identity restoration. Its DID-specific skip/completion logic belongs to the feed worker. This module is absent from the shell lane's base; production contains no stub. A narrowly scoped test-only interface fixture is used only when that worker file has not arrived. + +## Verification and review + +Vertical RED→GREEN receipts cover home composition, action navigation/search/account, global demo disclosure, once-per-session login, unavailable sign-in, drawer focus/scroll behavior, theme-driven map paint, Observatory surfaces, responsive layout, and the onboarding interface. + +Canonical browser-harness QA ran on local webpack dev port 3381, in a dedicated `BU_NAME=openlab-v2-shell` window. Real probes verified: + +- No horizontal overflow at 1440, 1024, 768, 390, or 320px on home, with the scope control hit-testable and account/search/navigation reachable. +- Bottlenecks, apps, Atlas, collaboration, and profile returned HTTP 200; each was inspected at 1440 and 390px, with 32px headings and no horizontal overflow. +- GET search reached `/lab/feed/?q=marimo` and returned one editorial starter. +- Demo drawer geometry was x=880, width=560, height=1000 at a 1440×1000 viewport. Escape restored focus to Demo; body scrolling was restored. +- Mobile navigation opened without overflow and Escape returned focus to its toggle. +- Observatory changed from light paper/white nodes and inspector to dark paper/dark nodes and inspector, then back. ResearchMap SVG labels/geometry and white inspector were checked in actual light-mode browser rendering. + +A fresh skeptical pass found and fixed a desktop stacking-context defect that initially hid Demo behind the sidebar, a colliding Open Systems map label, a missing loading notification affordance, and overlarge Observatory metadata inherited from generic headings. No nested critic/worker was launched; the parent owns independent product/security review and final integrated release QA. + +## Integration status and honest limits + +The webpack production build and TypeScript check passed before the final onboarding import. After adding that required cross-lane import, TypeScript/build correctly fail because `LabOnboardingGate.tsx` is not on this worktree's base. Integrate the feed/auth worker first, then shell. The parent must run the exact final build and screenshots; these lane screenshots are dev/pre-onboarding-interface integration evidence, not a final release certification. + +The pre-interface full lab suite ran 182 tests: 175 passed, six failed, one optional live-browser probe was skipped. The six failures encode the superseded marketing landing, literal old shell class, and page-wide demo banner/login wording, or mount the formerly static `Landing` without auth context. These out-of-lane test files were not changed. The new shell suite, including recorded actual responsive probes and the test-only pending-worker interface, passes all 13 tests. + +No real account authorization, public writes, sends, pushes, deployment, DNS, or external configuration changes were made. The Find people destination and authenticated onboarding behavior require the peer worker integration. Old feed-owned demo supplements remain visible on this isolated base in demo mode; the feed worker owns their redesign. diff --git a/docs/open-lab/v2-domain-plan.md b/docs/open-lab/v2-domain-plan.md new file mode 100644 index 00000000..0fe4d81b --- /dev/null +++ b/docs/open-lab/v2-domain-plan.md @@ -0,0 +1,22 @@ +# Open Lab domain — prepared, not applied + +The requested `https://openlab.plrd.org` is supported by Vercel. As checked September 11, 2026, that hostname does not currently resolve. No domain, DNS, production branch, alias, or certificate configuration was changed in this pass. + +## The small safe change + +1. In the existing PLRD Vercel project, add **only** `openlab.plrd.org`. Preserve `plrd.org`, `www.plrd.org`, their existing assignments, nameservers, and email records. +2. For an unreleased pilot, assign the new domain to the approved preview branch/environment, not production. Vercel supports explicit Git-branch domain assignment. Get the exact project-specific CNAME target from its Domains panel; do not copy an example or replace nameservers. +3. Add the one subdomain CNAME at the authoritative DNS provider. Wait for the project to confirm DNS and HTTPS certificate validity, then verify the public hostname directly. +4. Configure that environment's exact Open Lab OAuth identity to this HTTPS origin, serve the metadata/callback publicly without a redirect/access wall, and keep unrelated aliases from accepting that identity. The v2 default preview path uses Vercel's trusted generated branch URL; a dedicated custom preview domain is a separate explicit origin configuration and test, not an arbitrary Host-header fallback. +5. Route the subdomain's `/` entry into `/lab/`; initially retaining `/lab/.../` route paths on this host avoids changing every internal route and AT record identifier in one DNS change. A later root-path cleanup can be separate. Do not change the stable AT community marker merely because the display domain changes. +6. After owner-approved account testing, update the PLRD home invitation to the new origin. Keep preview/noindex and production publication decisions separate. Existing browser-local drafts and OAuth grants do not migrate across origins automatically; offer export and require fresh login, never silently discard old drafts. + +## Rollback and gates + +Approval is required for the exact domain assignment/DNS write. Production homepage links are not changed until the destination and login are verified. Rollback is removal of the newly added subdomain assignment/record, preserving all existing apex and `www` records; no main-branch merge is implied. Recheck anonymous landing, metadata HTTP 200/JSON, callback return-to behavior, reload/logout, and legacy PLRD/CMS paths before declaring the domain usable. + +Sources inspected this turn: +- https://vercel.com/docs/domains/working-with-domains/add-a-domain — subdomains, project-specific CNAME, certificate/configuration validation. +- https://vercel.com/docs/domains/working-with-domains/assign-domain-to-a-git-branch — a domain can target a Preview environment and explicit branch. + +This is a configuration plan, not evidence that DNS or OAuth was changed. diff --git a/docs/open-lab/v2-feed.md b/docs/open-lab/v2-feed.md new file mode 100644 index 00000000..a99e17e4 --- /dev/null +++ b/docs/open-lab/v2-feed.md @@ -0,0 +1,60 @@ +# Open Lab v2 — feed / workshop lane + +## What the workshop does + +The default `/lab/feed/` surface is a compact mixed workshop feed, not a link to the old discussion panel. Its eight initial entries interleave existing public tools/perspectives with the existing Demo provider's reproducibility, neural-duration, and reusable-artifact stories. Each entry identifies an artifact, a bounded next action, and the current epistemic stage. Prototypes in the invented stories are specifications, not working releases. Public tools are editorial links, not inventions claimed by the demo people. + +- Discover and Following; Following is the union of followed disciplines, ideas, and people, deduplicated by activity ID. Selected disciplines match any selected tag, then intersect with the subscription union and text search. +- Nine local scientific disciplines: neuroscience, AI/machine learning, scientific software, mathematics, physics, biology, materials science, coordination science, and open systems. Disciplines with no entries show an honest empty state. Named multi-discipline views and idea-level tags persist locally. +- Detail panels lead with a bounded task and an artifact, then the test, uncertainty, contribution form, and supporting discussion. Real tool/source links, the existing contribution editor, and `/lab/collaborate/` run-packet preparation remain accessible. Run packets are not dispatches. +- A test can be saved to My bench, then returned with a result note, optional HTTPS artifact link, and worked/did-not-work/uncertain outcome. This is a personal commitment, not a task reservation or a notification sent to somebody. Results remain self-reported, not externally validated. +- The composer asks “What are you making?” and offers Prototype / Test result / Help wanted. Stage distinguishes idea, unfinished prototype, working, and tested. No stage grants scientific validation. Structured local build updates appear in the same feed and My bench. + +## Identity, follows, and storage + +`useLabIdentity` is unchanged. `useLabFollowing` composes browser-local preference storage with the current Demo provider's canonical follows/saved discussions. It does not duplicate those events. Shell must pass the actual restored identity to the one Demo provider's existing `storageScope` prop (see integration below). + +New keys: + +- `open-lab:following:v1::` — subscriptions, named views, idea tags, active Following/Discover and discipline filter. +- `open-lab:inventions:v1::` — personal bounded tasks, returned result notes, and structured local build updates. + +Real identity and guest keys never migrate automatically. Demo/live keys remain separate. Corrupt or unsupported shapes are preserved and block writes with visible errors; writes are read back before the UI reports saved. Text search is transient. Demo people and idea follows still use the provider's `app-demo:community:v1:` state and actions; point budget, notifications, and replies are unchanged. Existing `plrd:open-lab:v1::` social/profile/source-interest and record-editor draft keys are retained, not migrated. The legacy source-interest view is explicitly personal, separate from demo activity, and now refuses to overwrite unreadable data. + +The local taxonomy retains compatible original IDs and exports an explicit `protocolFieldForDiscipline()` mapping to the original PDS field enum. Mathematics, physics, biology, and materials map to `cross-field`. No protocol schema, auth module, public record validation, record writer, or permission check changed. New structured build updates currently have no public publication path; public record authoring remains the separate reviewed RecordEditor. + +## Profiles and people + +`/lab/people/` imports the auth worker's default `BlueskyConnections`, and also presents demo workshop personas. Demo person buttons open the appropriate `LabDialog` with a local Follow action and clear historical-persona disclosure. The six personas reuse existing IDs for compatibility: Ada Lovelace, Hedy Lamarr, Nikola Tesla, Marie Curie, Katherine Johnson, and Leonardo da Vinci. These are fictional inventor-inspired scenarios, not their statements, endorsements, or actual users; no portraits or attributed historical quotations were invented. + +My bench retains actual imported auth name/handle/DID/avatar and an actual Bluesky-profile link. A locally chosen display name is explicitly separate, not a replacement for the imported identity. Existing public record inspector/deletion/editing paths remain fail-closed. New bench sections show tasks/results/builds and followed disciplines/ideas/people, with functional details/unfollow controls. + +`social/LabOnboardingGate.tsx` is a default export for shell mounting. It appears only after verified `isAuthenticated`, a DID, and completed restoration. It offers display information, interests, and optional Scholar/GitHub/LinkedIn URLs. Profile edits patch only changed fields of the existing profile draft. Completion/skip/local display name live in the existing DID-scoped social metadata. Complete or skipped state suppresses reopening across page mounts. No automatic publication or remote follow occurs. Blocked/corrupt storage stays visible; a separate one-visit dismissal does not claim persistent completion. + +## Photo/video: implemented and deferred + +Media selection is real local preview, not a fake upload. Accepted MIME types and matching signatures: PNG, JPEG, WebP, MP4, WebM. Images are limited to 8 MiB, video to 50 MiB, with four files and 50 MiB total per composer. SVG, HTML, empty, mismatched, and oversize files fail visibly. Media has alt text/video description, caption, remove, original download, and caption-manifest download. Videos have controls and no autoplay. Decode/playback failures are visible. + +Binary persistence is deliberately deferred: no IndexedDB or auth-SDK storage is touched. Object URLs are scoped to the identity/mode composer instance, revoked on remove/unmount/scope change, and pending reads cannot add previews after disposal. Closing/reloading removes previews; the UI explicitly says text saved does NOT mean media saved. Original files and captions can be downloaded before leaving. Selection makes no remote upload calls, and this composer provides no pretend public-post button. + +AT Protocol supports media. The missing work is this app's upload/publication/readback integration, not a protocol impossibility: + +- https://docs.bsky.app/docs/tutorials/creating-a-post +- https://docs.bsky.app/docs/tutorials/video — video includes processing and account-level limits. + +## Integration requirements + +1. Integrate auth worker's `social/BlueskyConnections.tsx` first. This lane intentionally does not create a stub production component. Without it, full tsc/build fails with that missing module; People UI tests substitute only that seam in memory. +2. Shell mounts default `LabOnboardingGate` once under LabAuth/Lab contexts. +3. Shell passes `storageScope={session?.did || 'browser'}` to the ONE DemoCommunityProvider after identity restoration. Its existing keyed remount isolates demo state. Do not nest a second independent fixture provider. New preference keys already use verified DID + demo/live independently. +4. Shell's LabDialog change supplies the right drawer/focus/scroll contract; the new person/task/composer details all use LabDialog. This worktree's old centered LabDialog was not edited. Shell owns static left navigation and the one global demo menu. Keep the new persona disclosure in that menu; `DEMO_PERSONA_DISCLOSURE` is exported for reuse. +5. The previous `/lab/profile/` page has a supplementary `DemoPeople` section outside this lane's owned page paths; parent may remove it if redundant after People navigation is integrated. +6. Three old tests outside lane ownership assume the former feed starts with a supplemental discussion panel and eagerly mounts the legacy source controls. Update those assumptions to the new default feed, and click “Public sources & my records” before testing its legacy controls; kind filtering now uses the “Source kind” select. Do not reintroduce an obsolete panel just to satisfy them. `lab-feed-public.test.mjs` replays explicit-DID/late-account protection and search/filter/bookmark reload against the relocated source component, and both pass. + +## Verification + +Tests use the standard `scripts/lab-*.test.mjs` glob, actual TypeScript/React sources, and jsdom. RED→GREEN receipts include missing subscription model; missing curator action; missing feed model; missing provider reply projection; missing central feed; central FeedWorkbench returning zero mixed rows; missing authenticated onboarding; missing personal-task model; unsupported structured build action; missing science taxonomy; missing preview validator/picker; missing bench/composer/People UI; auth restoration blanking the feed; and a corrupt legacy bookmark being overwritten. Each was observed failing before its corresponding implementation/fix. + +Covered behavior includes OR/dedup semantics, resets, saved views, reload, mode/owner isolation, provider-backed follows/replies, no network calls from local social/media interactions, actual profile fields, no automatic onboarding publication, local bounded-task return, honest stages, MIME/signature/size rejection, local media preview/removal/scope cleanup, and legacy/public fail-closed record tests. + +Release notes and exact command exits, commit SHA, browser screenshots, and remaining parent integration gates are in `/opt/data/tmp/openlab-v2-feed-result.md`. This is a lane handoff, not a deployment or a claim that integration build is green. diff --git a/docs/open-lab/v3-apps-handoff.md b/docs/open-lab/v3-apps-handoff.md new file mode 100644 index 00000000..fa546f0b --- /dev/null +++ b/docs/open-lab/v3-apps-handoff.md @@ -0,0 +1,36 @@ +# Open Lab v3 — app catalog and source-bound handoff + +## Delivered behavior + +`/lab/apps/` is a source-attributed catalog, not an execution surface. Its five editorial starter listings lead to a use-case preview, maintainer attribution, license/source links, limitations, local save/follow actions, and a review draft. Launch opens the actual external site with `noopener noreferrer`. There is no iframe, simulator, install, invented review score, member count, or implied usage metric. Local listing creation/editing is unpublished and explicitly scoped to this browser, identity, and Demo/live mode. + +App shelves use `open-lab:apps:v1::`. The loader and writer share the same validator, including entry and serialized-size bounds. Corrupt, unknown-version, unknown-field, unavailable, and unconfirmed storage does not become a success or get silently replaced. Selection is an ID rederived from the current shelf; account/mode changes remount the catalog and editor, not merely the review textarea. Validation errors are correctable in the open editor rather than locking all subsequent writes. + +Feed tool details expose the same app listing while preserving the existing bounded-task, evidence, source-result, build-edit, and record-editor paths. `WorkHandoff` offers Work here / Work on GitHub / Use my agent. Copy/download rereads the bench, saves the exact source-correlated task first, and then exports a text brief with task/source IDs, the exact goal, source context, source/artifact links, expected output, limits, and stop condition. Exports preserve an existing task's original goal/artifact/title and result. Current source context is explicitly distinguished from the older saved task snapshot. Missing or mismatched legacy source correlation blocks export instead of silently remapping it. + +The exported brief is a manual handoff, not a dispatch. GitHub destinations require HTTPS on github.com without credentials or control characters and are not verified as belonging to the source project. No URL is fetched by the catalog/handoff code. No issue, PR, agent, account action, public record, or sync is created. Return remains the existing self-reported result path on the same source and My bench. + +`/lab/explorations/arcade/` now redirects to `/lab/apps/`; its old implementation and standalone unit probes remain for compatibility, but this route no longer mounts it. Source discovery must not promote the retired runtime. The daily-shell lane owns the remaining feed-model action label change from “Try prototype” to “View app.” + +## Verification and review + +The original interrupted lane's TDD history is in session `20260911_131309_70068f`: catalog missing-model RED (message 7957), handoff missing-model RED (8137), and the earlier UI/storage/snapshot iterations. Its redirected worker log was empty on resume, so it was not treated as evidence of success. + +Resume RED → GREEN: + +- Invalid GitHub source URL followed by a corrected value: observed the corrected value was not persisted because the save button remained disabled; fixed action-error handling and reran the real React editor probe. +- Local idea brief: observed the actual idea text was absent; added source context with explicit current-vs-saved snapshot labeling and reran model/UI handoff probes. +- Broader Lab replay exposed two obsolete app-entry test assumptions: the retired arcade route was expected to render instead of redirect, and app SSR omitted the now-required auth provider and expected removed execution-oriented labels. Updated only those app assertions to the v3 contract; no production guards were relaxed. + +Additional deterministic probes cover same-ID listings in different identity/mode shelves with open review/editor drafts; no network on local catalog/handoff paths; actual external launch links; readback/persistence; shelf bounds; export after storage corruption; preservation of original task goals and negative results; and return-to-source behavior. Parent review note 2 is covered by the open-dialog/open-editor account-and-mode regression. + +Fresh-skeptical lane review: no remaining scoped logic/security blocker identified after those fixes. Source/code review and tests are not visual or independent release approval. No nested reviewer, browser, build, push, PR, merge, or deployment was run in this lane. + +## Parent gates + +- Integrate with daily-shell/science-tree at one pinned revision and rerun the full repository tests, frozen pnpm install, typecheck, and isolated production build. +- Inspect real desktop and 390/320px mobile catalog and detail drawers, light/dark hierarchy, actual clipboard/download flows, Back/Escape/focus return, and source-bound result return. CSS/jsdom checks do not establish geometry. +- Confirm the daily-shell feed action says “View app” and the catalog has no promoted embedded runtime entry. +- Independent exact-revision review and preview release remain parent-owned. Local listings/reviews are deliberately unpublished; cross-device sync, public UGC moderation/reviews, agent dispatch, and GitHub sync are not implemented or represented as working. + +Exact local commit, commands, logs, counts, and integration notes are in `/opt/data/tmp/openlab-v3-apps-handoff-result.md`. diff --git a/docs/open-lab/v3-daily-shell.md b/docs/open-lab/v3-daily-shell.md new file mode 100644 index 00000000..c71d5e15 --- /dev/null +++ b/docs/open-lab/v3-daily-shell.md @@ -0,0 +1,33 @@ +# Open Lab v3 — daily catch-up and shell + +## Behavior + +The home/feed surface is now Catch up. The default unread view is browser-local acknowledgement history, not a claim that an item is new today. Readers can move directly to unread followed work, narrow to explicit help requests, inspect a source, and acknowledge only the exact revisions they opened or explicitly marked Reviewed in the current filtered view. Rendering a row alone does not acknowledge it. A changed source returns to unread; hidden/unreviewed items are not cleared. All activity reopens caught-up work. Reset filters also exits Needs a hand. + +Demo examples, editorial starters, and local unpublished work keep visible provenance. Empty, loading, failed, and truncated public reads are not presented as a complete all-caught-up result. No dates, live community activity, collaboration metrics, or scientific validation are manufactured. + +On your bench offers up to three unfinished, scoped task snapshots and a link to the full bench. Return a result opens the existing exact-ID result editor. It does not substitute a recipe, claim a reservation, submit an issue, or dispatch an agent. The result remains on the existing source-associated bench task, with its actual self-reported outcome and optional artifact URL. + +## Storage contract + +New key: `open-lab:catchup:v1::`. + +The owner is the restored authenticated DID or guest; mode, owner, and version are checked at read time. No automatic guest/account/Demo migration. Scope changes remount the feed so reviewed selections, notices, and task drawers cannot follow another identity. + +A receipt identifies a feed row and its source-content revision; it is not a timestamp. Receipt IDs and revision strings have strict limits. Both reader and writer enforce the same schema, 1,000 distinct row IDs, and a 1,048,576-byte UTF-8 serialized envelope. Reads reject oversized envelopes before parsing. Writers re-read current history, validate the complete merged envelope before writing, and require exact readback before reporting success. + +Corrupt, unknown, wrong-scope, oversized, and unavailable reads block writes. Full history is preserved rather than silently evicted or overwritten; no new acknowledgement is saved when a bound would be exceeded. Existing IDs can still receive a revised receipt at the count cap if the envelope remains valid. A future explicit history-management/export flow is not implemented. LocalStorage merging is synchronous best effort, not cross-tab transactional synchronization; simultaneous independent writes can still race. This is not cross-device sync. + +## Layout and navigation + +Static left navigation, white working stream, tinted context, and existing right drawers have distinct surfaces. The shared canvas uses available width up to 1,600 px. Row sources/tags and branch management are progressively disclosed; provenance and next action stay visible. Existing responsive context drawer, native detail controls, keyboard focus treatment, touch target rules, light-first behavior, and dark tokens remain in place. + +Primary navigation: Catch up; Work on ideas; Find tools; Explore the tech tree; Contribute; Find people; My bench. Improve the Atlas and Efforts experiment remain secondary with correct active states. `/lab/feed/` still maps to Catch up. Routes retain trailing slashes. The shell search says Search work and explicitly searches feed ideas, tools, and requests; it does not pretend to search the new science taxonomy. Tool rows say View app; the app listing and external-launch behavior belong to the apps-handoff lane. + +## Verification and handoff + +New model, UI, and CSS contract tests supplement the existing feed/following/bench, public-source isolation, shell/auth, theme-cascade/contrast, and security regression tests. UI tests execute the actual React components in jsdom with synthetic fixtures; they cover read/acknowledge/reload, filtered-out reviews, Demo/account isolation, failed saves, incomplete sources, exact stored-task continuation, negative result persistence, and reset from empty help views. + +The resumed regression pass reproduced and fixed count overflow, UTF-8 envelope/writer-reader parity, a trapped empty help view, overbroad search wording, and a stale success notice after failed acknowledgement. A legacy appearance-test heading assertion was updated from The workshop to Catch up; its actual cascade, sizing, and theme assertions were retained. + +This lane does not build, browse, publish, push, merge, deploy, or invoke other workers. Parent must replay the integrated exact revision, review App Store/feed detail interoperability, and verify actual desktop/mobile/dark layouts and native disclosure/focus interactions in a browser. CSS source tests and jsdom are not screenshot evidence. Exact local commit and test receipts are recorded in `/opt/data/tmp/openlab-v3-daily-shell-result.md`. diff --git a/docs/open-lab/v3-native-evidence.md b/docs/open-lab/v3-native-evidence.md new file mode 100644 index 00000000..7afe0f14 --- /dev/null +++ b/docs/open-lab/v3-native-evidence.md @@ -0,0 +1,37 @@ +# Open Lab v3 — native journey evidence + +## Scope and source + +These are real Chrome captures and interactions on a localhost Next.js dev server at runtime revision `4584de13227bbb38f45002dd0beac5e1e4e10e41`. All authored listings, reviews, proposals, tasks, and returned results in the captures are explicitly synthetic, browser-local test data. They are not evidence of live community participation or scientific validation. The evidence-only commit does not change runtime code. + +This remains the existing draft PR and preview iteration, not a production/public-account launch. The PR records the final clean install/test/build, independent final verdicts, and deployed revision separately. + +## Exercised journeys + +- **Catch up → work → return → catch up:** acknowledge the old exact feed row, open its same source/task, inspect and download the real plain-text agent brief, return a failed-result note and artifact URL, observe that source become unread again, reopen/review the new revision, acknowledge it, and reload without replaying it. Native selectors use the actual `message:r5` row identity, not a title shared by another discussion row. +- **App catalog:** the actual Find tools route leads with app search/cards, not the old expanded demo discussion. Open marimo's source-attributed listing; save an app and a local review, reload and read both back. No iframe or embedded runtime is mounted. The legacy arcade entrance redirects to the catalog. +- **Authored app listing:** fill and read back every field, save an unpublished synthetic listing, reopen/edit its evidence, reload the exact listing URL, and verify the edit. At 1440, 390, and 320px, app drawers fit the viewport and Escape restores focus to the originating card. +- **GitHub/own-agent handoff:** both destinations preserve the saved task/source identity and its goal, output, limits, and stop condition. The native downloaded file's contents equal the inspected brief. No issue, PR, agent job, or public record is created. +- **Tech tree:** navigate root → Life Sciences → Neuroscience → Cognitive Neuroscience; search the entire snapshot from that narrow branch for CRISPR and Genetic Engineering; open topic T10878, reload its deep link, go up to its parent and back through browser history. Zoom, pan, reset, and mobile default List were exercised. Narrow panes pan rather than auto-shrinking node titles below 14.1px at default zoom. +- **Responsive shell:** requested widths 1440, 1024, 390, and 320px across home, apps, bottlenecks, tech tree, and My bench: 20 route/viewport checks. Requested viewport width, document width, scroll width, active navigation, visible scope/account/search controls, and heading sizes were measured. The standard shell test consumed these real probes rather than skipping its browser gate. +- **Small-phone search:** the magnifier is a real input, expands across the header on focus, accepts a query, and submits the native GET feed search without horizontal overflow. +- **Compact proposal:** the earlier native check saved two synthetic fields, collapsed/reopened their disclosure groups, and reloaded the persisted values. Its source was `a811685`; later changes do not alter the proposal's writer or disclosure structure. The included final-runtime route capture shows the integrated shell. + +## Independent review findings resolved in code + +- Returned source evidence now participates in deterministic catch-up revisions, without clearing unrelated history or inferring legacy associations. +- App discovery leads its actual route; tool details put contribution preparation behind a disclosure instead of repeating source prose/links. +- URL validation rejects ambiguous HTTPS authority forms before parsing. Tests include mounted GitHub anchors at an HTTPS document origin, no export/write for rejected forms, and exact preservation of unsupported stored input. +- Sparse graphs size to their actual rows; auto-fit has a readable minimum and no 619→620px discontinuity. +- Phone search no longer shows a clipped fragment of its placeholder; repeat-user catch-up explanation is available on demand. + +## Source integrity + +The bundled OpenAlex hierarchy is research-literature containment: 4 domains, 26 fields, 252 subfields, and 4,516 topics. It is not a prerequisite graph or an exhaustive ontology. Every acquired CSV topic and parent was compared against the bundled snapshot, with the recorded source checksum verified. See [source/navigation decision](./v3-science-map-decision.md) and [science-tree implementation notes](./v3-science-tree.md). + +## Honest limits + +- Local history, listings, saves, and reviews are scoped by browser, identity, and Demo/live mode—not cross-device or public synchronization. LocalStorage writes are not multi-tab transactions. +- External launch was clicked: a new native tab targeted `https://marimo.io/`. This container's managed-browser allowlist blocked loading that external page. The link target and public HTTP availability were checked separately; execution of the external app was not evaluated, and the browser restriction was not bypassed. +- No new real-account OAuth/PDS roundtrip, native follow, public post, public app review, GitHub mutation, or agent execution was authorized or performed in this iteration. +- These flows are a product hypothesis for useful repeat collaboration, not measured adoption or retention. diff --git a/docs/open-lab/v3-product-journeys.md b/docs/open-lab/v3-product-journeys.md new file mode 100644 index 00000000..bd43d551 --- /dev/null +++ b/docs/open-lab/v3-product-journeys.md @@ -0,0 +1,41 @@ +# Open Lab: make progress worth returning for + +## Product decision +Open Lab should be the place to **notice changed work, choose a useful contribution, and bring back evidence**. It should not replace a researcher's editor, GitHub, notebook environment, or agent. A catalog gives an app context and credible evidence; the app itself runs at its own destination. + +These are concrete product hypotheses and implemented review-preview journeys, not findings from interviews or evidence of adoption. The existing prototype's demo activity is illustrative; local drafts and subscriptions are not shared collaboration infrastructure. + +## Journey 1 — a methods reviewer with ten minutes +**Problem:** “Which of the projects I care about changed, and is there something I can actually help with?” The reviewer does not need to reread a chronological wall of discussion. + +They return to **Catch up**, narrow to following or work that needs a hand, and see concise updates with a recognizable artifact, contribution request, and status. Opening a row reveals its exact source and evidence. For example, a split-before-fit experiment needs a second pair of eyes on whether held-out observations leaked into preprocessing. The reviewer saves the specific test to **My bench**, investigates, and returns a lower score or a failure just as readily as a positive result. They mark only the visible updates caught up; a changed result should become new again. + +The return value is a finite, actionable queue and a persistent next step—not an endless feed or an invented “peer reviewed” reaction. The first visit must explain that there is no earlier catch-up baseline. In demo mode, “new” means unseen sample/version in this browser, not a claim about today's real activity. + +## Journey 2 — a researcher finding an instrument +**Problem:** “Someone has probably built the tool I need. What does it do, can I inspect it, and what should I trust?” + +They open **Find tools**, search by capability or field, and open a listing rather than a simulator. The listing exposes the actual project destination, code/license where available, a concise preview or description, limitations, and evidence/reviews with clear provenance. A useful local review names what was attempted, on which artifact/version, what happened, and what remains uncertain. An empty review section stays empty; ratings and endorsements are never manufactured. The researcher can follow/save the idea, draft a contribution, and open the tool on the publisher's site. + +The return value is maintained discovery plus evidence from use. Local review drafting and editorial starter listings are a prototype of the experience, not a claim that an indexed UGC marketplace or shared review service is live. + +## Journey 3 — a contributor working with an agent +**Problem:** “I can help, but my work happens in GitHub or with my own agent. How do I avoid losing the original question and deliver something reviewable?” + +From the exact work item's detail, the contributor saves a bounded task and prepares a portable work brief. The brief carries the source identity, question, expected artifact, evidence requirements, constraints, and stop condition. They can copy/download it for their agent or prepare a GitHub handoff. Merely opening a draft or downloading a brief does not create an issue, run an agent, spend tokens, notify a teammate, or reserve work. User-entered repository links are validated and never fetched server-side as trusted execution instructions. + +After doing the work elsewhere, they return the artifact URL and result to the same task on **My bench**. A negative or inconclusive result is useful. A later collaborator should be able to inspect exactly what changed and where the evidence lives. + +The return value is continuity across tools and a reviewable result, rather than another isolated conversation. Automatic GitHub/agent synchronization is a separate integration; manual source-bound handoff must not pretend to be one. + +## Journey 4 — a curious researcher outside PL R&D +**Problem:** “Where does my question sit in science, and can I find related branches without already knowing PL's focus areas?” + +They open the **tech tree**, browse a broad research-literature hierarchy or search it globally, and drill from domain to field to subfield to specific topic. Breadcrumbs, deep links, back/forward, zoom/reset, and an accessible list prevent getting lost. PL R&D's focus is an overlay on this larger map. A narrow project map cannot stand in for humanity's science. + +The initial data backbone is the [OpenAlex Topics hierarchy](https://help.openalex.org/data/topics/) linked from its [official classification repository](https://github.com/ourresearch/openalex-topic-classification). Its machine-generated topic labels describe citation clusters, not settled ontology or technological prerequisites. Explicitly separate hierarchical containment, keyword overlap, and any future expert-curated dependency edges. A readable 2D neighborhood with semantic zoom is the default; 3D is not useful if the only way to read a label is to orbit around another one. + +## Habit and collaboration: what must be validated after the preview +A returning-user interface is not yet a returning-user product. The decisive next validation is with a small real working group using the same research problem: does someone discover a useful change, accept a bounded contribution, bring back inspectable evidence, and receive a useful response? Measure completed evidence loops and time-to-useful-action, not page views, post volume, fictional engagement, or time in app. Any metrics collection requires an explicit privacy and data plan; this document does not add tracking. + +Before a public launch, the remaining shared-system work is substantial: verified account roundtrip; published schemas and indexing; cross-user activity/review synchronization; moderation/reporting; source/license handling; and transparent freshness. Keep those gates visible rather than filling empty states with simulated success. diff --git a/docs/open-lab/v3-science-map-decision.md b/docs/open-lab/v3-science-map-decision.md new file mode 100644 index 00000000..ade4035b --- /dev/null +++ b/docs/open-lab/v3-science-map-decision.md @@ -0,0 +1,23 @@ +# Science map: broad coverage, readable navigation + +## Decision + +Use the [OpenAlex Topics hierarchy](https://help.openalex.org/data/topics/) as a locally bundled research-literature atlas. The [official classification repository](https://github.com/ourresearch/openalex-topic-classification) links the exact source table. This snapshot contains **4 domains, 26 fields, 252 subfields, and 4,516 topics**, rather than limiting the map to PL R&D's focus areas. + +The refresh script records source URL, retrieval date, checksum, hierarchy counts, and explicit classification limits. Source identifiers and parent relationships are preserved, including surprising classifications. Parent verification compared every topic name and parent against the acquired CSV. The source CSV's SHA-256 is `f1493b5448d6998b58a76e62b6829401ca2771300b713ce5d8803780ca9d47be`. + +## Why this, not a globe of nodes + +The interaction decision is a readable, bounded **2D branch map**, with pan/zoom/reset, breadcrumbs, a full-snapshot search, stable branch links, and a complete HTML list alternative. Phones start in list view. A 3D view would add camera control and occlusion before it adds useful navigation; it also requires graphics support the accessible path must not depend on. This is a design judgment, not a performance benchmark. + +[ETO's Map of Science](https://sciencemap.eto.tech/) is a useful reference for a broad research map rather than a narrow institutional map. Its underlying corpus is different; none of its cluster data or layout is copied here. [OpenAlex's classification documentation](https://help.openalex.org/data/topics/) makes its own hierarchy, IDs, and machine-generated topic descriptions explicit, so it is the practical backbone for this preview. + +## What a line means + +A line means **contains**, following the source hierarchy. It does not mean “causes,” “must be learned first,” “technological prerequisite,” or measured scientific similarity. PL R&D's contextual briefs are an explicitly editorial overlay, not the extent of science and not source-authored OpenAlex mappings. + +This is broad coverage of indexed research literature, not an exhaustive ontology of every concept, invention, or unpublished discovery. OpenAlex topics come from citation clusters; topic labels and descriptions are machine-generated. Keywords are source facets, not an invented fifth taxonomy level. The interface should make these distinctions available without turning every branch into a disclaimer wall. + +## The next useful extension + +Validate whether researchers can locate a question, inspect its source, and navigate to a neighboring branch without losing their place. Add reviewed crosslinks and topic-linked working groups only when they create a real next action. A taxonomy alone does not create community activity, prerequisite knowledge, or a reason to return daily; the [catch-up and contribution journeys](./v3-product-journeys.md) carry that part of the product. diff --git a/docs/open-lab/v3-science-tree.md b/docs/open-lab/v3-science-tree.md new file mode 100644 index 00000000..7b1bb04f --- /dev/null +++ b/docs/open-lab/v3-science-tree.md @@ -0,0 +1,60 @@ +# Open Lab v3: science-tree lane + +## Implemented surface + +`/lab/explorations/observatory/` now explores a bundled OpenAlex research-literature hierarchy rather than treating four PL R&D questions as the entire tree. The existing question routes remain source-linked contextual briefs. + +- Four domains → 26 fields → 252 subfields → 4,516 topics, plus one explicitly app-owned `science` root (4,799 navigable nodes). +- Full-snapshot search across labels, keywords, descriptions and ancestry. Exact labels rank first; search does not narrow to the current branch or PL overlay. +- HTML/SVG branch graph, bounded to eight children per page, with real scroll/drag/button/keyboard pan, bounded zoom and reset. List/search pages contain at most 24 nodes. No WebGL, force simulation, new dependencies, runtime taxonomy service or credential is required. +- Source breadcrumbs, explicit Up, native anchor fallback/modifier clicks, selected-node URLs and history restoration. Query, layout, pagination and overlay controls are encoded in the current view URL. Search edits replace the current history entry; opening a branch pushes an entry so returning restores the search. Escape and Clear search retain input focus. +- Branch and view links are available in an optional inspector disclosure. Canonical branch links omit transient controls. Question links remain canonical, resetting to the device-default layout and turning off the overlay; their source brief receives focus on arrival/selection. +- Mobile starts with the list. Light-first white work surface and tinted source inspector have an explicit dark variant, 22/17/15px heading/body hierarchy, dividers and visible focus. Parent browser QA remains required; CSS/JSDOM probes are not screenshots. + +## Source and reproduction + +Publisher: OpenAlex / OurResearch. Metadata license: CC0 (not a license to redistribute linked full text). + +The official [classification repository](https://github.com/ourresearch/openalex-topic-classification) links the [exact source table](https://docs.google.com/spreadsheets/d/1v-MAq64x4YjhO7RWcB-yrKV5D_2vOOsxl4u6GBKEXY8/export?format=csv). [Classification method](https://help.openalex.org/data/topics/) and [data access/license](https://help.openalex.org/access/get-the-data) are linked in the inspector. + +Source checked September 11, 2026. CSV SHA-256: + +`f1493b5448d6998b58a76e62b6829401ca2771300b713ce5d8803780ca9d47be` + +The bundled JSON preserves IDs, labels, one source parent, descriptions and semicolon-delimited keywords. Numeric source topic ID `10429` is explicitly namespaced to `topic:T10429`; it is not silently treated as an already canonical OpenAlex ID. The refresh script rejects malformed IDs rather than normalizing them. + +After separately acquiring and reviewing the official CSV, run from the repository root: + +```sh +python3 scripts/refresh-lab-science-tree.py --input /path/to/reviewed-topics.csv --checked-on YYYY-MM-DD +node --test --test-concurrency=1 scripts/lab-science-tree*.test.mjs +``` + +The refresh is offline, stamps the input bytes' hash, validates stable ancestor labels/parents, unique topics, nonempty descriptions and exact per-level counts, then atomically replaces the output. A changed/incomplete universe fails closed; review intentional source changes before adjusting expected counts and count assertions. It does not run during builds or at runtime. Python 3 is only needed to refresh/test the source pipeline, not serve the app. + +The September 11 CSV was replayed into a separate output and compared byte-for-byte with the bundle. Bundled JSON: 3,325,230 bytes; gzip probe: 874,604 bytes. This is a route-local static import, not a measured production JavaScript transfer size. Parent should inspect the actual built route's payload/startup on mobile. + +## Meaning and boundaries + +Lines mean source containment only—not similarity, prerequisites, progress, peer review or current community activity. Topic names/descriptions are machine-generated from citation clusters and explicitly labeled as such. Coverage reflects indexed research and its publication/language biases. This finite literature classification is not an exhaustive ontology of every science or technology. + +The PL overlay is an editorial starting-point mapping from the four existing briefs to Computer Science (`field:17`), Neuroscience (`field:28`), and Economics, Econometrics and Finance (`field:20`), including their ancestors. It highlights but never hides other science, and does not claim that OpenAlex, PL R&D, or a topic's authors endorse a whole field. The existing compact social interest taxonomy is unchanged. + +No storage reads/writes, public mutations, arbitrary execution, account changes, server fetches, synthetic activity or hidden scientific dependencies were added. Invalid node URLs visibly return to All sciences without changing saved data. + +## Verification and remaining integration gates + +Targeted command: + +```sh +node --test --test-concurrency=1 scripts/lab-science-tree*.test.mjs scripts/lab-explorations.test.mjs scripts/lab-explorations-ui.test.mjs scripts/lab-shell-responsive.test.mjs +node_modules/.bin/tsc --noEmit --incremental false +``` + +At lane completion: 34 tests passed, zero failed, one existing real-browser probe skipped; typecheck passed. JSDOM reports its expected unsupported native document-navigation diagnostic when the modifier-click test deliberately leaves the event native. No full build or browser session was run by this lane. + +Resume red-green evidence covers the initial contextual-brief focus regression, missing search/view URL restoration, and lost view/overlay URL state on opening a branch. Added offline pipeline regression tests verify exact node preservation and that malformed IDs, duplicate topics, conflicting ancestry, missing descriptions and incomplete input cannot replace the previous snapshot. Original interrupted-run red/green receipts were not available; resumed-run results do not claim to reconstruct those executions. + +Fresh-skeptical code review found and resolved the focus/history gaps above and moved sharing fields behind a disclosure so they do not consume the primary navigation surface. Source checksum, all parent paths, exact counts and zero duplicate visible keyword keys were independently checked with deterministic probes. + +Parent owns: full frozen-install/integrated test/build replay; native browser Back/Forward and Next navigation integration; graph drag/zoom/reset/focus at desktop and 390/320px; actual long-label card geometry (e.g. `topic:T11672`), light/dark screenshots, no-WebGL operation, startup payload/performance, final independent review and release decisions. Search in the shell should remain labeled as work search; this route's explicit “Search all sciences” is the full taxonomy search. This lane does not change the shell. diff --git a/docs/open-lab/verification.md b/docs/open-lab/verification.md new file mode 100644 index 00000000..080e2f81 --- /dev/null +++ b/docs/open-lab/verification.md @@ -0,0 +1,32 @@ +# Open Lab verification evidence + +## What this evidence proves + +Local production-build evidence was captured on September 11, 2026 from source commit `6e582b12b5c83f3a3d897fd5c1829ff45268118b`. The screenshots in this directory are actual Chrome captures, not generated mockups. Any subsequent screenshot/documentation-only commit must have an empty runtime/test delta and receive final review at its own SHA. + +This is a prototype verification record, not production launch approval, a comprehensive security audit, an accessibility certification, or proof of community adoption. The PR reports its own current hosted-preview/check state. + +## Executed checks + +- Fresh isolated pnpm 10 frozen-lockfile install, followed by the project-wide test command: **275 passed, zero failed**. `npm test` includes the root `scripts/*.test.mjs` suites and the field-velocity suites. The evidence-recovery regressions are included in that standard gate. +- No-emit TypeScript check: passed. +- Next.js production build: passed; the task-owned production server answered the actual Lab routes. +- Responsive route sweep at an intended **320px** viewport: all 14 listed routes rendered a heading and had document, layout, and scroll widths no greater than 320px. Comparing only `scrollWidth` to `innerWidth` would have missed the original Arcade overflow, because Chrome expanded both to 350px. +- Desktop screenshots at **1440 × 1000**, mobile landing/inbox at **390 × 844**, and the repaired Arcade at **320 × 844**. The focused Arcade image is deliberately scrolled to show its complete headline and instrument. + +Route sweep: `/lab/`, `/lab/bottlenecks/`, `/lab/feed/`, `/lab/apps/`, `/lab/atlas/`, `/lab/collaborate/`, `/lab/profile/`, `/lab/onboarding/`, `/lab/efforts/`, `/lab/record/`, `/lab/explorations/`, `/lab/explorations/arcade/`, `/lab/explorations/observatory/`, and `/lab/explorations/observatory/portable-evaluations/`. + +## Interaction and recovery evidence + +The browser pass exercised the notification-to-discussion path; local reply and finite-point persistence across reload; real/empty mode; optional interests and self-supplied profile links; proposal preparation/export; Observatory question selection and accessible list mode; light/dark switching; and explicitly confirmed demo reset without removing the ordinary guest profile. Earlier captures of those unchanged paths were made during integration; the bundled screenshots and following repaired-path checks use the source SHA above. + +- **Evidence recovery:** an explicitly synthetic malformed original survived opening the workbench and scratch edits unchanged. The native backup download contained the exact original UTF-8 bytes. Replacement stayed gated by a separate acknowledgment; the deliberately replaced draft then survived reload. Mounted regressions also cover incompatible versions, oversized storage, unavailable reads, failed export/replacement, StrictMode, unrelated-key preservation, and permitted inputs whose serialized envelope exceeded the old restore limit. +- **Computed instrument:** selecting Rule 30 and using the actual JSON download control produced an 80-row, 121-column result. The downloaded configuration, method, limitation, and cells matched the real generator. This is an explicitly synthetic educational model, not a biological simulation or scientific finding. +- **Review boundary:** paired synthetic returns retain disagreement; import validates shape/source/role, not scientific truth. Review export requires an explicit local review. No remote agent execution is inferred from a prepared packet. +- **Identity boundary:** real component/SDK-boundary tests cover owner isolation, restored-session races, fresh consent, exact URI/CID receipts, uncertain-write recovery, stale-version refusal, and deletion confirmation. These are controlled transport tests, not a real-account OAuth/PDS pass. Cleanup-failure guidance remains visible after the owner Bench unmounts; expected default-off setup guidance stays in the sign-in flow. + +## Remaining launch gates + +In the local replay, capabilities reported `mode=unconfigured`, `canSignIn=false`, `canPublish=false`, `oauthVerified=false`, and `schemaPublished=false`. No real account was authenticated or publicly written for this verification. Canonical OAuth/schema setup, a consented real-account round trip, discovery/indexing, moderation and community operations remain separate launch work. LinkedIn/Scholar links are self-supplied, not imported or independently verified. Backing is local points, with no payment or Hypercert issuance. + +See [the protocol runbook](protocol-runbook.md), [acceptance boundaries](acceptance.md), and [product rationale](strategy.md) for the precise next gates. Preserve the brochure; do not merge or launch merely because a preview builds. diff --git a/package-lock.json b/package-lock.json index ae117c10..ab85a891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "research-website-v2", "version": "0.1.0", "dependencies": { + "@atproto-labs/did-resolver": "0.3.7", "@atproto/api": "^0.18.17", "@atproto/common-web": "^0.4.13", "@atproto/jwk-jose": "^0.1.11", + "@atproto/lexicon": "0.6.1", + "@atproto/oauth-client-browser": "0.5.4", "@atproto/oauth-client-node": "^0.3.15", "@atproto/syntax": "^0.4.3", "@next/third-parties": "^15.5.20", @@ -105,16 +108,75 @@ } }, "node_modules/@atproto-labs/did-resolver": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", - "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.7.tgz", + "integrity": "sha512-F3M3U5cU1QSAXX3J5auGEc5N2pgDgpirAjvyDFsytXuyWfrjWfTPd/H+DZrrED7gK+noW0cAWtxxjdX7/cTSVQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.5", + "@atproto-labs/pipe": "^0.2.4", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/@atproto-labs/fetch": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.5.tgz", + "integrity": "sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/@atproto-labs/pipe": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.4.tgz", + "integrity": "sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/@atproto-labs/simple-store": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.5.1.tgz", + "integrity": "sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.6.tgz", + "integrity": "sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.5.1", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/@atproto/did": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.4.tgz", + "integrity": "sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==", + "license": "MIT", "dependencies": { - "@atproto-labs/fetch": "0.2.3", - "@atproto-labs/pipe": "0.1.1", - "@atproto-labs/simple-store": "0.3.0", - "@atproto-labs/simple-store-memory": "0.1.4", - "@atproto/did": "0.2.4", "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" } }, "node_modules/@atproto-labs/fetch": { @@ -172,6 +234,20 @@ "@atproto-labs/handle-resolver": "0.3.5" } }, + "node_modules/@atproto-labs/identity-resolver/node_modules/@atproto-labs/did-resolver": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.4", + "zod": "^3.23.8" + } + }, "node_modules/@atproto-labs/pipe": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", @@ -305,6 +381,276 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.5.4.tgz", + "integrity": "sha512-+mFzVwjDmi6n6ImfTuj9dIvM2nDjGp/uv01ONhXS6TbA30WG2cPBXqPjycPldc/OSc6F7TJ4/DQREBUCeDGn8w==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.7", + "@atproto-labs/handle-resolver": "^0.4.8", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto/did": "^0.5.4", + "@atproto/jwk": "^0.7.4", + "@atproto/jwk-webcrypto": "^0.3.4", + "@atproto/oauth-client": "^0.8.4", + "@atproto/oauth-types": "^0.7.5", + "core-js": "^3.50.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/fetch": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.5.tgz", + "integrity": "sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/handle-resolver": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.8.tgz", + "integrity": "sha512-38I+j8Efs+cCgW/NX9RFKKu7qv/AF+FqxlKS8sWjXlNZSHQZqPj5cnKVQhSsJCs4ypPB84iKch8w4/STd33bLg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/identity-resolver": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.7.tgz", + "integrity": "sha512-lKDaiBHoxrqTOFdSKAAPpplEtbbFrH4djGjnXJtOSMnZ46k7+G7AkCr1xycGGPOBYfyF54zRnTp0Tt8iu86qWg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.7", + "@atproto-labs/handle-resolver": "^0.4.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/pipe": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.4.tgz", + "integrity": "sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/simple-store": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.5.1.tgz", + "integrity": "sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.6.tgz", + "integrity": "sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.5.1", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/common-web": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.10.tgz", + "integrity": "sha512-w4JUdsJ3VXt8ewkavYh5m/u0UbxDtFdCKhNZPEpJ0U1vcWIjDaSInIexteETDgD7fBJAhidIEi30MGz1kk49ug==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.7", + "@atproto/lex-json": "^0.1.6", + "@atproto/syntax": "^0.7.5", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/did": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.4.tgz", + "integrity": "sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.4.tgz", + "integrity": "sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk-jose": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.4.tgz", + "integrity": "sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.4", + "jose": "^5.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk-webcrypto": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.4.tgz", + "integrity": "sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.4", + "@atproto/jwk-jose": "^0.2.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/lex-data": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.7.tgz", + "integrity": "sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/lex-json": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.6.tgz", + "integrity": "sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/lexicon": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.12.tgz", + "integrity": "sha512-bXVWXc2+ctVVUc3CEuWV9mVXRMMQcWmdzmyRE7w2Rli0B36HADU49Vvi7PWTw26zFFqumYVl9b23bW3vrzAzbg==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.10", + "@atproto/syntax": "^0.7.5", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/oauth-client": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.8.4.tgz", + "integrity": "sha512-fO3V+4f3Tm6tHKxjXoE/fNREcO3IjQVGPpyfX7FTrkJhu+pWk68TQ5bwUNon14zxIUr8V5RQq2egY5CA/x0wKQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.7", + "@atproto-labs/fetch": "^0.3.5", + "@atproto-labs/handle-resolver": "^0.4.8", + "@atproto-labs/identity-resolver": "^0.4.7", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.4", + "@atproto/jwk": "^0.7.4", + "@atproto/oauth-types": "^0.7.5", + "@atproto/xrpc": "^0.8.11", + "core-js": "^3.50.0", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/oauth-types": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.5.tgz", + "integrity": "sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.5.4", + "@atproto/jwk": "^0.7.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/syntax": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.7.5.tgz", + "integrity": "sha512-6vnLQK8OAzg0dO6z/xnvuXn5zMV0UMI54zbxk7G7BXhGlIlLMb1yo+JVYtAlv8Nxzr+AaFdNZ8AJt+L/QhJMFQ==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/xrpc": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.11.tgz", + "integrity": "sha512-5M38m9REQouVIA4/Gh0wqo6Vncr3QbG7rb5oqlEbqB+f77pruCk4JLDipfrOrsXH+pdnVnU2IaPXvpsSAC7Mqw==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.12", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, "node_modules/@atproto/oauth-client-node": { "version": "0.3.15", "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.15.tgz", @@ -324,6 +670,34 @@ "node": ">=18.7.0" } }, + "node_modules/@atproto/oauth-client-node/node_modules/@atproto-labs/did-resolver": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.4", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client/node_modules/@atproto-labs/did-resolver": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.4", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/oauth-types": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.1.tgz", @@ -1663,10 +2037,14 @@ } }, "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" diff --git a/package.json b/package.json index 6d23f956..7204e056 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,16 @@ "build": "node scripts/build-content.mjs && next build", "start": "next start", "generate-jwk": "node scripts/generate-jwk.js", - "test": "node --test scripts/velocity/*.test.mjs scripts/neuro-content.test.mjs scripts/neuro-publication.test.mjs scripts/blog-metadata.test.mjs" + "test:lab": "node --test --test-concurrency=1 scripts/lab-*.test.mjs", + "test": "node --test --test-concurrency=1 scripts/velocity/*.test.mjs scripts/*.test.mjs" }, "dependencies": { + "@atproto-labs/did-resolver": "0.3.7", "@atproto/api": "^0.18.17", "@atproto/common-web": "^0.4.13", "@atproto/jwk-jose": "^0.1.11", + "@atproto/lexicon": "0.6.1", + "@atproto/oauth-client-browser": "0.5.4", "@atproto/oauth-client-node": "^0.3.15", "@atproto/syntax": "^0.4.3", "@next/third-parties": "^15.5.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a09331eb..5992082b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@atproto-labs/did-resolver': + specifier: 0.3.7 + version: 0.3.7 '@atproto/api': specifier: ^0.18.17 version: 0.18.21 @@ -17,6 +20,12 @@ importers: '@atproto/jwk-jose': specifier: ^0.1.11 version: 0.1.11 + '@atproto/lexicon': + specifier: 0.6.1 + version: 0.6.1 + '@atproto/oauth-client-browser': + specifier: 0.5.4 + version: 0.5.4 '@atproto/oauth-client-node': specifier: ^0.3.15 version: 0.3.16 @@ -108,6 +117,10 @@ packages: '@atproto-labs/did-resolver@0.2.6': resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + '@atproto-labs/did-resolver@0.3.7': + resolution: {integrity: sha512-F3M3U5cU1QSAXX3J5auGEc5N2pgDgpirAjvyDFsytXuyWfrjWfTPd/H+DZrrED7gK+noW0cAWtxxjdX7/cTSVQ==} + engines: {node: '>=22'} + '@atproto-labs/fetch-node@0.2.0': resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} engines: {node: '>=18.7.0'} @@ -115,6 +128,10 @@ packages: '@atproto-labs/fetch@0.2.3': resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + '@atproto-labs/fetch@0.3.5': + resolution: {integrity: sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA==} + engines: {node: '>=22'} + '@atproto-labs/handle-resolver-node@0.1.25': resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} engines: {node: '>=18.7.0'} @@ -122,45 +139,101 @@ packages: '@atproto-labs/handle-resolver@0.3.6': resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} + '@atproto-labs/handle-resolver@0.4.8': + resolution: {integrity: sha512-38I+j8Efs+cCgW/NX9RFKKu7qv/AF+FqxlKS8sWjXlNZSHQZqPj5cnKVQhSsJCs4ypPB84iKch8w4/STd33bLg==} + engines: {node: '>=22'} + '@atproto-labs/identity-resolver@0.3.6': resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} + '@atproto-labs/identity-resolver@0.4.7': + resolution: {integrity: sha512-lKDaiBHoxrqTOFdSKAAPpplEtbbFrH4djGjnXJtOSMnZ46k7+G7AkCr1xycGGPOBYfyF54zRnTp0Tt8iu86qWg==} + engines: {node: '>=22'} + '@atproto-labs/pipe@0.1.1': resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + '@atproto-labs/pipe@0.2.4': + resolution: {integrity: sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==} + engines: {node: '>=22'} + '@atproto-labs/simple-store-memory@0.1.4': resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + '@atproto-labs/simple-store-memory@0.2.6': + resolution: {integrity: sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA==} + engines: {node: '>=22'} + '@atproto-labs/simple-store@0.3.0': resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto-labs/simple-store@0.5.1': + resolution: {integrity: sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg==} + engines: {node: '>=22'} + '@atproto/api@0.18.21': resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==} '@atproto/common-web@0.4.16': resolution: {integrity: sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==} + '@atproto/common-web@0.5.10': + resolution: {integrity: sha512-w4JUdsJ3VXt8ewkavYh5m/u0UbxDtFdCKhNZPEpJ0U1vcWIjDaSInIexteETDgD7fBJAhidIEi30MGz1kk49ug==} + engines: {node: '>=22'} + '@atproto/did@0.3.0': resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} + '@atproto/did@0.5.4': + resolution: {integrity: sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==} + engines: {node: '>=22'} + '@atproto/jwk-jose@0.1.11': resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + '@atproto/jwk-jose@0.2.4': + resolution: {integrity: sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==} + engines: {node: '>=22'} + '@atproto/jwk-webcrypto@0.2.0': resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + '@atproto/jwk-webcrypto@0.3.4': + resolution: {integrity: sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA==} + engines: {node: '>=22'} + '@atproto/jwk@0.6.0': resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + '@atproto/jwk@0.7.4': + resolution: {integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==} + engines: {node: '>=22'} + '@atproto/lex-data@0.0.11': resolution: {integrity: sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==} + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} + engines: {node: '>=22'} + '@atproto/lex-json@0.0.11': resolution: {integrity: sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==} + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} + engines: {node: '>=22'} + '@atproto/lexicon@0.6.1': resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} + '@atproto/lexicon@0.7.12': + resolution: {integrity: sha512-bXVWXc2+ctVVUc3CEuWV9mVXRMMQcWmdzmyRE7w2Rli0B36HADU49Vvi7PWTw26zFFqumYVl9b23bW3vrzAzbg==} + engines: {node: '>=22'} + + '@atproto/oauth-client-browser@0.5.4': + resolution: {integrity: sha512-+mFzVwjDmi6n6ImfTuj9dIvM2nDjGp/uv01ONhXS6TbA30WG2cPBXqPjycPldc/OSc6F7TJ4/DQREBUCeDGn8w==} + engines: {node: '>=22'} + '@atproto/oauth-client-node@0.3.16': resolution: {integrity: sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw==} engines: {node: '>=18.7.0'} @@ -168,15 +241,31 @@ packages: '@atproto/oauth-client@0.5.14': resolution: {integrity: sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==} + '@atproto/oauth-client@0.8.4': + resolution: {integrity: sha512-fO3V+4f3Tm6tHKxjXoE/fNREcO3IjQVGPpyfX7FTrkJhu+pWk68TQ5bwUNon14zxIUr8V5RQq2egY5CA/x0wKQ==} + engines: {node: '>=22'} + '@atproto/oauth-types@0.6.2': resolution: {integrity: sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==} + '@atproto/oauth-types@0.7.5': + resolution: {integrity: sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==} + engines: {node: '>=22'} + '@atproto/syntax@0.4.3': resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} + '@atproto/syntax@0.7.5': + resolution: {integrity: sha512-6vnLQK8OAzg0dO6z/xnvuXn5zMV0UMI54zbxk7G7BXhGlIlLMb1yo+JVYtAlv8Nxzr+AaFdNZ8AJt+L/QhJMFQ==} + engines: {node: '>=22'} + '@atproto/xrpc@0.7.7': resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@atproto/xrpc@0.8.11': + resolution: {integrity: sha512-5M38m9REQouVIA4/Gh0wqo6Vncr3QbG7rb5oqlEbqB+f77pruCk4JLDipfrOrsXH+pdnVnU2IaPXvpsSAC7Mqw==} + engines: {node: '>=22'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -259,105 +348,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -418,28 +491,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.5.7': resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.5.7': resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.5.7': resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.5.7': resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} @@ -500,28 +569,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -641,6 +706,9 @@ packages: core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -930,28 +998,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1079,6 +1143,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} @@ -1383,6 +1450,15 @@ snapshots: '@atproto/did': 0.3.0 zod: 3.25.76 + '@atproto-labs/did-resolver@0.3.7': + dependencies: + '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/pipe': 0.2.4 + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto/did': 0.5.4 + zod: 3.25.76 + '@atproto-labs/fetch-node@0.2.0': dependencies: '@atproto-labs/fetch': 0.2.3 @@ -1394,6 +1470,10 @@ snapshots: dependencies: '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/fetch@0.3.5': + dependencies: + '@atproto-labs/pipe': 0.2.4 + '@atproto-labs/handle-resolver-node@0.1.25': dependencies: '@atproto-labs/fetch-node': 0.2.0 @@ -1407,20 +1487,41 @@ snapshots: '@atproto/did': 0.3.0 zod: 3.25.76 + '@atproto-labs/handle-resolver@0.4.8': + dependencies: + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto/did': 0.5.4 + zod: 3.25.76 + '@atproto-labs/identity-resolver@0.3.6': dependencies: '@atproto-labs/did-resolver': 0.2.6 '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/identity-resolver@0.4.7': + dependencies: + '@atproto-labs/did-resolver': 0.3.7 + '@atproto-labs/handle-resolver': 0.4.8 + '@atproto-labs/pipe@0.1.1': {} + '@atproto-labs/pipe@0.2.4': {} + '@atproto-labs/simple-store-memory@0.1.4': dependencies: '@atproto-labs/simple-store': 0.3.0 lru-cache: 10.4.3 + '@atproto-labs/simple-store-memory@0.2.6': + dependencies: + '@atproto-labs/simple-store': 0.5.1 + lru-cache: 10.4.3 + '@atproto-labs/simple-store@0.3.0': {} + '@atproto-labs/simple-store@0.5.1': {} + '@atproto/api@0.18.21': dependencies: '@atproto/common-web': 0.4.16 @@ -1439,26 +1540,53 @@ snapshots: '@atproto/syntax': 0.4.3 zod: 3.25.76 + '@atproto/common-web@0.5.10': + dependencies: + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.7.5 + zod: 3.25.76 + '@atproto/did@0.3.0': dependencies: zod: 3.25.76 + '@atproto/did@0.5.4': + dependencies: + zod: 3.25.76 + '@atproto/jwk-jose@0.1.11': dependencies: '@atproto/jwk': 0.6.0 jose: 5.10.0 + '@atproto/jwk-jose@0.2.4': + dependencies: + '@atproto/jwk': 0.7.4 + jose: 5.10.0 + '@atproto/jwk-webcrypto@0.2.0': dependencies: '@atproto/jwk': 0.6.0 '@atproto/jwk-jose': 0.1.11 zod: 3.25.76 + '@atproto/jwk-webcrypto@0.3.4': + dependencies: + '@atproto/jwk': 0.7.4 + '@atproto/jwk-jose': 0.2.4 + zod: 3.25.76 + '@atproto/jwk@0.6.0': dependencies: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/jwk@0.7.4': + dependencies: + multiformats: 13.4.2 + zod: 3.25.76 + '@atproto/lex-data@0.0.11': dependencies: multiformats: 9.9.0 @@ -1466,11 +1594,22 @@ snapshots: uint8arrays: 3.0.0 unicode-segmenter: 0.14.5 + '@atproto/lex-data@0.1.7': + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + '@atproto/lex-json@0.0.11': dependencies: '@atproto/lex-data': 0.0.11 tslib: 2.8.1 + '@atproto/lex-json@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + tslib: 2.8.1 + '@atproto/lexicon@0.6.1': dependencies: '@atproto/common-web': 0.4.16 @@ -1479,6 +1618,25 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/lexicon@0.7.12': + dependencies: + '@atproto/common-web': 0.5.10 + '@atproto/syntax': 0.7.5 + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/oauth-client-browser@0.5.4': + dependencies: + '@atproto-labs/did-resolver': 0.3.7 + '@atproto-labs/handle-resolver': 0.4.8 + '@atproto-labs/simple-store': 0.5.1 + '@atproto/did': 0.5.4 + '@atproto/jwk': 0.7.4 + '@atproto/jwk-webcrypto': 0.3.4 + '@atproto/oauth-client': 0.8.4 + '@atproto/oauth-types': 0.7.5 + core-js: 3.50.0 + '@atproto/oauth-client-node@0.3.16': dependencies: '@atproto-labs/did-resolver': 0.2.6 @@ -1507,21 +1665,53 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/oauth-client@0.8.4': + dependencies: + '@atproto-labs/did-resolver': 0.3.7 + '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/handle-resolver': 0.4.8 + '@atproto-labs/identity-resolver': 0.4.7 + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto/did': 0.5.4 + '@atproto/jwk': 0.7.4 + '@atproto/oauth-types': 0.7.5 + '@atproto/xrpc': 0.8.11 + core-js: 3.50.0 + multiformats: 13.4.2 + zod: 3.25.76 + '@atproto/oauth-types@0.6.2': dependencies: '@atproto/did': 0.3.0 '@atproto/jwk': 0.6.0 zod: 3.25.76 + '@atproto/oauth-types@0.7.5': + dependencies: + '@atproto/did': 0.5.4 + '@atproto/jwk': 0.7.4 + zod: 3.25.76 + '@atproto/syntax@0.4.3': dependencies: tslib: 2.8.1 + '@atproto/syntax@0.7.5': + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + '@atproto/xrpc@0.7.7': dependencies: '@atproto/lexicon': 0.6.1 zod: 3.25.76 + '@atproto/xrpc@0.8.11': + dependencies: + '@atproto/lexicon': 0.7.12 + zod: 3.25.76 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -1850,6 +2040,8 @@ snapshots: core-js@3.48.0: {} + core-js@3.50.0: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -2385,6 +2577,8 @@ snapshots: ms@2.1.3: {} + multiformats@13.4.2: {} + multiformats@9.9.0: {} nanoid@3.3.11: {} diff --git a/public/lab/lexicons/org.plresearch.lab.app.json b/public/lab/lexicons/org.plresearch.lab.app.json new file mode 100644 index 00000000..f5ca4f34 --- /dev/null +++ b/public/lab/lexicons/org.plresearch.lab.app.json @@ -0,0 +1,64 @@ +{ + "lexicon": 1, + "id": "org.plresearch.lab.app", + "description": "Open Lab pilot candidate schema. Not published through Lexicon resolution; public self-reported content, not endorsement or peer review.", + "defs": { + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": [ + "community", + "createdAt", + "title", + "url", + "description", + "field" + ], + "properties": { + "community": { + "type": "string", + "const": "https://www.plrd.org/lab/" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "title": { + "type": "string", + "minLength": 1, + "maxGraphemes": 160, + "maxLength": 640 + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "description": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1200, + "maxLength": 4800 + }, + "field": { + "type": "string", + "enum": [ + "digital-human-rights", + "economies-governance", + "ai-robotics", + "neurotech", + "cross-field" + ] + }, + "githubUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } +} diff --git a/public/lab/lexicons/org.plresearch.lab.contribution.json b/public/lab/lexicons/org.plresearch.lab.contribution.json new file mode 100644 index 00000000..510da478 --- /dev/null +++ b/public/lab/lexicons/org.plresearch.lab.contribution.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "org.plresearch.lab.contribution", + "description": "Open Lab pilot candidate schema. Not published through Lexicon resolution; public self-reported content, not endorsement or peer review.", + "defs": { + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": [ + "community", + "createdAt", + "targetUrl", + "observation", + "evidenceUrl", + "field" + ], + "properties": { + "community": { + "type": "string", + "const": "https://www.plrd.org/lab/" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "targetUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "observation": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1200, + "maxLength": 4800 + }, + "evidenceUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "field": { + "type": "string", + "enum": [ + "digital-human-rights", + "economies-governance", + "ai-robotics", + "neurotech", + "cross-field" + ] + } + } + } + } + } +} diff --git a/public/lab/lexicons/org.plresearch.lab.note.json b/public/lab/lexicons/org.plresearch.lab.note.json new file mode 100644 index 00000000..03f6f928 --- /dev/null +++ b/public/lab/lexicons/org.plresearch.lab.note.json @@ -0,0 +1,62 @@ +{ + "lexicon": 1, + "id": "org.plresearch.lab.note", + "description": "Open Lab pilot candidate schema. Not published through Lexicon resolution; public self-reported content, not endorsement or peer review.", + "defs": { + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": [ + "community", + "createdAt", + "text", + "postType", + "field" + ], + "properties": { + "community": { + "type": "string", + "const": "https://www.plrd.org/lab/" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "text": { + "type": "string", + "minLength": 1, + "maxGraphemes": 2000, + "maxLength": 8000 + }, + "postType": { + "type": "string", + "enum": [ + "question", + "finding", + "tool", + "help", + "negative" + ] + }, + "field": { + "type": "string", + "enum": [ + "digital-human-rights", + "economies-governance", + "ai-robotics", + "neurotech", + "cross-field" + ] + }, + "evidenceUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } +} diff --git a/public/lab/lexicons/org.plresearch.lab.participation.json b/public/lab/lexicons/org.plresearch.lab.participation.json new file mode 100644 index 00000000..2c2ef598 --- /dev/null +++ b/public/lab/lexicons/org.plresearch.lab.participation.json @@ -0,0 +1,63 @@ +{ + "lexicon": 1, + "id": "org.plresearch.lab.participation", + "description": "Open Lab pilot candidate schema. Not published through Lexicon resolution; public self-reported content, not endorsement or peer review.", + "defs": { + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": [ + "community", + "createdAt", + "campaignId", + "taskId", + "role", + "note" + ], + "properties": { + "community": { + "type": "string", + "const": "https://www.plrd.org/lab/" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "campaignId": { + "type": "string", + "minLength": 1, + "maxGraphemes": 100, + "maxLength": 400 + }, + "taskId": { + "type": "string", + "minLength": 1, + "maxGraphemes": 100, + "maxLength": 400 + }, + "role": { + "type": "string", + "enum": [ + "research", + "reproduce", + "review" + ] + }, + "note": { + "type": "string", + "minLength": 1, + "maxGraphemes": 2000, + "maxLength": 8000 + }, + "evidenceUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } +} diff --git a/public/lab/lexicons/org.plresearch.lab.profile.json b/public/lab/lexicons/org.plresearch.lab.profile.json new file mode 100644 index 00000000..fafcd3d2 --- /dev/null +++ b/public/lab/lexicons/org.plresearch.lab.profile.json @@ -0,0 +1,68 @@ +{ + "lexicon": 1, + "id": "org.plresearch.lab.profile", + "description": "Open Lab pilot candidate schema. Not published through Lexicon resolution; public self-reported content, not endorsement or peer review.", + "defs": { + "main": { + "type": "record", + "key": "literal:self", + "record": { + "type": "object", + "required": [ + "community", + "createdAt", + "workingOn", + "interests", + "lookingFor" + ], + "properties": { + "community": { + "type": "string", + "const": "https://www.plrd.org/lab/" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "workingOn": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1200, + "maxLength": 4800 + }, + "interests": { + "type": "array", + "maxLength": 8, + "items": { + "type": "string", + "minLength": 1, + "maxGraphemes": 60, + "maxLength": 240 + } + }, + "lookingFor": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1200, + "maxLength": 4800 + }, + "githubUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "linkedinUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "scholarUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } +} diff --git a/scripts/lab-app-catalog.test.mjs b/scripts/lab-app-catalog.test.mjs new file mode 100644 index 00000000..9e24863c --- /dev/null +++ b/scripts/lab-app-catalog.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +import { JSDOM } from 'jsdom' +const store=()=>{const m=new Map();return {getItem:k=>m.get(k)??null,setItem:(k,v)=>m.set(k,v),m}} +test('catalog is a source-attributed external shelf, not an embedded runtime or invented community',()=>{ + assert.ok(existsSync('src/lib/lab-app-catalog.ts'),'External app catalog model missing') + const c=source('lib/lab-app-catalog.ts') + assert.equal(c.APP_CATALOG.length,5) + for(const app of c.APP_CATALOG){assert.ok(c.validAppListing(app));assert.equal(app.origin,'editorial');assert.ok(app.maintainer);assert.ok(app.license);assert.ok(app.evidence);assert.ok(c.safeAppUrl(app.launchUrl));assert.ok(!app.launchUrl.startsWith('/'))} + assert.equal(c.filterApps(c.APP_CATALOG,{query:'notebook',field:'cross-field'}).length,2) + for(const url of ['javascript:alert(1)','http://example.org','https://user:pass@example.org','https://example.org/\nsecret','https://example.org/%0a',' https://example.org','https://github.com.evil.test/x','https://github.com@evil.test/x']){ + assert.equal(c.safeAppUrl(url,true),false,url) + } + assert.equal(c.safeAppUrl('https://github.com/marimo-team/marimo',true),true) +}) +test('HTTPS validation agrees with actual HTTPS-document anchors and preserves ambiguous stored input',()=>{ + const c=source('lib/lab-app-catalog.ts'),s=store(),owner='authority',mode='live',key=c.appShelfKey(owner,mode) + const dom=new JSDOM('',{url:'https://openlab.example/lab/apps/'}) + for(const url of ['https:github.com/owner/repo','https:/github.com/owner/repo','https:///github.com/owner/repo','https://github.com\\owner/repo']) { + assert.equal(c.safeAppUrl(url,true),false,url) + const listing={...c.APP_CATALOG[0],id:'local:authority',origin:'local',launchUrl:url,sourceUrl:url,codeUrl:url} + assert.equal(c.changeAppShelf(s,owner,mode,{type:'listing',listing}).ok,false) + assert.equal(s.getItem(key),null) + const raw=JSON.stringify({version:1,owner,mode,saved:[],reviews:{},listings:[listing]});s.setItem(key,raw) + assert.ok(c.loadAppShelf(s,owner,mode).error) + assert.equal(c.changeAppShelf(s,owner,mode,{type:'save',id:'marimo'}).ok,false);assert.equal(s.getItem(key),raw);s.m.delete(key) + } + const url='https://github.com/owner/repo';assert.equal(c.safeAppUrl(url,true),true) + const a=dom.window.document.querySelector('a');a.setAttribute('href',url);assert.equal(new URL(a.href).hostname,'github.com');assert.equal(a.href,url) +}) +test('app shelf writer and reader agree at entry and serialized-size limits without overwriting originals',()=>{ + const c=source('lib/lab-app-catalog.ts'),s=store(),owner='bounds',mode='live',key=c.appShelfKey(owner,mode) + const listing=i=>({...c.APP_CATALOG[0],id:`local:${i}`,origin:'local'}) + const full={version:1,owner,mode,saved:[],reviews:{},listings:Array.from({length:100},(_,i)=>listing(i))} + s.setItem(key,JSON.stringify(full));assert.equal(c.loadAppShelf(s,owner,mode).error,'') + const before=s.getItem(key) + assert.equal(c.changeAppShelf(s,owner,mode,{type:'listing',listing:listing(100)}).ok,false);assert.equal(s.getItem(key),before) + assert.equal(c.changeAppShelf(s,owner,mode,{type:'listing',listing:{...listing(99),title:'Existing listing updated'}}).ok,true) + assert.equal(c.loadAppShelf(s,owner,mode).error,'') + // Each field is within its own bound; the total serialized envelope is not. + const oversized={...full,listings:full.listings.map(a=>({...a,description:'d'.repeat(2000),useCase:'u'.repeat(2000),evidence:'e'.repeat(2000)})),reviews:Object.fromEntries(Array.from({length:100},(_,i)=>[`local:${i}`,'r'.repeat(4000)]))} + const raw=JSON.stringify(oversized);assert.ok(raw.length>1_000_000);s.setItem(key,raw) + assert.ok(c.loadAppShelf(s,owner,mode).error);assert.equal(c.changeAppShelf(s,owner,mode,{type:'save',id:'marimo'}).ok,false);assert.equal(s.getItem(key),raw) +}) +test('saved apps, review drafts and authored listings are verified browser/identity/mode scoped writes',()=>{ + const c=source('lib/lab-app-catalog.ts'),s=store() + assert.equal(typeof c.changeAppShelf,'function','Scoped app shelf writer missing') + assert.equal(c.changeAppShelf(s,'alice','live',{type:'save',id:'marimo'}).ok,true) + assert.equal(c.changeAppShelf(s,'alice','live',{type:'review',id:'marimo',text:'Useful for my synthetic notebook. Package support not tested.'}).ok,true) + const listing={...c.APP_CATALOG[0],id:'local:one',title:'My public-input notebook',origin:'local'} + assert.equal(c.changeAppShelf(s,'alice','live',{type:'listing',listing}).ok,true) + const state=c.loadAppShelf(s,'alice','live').state + assert.deepEqual(state.saved,['marimo']);assert.match(state.reviews.marimo,/synthetic notebook/);assert.equal(state.listings[0].title,listing.title) + assert.equal(c.loadAppShelf(s,'bob','live').state.saved.length,0);assert.equal(c.loadAppShelf(s,'alice','demo').state.saved.length,0) + for(const bad of ['{broken',JSON.stringify({...state,version:9}),JSON.stringify({...state,extra:true}),JSON.stringify({...state,listings:[{...listing,futureField:'preserve me'}]})]){ + s.setItem(c.appShelfKey('alice','live'),bad) + assert.equal(c.changeAppShelf(s,'alice','live',{type:'save',id:'cadcad'}).ok,false) + assert.equal(s.getItem(c.appShelfKey('alice','live')),bad) + } + assert.equal(c.changeAppShelf({getItem:()=>{throw Error('Blocked')},setItem:()=>assert.fail('No overwrite')},'a','live',{type:'save',id:'marimo'}).ok,false) + assert.equal(c.changeAppShelf({getItem:()=>null,setItem:()=>{}},'a','live',{type:'save',id:'marimo'}).ok,false) + assert.equal(c.changeAppShelf(store(),'a','live',{type:'listing',listing:{...listing,codeUrl:'https://evil.test/repo'}}).ok,false) +}) diff --git a/scripts/lab-app-entry.test.mjs b/scripts/lab-app-entry.test.mjs new file mode 100644 index 00000000..e94726ff --- /dev/null +++ b/scripts/lab-app-entry.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +import { createRequire } from 'node:module' +createRequire(import.meta.url).extensions['.css']=m=>{m.exports={}} +test('editorial license claims link to the exact inspected license, not a guessed blanket license',()=>{ + const c=source('lib/lab-app-catalog.ts') + for(const [id,license] of [['marimo','Apache-2.0'],['jupyterlite','BSD-3-Clause'],['cadcad','MIT']]){ + const app=c.APP_CATALOG.find(a=>a.id===id);assert.match(app.license,new RegExp(license));assert.ok(c.safeAppUrl(app.licenseUrl));assert.ok(c.validAppListing(app)) + } + assert.match(c.APP_CATALOG.find(a=>a.id==='allen').license,/not verified/i) + assert.equal(c.validAppListing({...c.APP_CATALOG[0],licenseUrl:'javascript:alert(1)'}),false) +}) +test('app styling consumes the existing light/dark shell tokens rather than falling back to white panels',()=>{ + const css=readFileSync('src/components/lab/AppListing.module.css','utf8') + assert.doesNotMatch(css,/--lab-surface|--lab-panel/,'Undefined tokens make dark listings unreadable') + assert.match(css,/--lab-card/);assert.match(css,/--lab-paper/);assert.match(css,/focus-visible/);assert.match(css,/@media/) +}) +test('the legacy tools entrance redirects to the catalog instead of starting an embedded experiment',()=>{ + const page=source('app/lab/explorations/arcade/page.tsx') + assert.throws(()=>page.default(),e=>e.digest?.includes('NEXT_REDIRECT')&&e.digest.includes('/lab/apps/')) +}) diff --git a/scripts/lab-app-listing-ui.test.mjs b/scripts/lab-app-listing-ui.test.mjs new file mode 100644 index 00000000..d87f0ede --- /dev/null +++ b/scripts/lab-app-listing-ui.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require=createRequire(import.meta.url) +require.extensions['.css']=m=>{m.exports=new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)})} +const dom=new JSDOM('
',{url:'http://localhost/lab/apps/'}) +for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','HTMLDialogElement','Event','KeyboardEvent','StorageEvent','localStorage'])Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +HTMLDialogElement.prototype.showModal=function(){this.open=true};HTMLDialogElement.prototype.close=function(){this.open=false} +globalThis.self=dom.window;globalThis.IS_REACT_ACT_ENVIRONMENT=true +const React=await import('react'),{createRoot}=await import('react-dom/client') +let identity={isLoading:false,isAuthenticated:false,session:null} +source('lib/lab-identity.ts').useLabIdentity=()=>identity +const D=source('components/lab/demo/DemoCommunityProvider.tsx') +function Mode(){const d=D.useDemoCommunity();return React.createElement('button',{onClick:()=>d.setMode(d.isDemo?'live':'demo')},'Switch mode')} +const click=async label=>{const e=[...document.querySelectorAll('button,a,summary')].find(e=>e.getAttribute('aria-label')===label||e.textContent.trim()===label);assert.ok(e,'Missing action '+label);await React.act(()=>{e.focus();e.click()});return e} +const fill=async(label,value)=>{const e=document.querySelector(`[aria-label="${label}"]`);assert.ok(e,'Missing input '+label);await React.act(()=>{Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,value);e.dispatchEvent(new Event('input',{bubbles:true}))})} +test('the actual apps route leads with discovery, with illustrative discussion only on demand',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/apps/') + const C=source('app/lab/apps/page.tsx').default,root=createRoot(document.getElementById('root')) + try { + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C)))) + const card=document.querySelector('[data-app-card]');assert.ok(card) + const panel=document.querySelector('.lab-community-supplement') + if(panel){assert.ok(panel.closest('details'));assert.equal(panel.closest('details').open,false);assert.ok(card.compareDocumentPosition(panel)&window.Node.DOCUMENT_POSITION_FOLLOWING)} + assert.equal(document.querySelector('h1').textContent,'Find tools. Keep building.') + }finally{await React.act(()=>root.unmount())} +}) +test('open local listing details and editor drafts never cross identity or Demo scopes',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/apps/') + const c=source('lib/lab-app-catalog.ts'),ownerA='did:plc:fixturea',ownerB='did:plc:fixtureb' + for(const [owner,mode,title] of [[ownerA,'demo','A demo source'],[ownerA,'live','A live source'],[ownerB,'live','B live source']]){ + assert.equal(c.changeAppShelf(localStorage,owner,mode,{type:'listing',listing:{...c.APP_CATALOG[0],id:'local:shared-id',origin:'local',title,description:`Private context for ${title}`}}).ok,true) + } + identity={isLoading:false,isAuthenticated:true,session:{did:ownerA}} + const C=source('components/lab/AppsWorkbench.tsx').default,root=createRoot(document.getElementById('root')) + const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,{storageScope:identity.session.did},React.createElement(C),React.createElement(Mode)))) + try{ + await render();await click('View app: A demo source');await fill('Your review draft','Unsaved A demo review') + await click('Switch mode');assert.doesNotMatch(document.body.textContent,/A demo source|Private context for A demo/) + assert.equal(document.querySelector('[aria-label="Your review draft"]').value,'') + await click('Edit local listing');await fill('Short description','Unsaved A live edit') + identity={isLoading:false,isAuthenticated:true,session:{did:ownerB}};await render() + assert.equal(document.querySelector('[aria-label="Short description"]'),null) + assert.doesNotMatch(document.body.textContent,/A live source|Unsaved A live edit/) + assert.match(document.querySelector('dialog').textContent,/B live source/) + assert.equal(c.loadAppShelf(localStorage,ownerB,'live').state.listings[0].description,'Private context for B live source') + }finally{await React.act(()=>root.unmount());identity={isLoading:false,isAuthenticated:false,session:null};window.history.replaceState(null,'','/lab/apps/')} +}) +test('an invalid local listing can be corrected and saved without closing the editor',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/apps/');identity={isLoading:false,isAuthenticated:false,session:null} + const c=source('lib/lab-app-catalog.ts'),listing={...c.APP_CATALOG[0],origin:'local',id:'local:correction',title:'Correction fixture'} + assert.equal(c.changeAppShelf(localStorage,'guest','demo',{type:'listing',listing}).ok,true) + const C=source('components/lab/AppsWorkbench.tsx').default,root=createRoot(document.getElementById('root')) + try{ + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C)))) + await click('View app: Correction fixture');await click('Edit local listing') + await fill('GitHub source URL (optional)','https://example.org/not-github');await click('Save local listing') + assert.match(document.querySelector('[role="alert"]').textContent,/GitHub/) + assert.equal(c.loadAppShelf(localStorage,'guest','demo').state.listings[0].codeUrl,listing.codeUrl) + await fill('GitHub source URL (optional)','https://github.com/example/corrected');await click('Save local listing') + assert.equal(c.loadAppShelf(localStorage,'guest','demo').state.listings[0].codeUrl,'https://github.com/example/corrected') + assert.equal(document.querySelector('[aria-label="App name"]'),null) + }finally{await React.act(()=>root.unmount())} +}) +test('app discovery opens a source-rich listing before external launch; saves, follows and review drafts persist without network',async()=>{ + localStorage.clear();const C=source('components/lab/AppsWorkbench.tsx').default + let root=createRoot(document.getElementById('root'));let calls=0;const prior=globalThis.fetch;globalThis.fetch=()=>{calls++;throw Error('No remote calls')} + const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C),React.createElement(Mode)))) + try{ + await render();assert.equal(document.querySelector('[data-signal-sandbox]'),null);assert.doesNotMatch(document.body.textContent,/Synthetic signal experiment/) + const trigger=await click('View app: marimo');const dialog=document.querySelector('dialog[open]');assert.ok(dialog) + assert.match(dialog.textContent,/Maintainer|Maintained by/);assert.match(dialog.textContent,/License/);assert.match(dialog.textContent,/Evidence/);assert.match(dialog.textContent,/No community reviews/) + const launch=dialog.querySelector('[data-app-launch]');assert.equal(launch.href,'https://marimo.io/');assert.equal(launch.target,'_blank');assert.match(launch.rel,/noopener/) + assert.ok(dialog.querySelector('[data-license-source]'),'License claim needs its inspected source link') + assert.equal(document.querySelector('iframe,canvas'),null) + await click('Save app');await click('Follow app');await fill('Your review draft','Reruns my synthetic example. Dependency compatibility remains untested.');await click('Save review draft') + assert.match(dialog.textContent,/not published|unpublished/i);await click('Close dialog');assert.equal(document.activeElement,trigger) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();await click('View app: marimo') + assert.ok(document.querySelector('[aria-label="Unsave app"]'));assert.ok(document.querySelector('[aria-label="Unfollow app"]'));assert.match(document.querySelector('[aria-label="Your review draft"]').value,/synthetic example/) + await click('Close dialog');await click('Switch mode');await click('View app: marimo');assert.equal(document.querySelector('[aria-label="Your review draft"]').value,'');await click('Close dialog') + await click('Add your app');await fill('App name','Test-only notebook');await fill('Short description','A local listing for public synthetic inputs.');await fill('Maintained by','Synthetic author');await fill('License / terms','Unverified — check linked source');await fill('Use case','Compare two public-input examples.');await fill('Evidence and limitations','Not independently evaluated.');await fill('App URL','https://example.org/app');await fill('Source / documentation URL','https://example.org/docs');await click('Save local listing') + await click('View app: Test-only notebook');assert.match(document.querySelector('dialog').textContent,/unpublished/i);await click('Close dialog');await click('Switch mode');assert.doesNotMatch(document.body.textContent,/Test-only notebook/) + assert.equal(calls,0) + }finally{await React.act(()=>root.unmount());globalThis.fetch=prior} +}) diff --git a/scripts/lab-appearance-boot.test.mjs b/scripts/lab-appearance-boot.test.mjs new file mode 100644 index 00000000..eea49936 --- /dev/null +++ b/scripts/lab-appearance-boot.test.mjs @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { JSDOM } from 'jsdom'; + +// Execute the literal first-paint script shipped by RootLayout, not a copy of its logic. +const layout = readFileSync('src/app/layout.tsx', 'utf8'); +const boot = layout.match(/__html:\s*`([^`]+)`/)?.[1]; +assert.ok(boot, 'RootLayout must still ship an executable before-paint theme script'); +function initialize({ path = '/lab/', saved = null, osDark = true, storageFails = false, mediaFails = false }) { + const dom = new JSDOM('', { url: `https://lab.example.org${path}` }); + const writes = []; + const localStorage = { + getItem(key) { assert.equal(key, 'theme'); if (storageFails) throw Error('Storage blocked'); return saved; }, + setItem(...args) { writes.push(args); }, + }; + dom.window.matchMedia = query => { + assert.equal(query, '(prefers-color-scheme: dark)'); + if (mediaFails) throw Error('Media query unavailable'); + return { matches: osDark }; + }; + runInNewContext(boot, { window: dom.window, document: dom.window.document, localStorage }); + const dark = dom.window.document.documentElement.classList.contains('dark'); + dom.window.close(); + assert.deepEqual(writes, [], 'An automatic theme must never be persisted as a user choice'); + return dark; +} + +for (const path of ['/lab', '/lab/', '/lab/feed/', '/lab/explorations/observatory/']) { + test(`OS-dark without a choice starts light before paint on ${path}`, () => { + assert.equal(initialize({ path }), false); + }); +} +for (const path of ['/lab/', '/about/']) { + for (const saved of ['light', 'dark']) { + test(`explicit ${saved} is honored before paint on ${path}`, () => { + assert.equal(initialize({ path, saved, osDark: saved === 'light' }), saved === 'dark'); + }); + } +} +for (const path of ['/', '/about/', '/laboratory/']) { + for (const osDark of [true, false]) { + test(`marketing ${path} without a choice retains OS ${osDark ? 'dark' : 'light'}`, () => { + assert.equal(initialize({ path, osDark }), osDark); + }); + } +} +test('blocked storage still resolves Lab light and marketing OS-dark without crashing', () => { + assert.equal(initialize({ storageFails: true }), false); + assert.equal(initialize({ path: '/about/', storageFails: true }), true); +}); +test('invalid stored values are automatic, not explicit light', () => { + assert.equal(initialize({ saved: 'system' }), false); + assert.equal(initialize({ path: '/about/', saved: 'system' }), true); +}); +test('unavailable preference APIs fall back to readable light', () => { + assert.equal(initialize({ path: '/about/', storageFails: true, mediaFails: true }), false); +}); diff --git a/scripts/lab-appearance-lifecycle.test.mjs b/scripts/lab-appearance-lifecycle.test.mjs new file mode 100644 index 00000000..e7f19377 --- /dev/null +++ b/scripts/lab-appearance-lifecycle.test.mjs @@ -0,0 +1,119 @@ +import { test, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { JSDOM } from 'jsdom'; +import React, { act, useLayoutEffect } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; +const require = createRequire(import.meta.url); +require.extensions['.css'] = m => { m.exports = new Proxy({}, {get: (_, p) => p === '__esModule' ? false : String(p)}); }; +const dom = new JSDOM('
', { url: 'https://lab.example.org/about/' }); +for (const key of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent', 'KeyboardEvent', 'StorageEvent', 'localStorage', 'sessionStorage']) globalThis[key] = dom.window[key]; +globalThis.self = window; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +window.HTMLDialogElement.prototype.showModal = function() { this.open = true; }; +window.HTMLDialogElement.prototype.close = function() { this.open = false; }; +const { createRoot } = await import('react-dom/client'); +const { PathnameContext } = require('next/dist/shared/lib/hooks-client-context.shared-runtime'); +const auth = source('lib/lab-identity.ts'); +const Shell = source('components/lab/LabShell.tsx').default; +const boot = readFileSync('src/app/layout.tsx', 'utf8').match(/__html:\s*`([^`]+)`/)[1]; +const isDark = () => document.documentElement.classList.contains('dark'); +let root, osDark, atCommit; +function CommitProbe() { + // Sibling layout effects run after the shell's, but BEFORE passive effects. + // This catches fixing navigation only after the first app frame has painted. + useLayoutEffect(() => { atCommit.push(isDark()); }); + return null; +} +async function navigate(path = '/lab/', strict = false) { + window.history.replaceState(null, '', path); + const content = React.createElement(PathnameContext.Provider, { value: path }, + React.createElement(Shell, null, React.createElement('h1', null, 'Appearance lifecycle probe')), + React.createElement(CommitProbe)); + await act(async () => { root.render(strict ? React.createElement(React.StrictMode, null, content) : content); }); +} +async function leave() { + window.history.replaceState(null, '', '/about/'); + await act(() => root.render(null)); +} +async function toggle() { + const button = document.querySelector('button[aria-label^="Switch to "]'); + assert.ok(button); + await act(() => button.click()); +} +beforeEach(() => { + localStorage.clear(); sessionStorage.clear(); sessionStorage.setItem('open-lab:welcome:v2', 'seen'); + document.documentElement.classList.remove('dark'); window.history.replaceState(null, '', '/about/'); + osDark = true; atCommit = []; + window.matchMedia = () => ({ matches: osDark, addEventListener() {}, removeEventListener() {} }); + mock.method(auth, 'useLabIdentity', () => ({ isLoading: false, isAuthenticated: false, session: null, error: null })); + mock.method(globalThis, 'fetch', async path => { + assert.equal(path, '/api/lab/capabilities/'); + return Response.json({ canSignIn: false, canPublish: false, mode: 'unconfigured' }); + }); + root = createRoot(document.getElementById('root')); +}); +afterEach(async () => { await act(() => root.unmount()); mock.restoreAll(); }); + +test('dark marketing → Lab is light during layout commit, stays light within Lab, then restores OS on exit', async () => { + runInNewContext(boot, { window, document, localStorage }); assert.equal(isDark(), true); + await navigate(); assert.deepEqual(atCommit, [false], 'Not a passive-effect-only theme correction'); + assert.equal(isDark(), false); assert.equal(localStorage.getItem('theme'), null); + assert.ok(document.querySelector('[aria-label="Switch to dark mode"]')); + await navigate('/lab/feed/'); assert.equal(isDark(), false); + await leave(); assert.equal(isDark(), true); assert.equal(localStorage.getItem('theme'), null); +}); +test('direct Lab load restores current marketing OS, not the light class captured at mount', async () => { + window.history.replaceState(null, '', '/lab/'); + runInNewContext(boot, { window, document, localStorage }); assert.equal(isDark(), false); + await navigate(); osDark = false; await leave(); assert.equal(isDark(), false); + await navigate(); osDark = true; await leave(); assert.equal(isDark(), true); +}); +for (const saved of ['light', 'dark']) { + test(`saved explicit ${saved} wins over inherited DOM and OS on mount and unmount`, async () => { + localStorage.setItem('theme', saved); document.documentElement.classList.toggle('dark', saved !== 'dark'); + osDark = saved !== 'dark'; + await navigate(); assert.deepEqual(atCommit, [saved === 'dark']); + assert.equal(isDark(), saved === 'dark'); + await leave(); assert.equal(isDark(), saved === 'dark'); assert.equal(localStorage.getItem('theme'), saved); + }); +} +test('appearance button persists only the explicit choice and keeps it when leaving or reentering', async () => { + await navigate(); await toggle(); assert.equal(isDark(), true); assert.equal(localStorage.getItem('theme'), 'dark'); + await toggle(); assert.equal(isDark(), false); assert.equal(localStorage.getItem('theme'), 'light'); + await leave(); assert.equal(isDark(), false); + await navigate(); assert.equal(isDark(), false); assert.ok(document.querySelector('[aria-label="Switch to dark mode"]')); +}); +test('blocked storage reads do not leave inherited dark on Lab or prevent OS restoration', async () => { + mock.method(window.Storage.prototype, 'getItem', function(key) { if (key === 'theme') throw Error('Blocked read'); return null; }); + document.documentElement.classList.add('dark'); + await navigate(); assert.equal(isDark(), false); assert.deepEqual(atCommit, [false]); + await leave(); assert.equal(isDark(), true); +}); +test('failed writes keep a just-chosen light appearance on exit, rather than stale saved dark', async () => { + localStorage.setItem('theme', 'dark'); + mock.method(window.Storage.prototype, 'setItem', function() { throw Error('Quota exceeded'); }); + await navigate(); assert.equal(isDark(), true); + await toggle(); assert.equal(isDark(), false); assert.equal(localStorage.getItem('theme'), 'dark'); + await leave(); assert.equal(isDark(), false); +}); +test('another tab’s explicit choice or removal updates the mounted shell and cleanup', async () => { + await navigate(); + localStorage.setItem('theme', 'dark'); + await act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: 'dark', storageArea: localStorage }))); + assert.equal(isDark(), true); assert.ok(document.querySelector('[aria-label="Switch to light mode"]')); + localStorage.removeItem('theme'); + await act(() => window.dispatchEvent(new StorageEvent('storage', { key: null, storageArea: localStorage }))); + assert.equal(isDark(), false); await leave(); assert.equal(isDark(), true); + // No leaked subscriber after unmount. + await act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: 'light', storageArea: localStorage }))); + assert.equal(isDark(), true); +}); +test('StrictMode setup/cleanup/setup keeps the implicit default scoped and never writes a choice', async () => { + document.documentElement.classList.add('dark'); + await navigate('/lab/', true); assert.ok(atCommit.length >= 2); assert.ok(atCommit.every(dark => !dark)); + assert.equal(isDark(), false); assert.equal(localStorage.getItem('theme'), null); + await leave(); assert.equal(isDark(), true); +}); diff --git a/scripts/lab-appearance-style.test.mjs b/scripts/lab-appearance-style.test.mjs new file mode 100644 index 00000000..f785d54b --- /dev/null +++ b/scripts/lab-appearance-style.test.mjs @@ -0,0 +1,223 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { JSDOM } from 'jsdom'; +import postcss from 'postcss'; +import { source } from './velocity/test-source-loader.mjs'; +const require = createRequire(import.meta.url); +// CSS module names stay readable for selector matching; no substitute component. +require.extensions['.css'] = m => { m.exports = new Proxy({}, { get: (_, p) => p === '__esModule' ? false : String(p) }); }; +source('lib/lab-identity.ts').useLabIdentity = () => ({ isLoading: false, isAuthenticated: false, session: null }); +const Shell = source('components/lab/LabShell.tsx').default; +const Feed = source('components/lab/FeedWorkbench.tsx').default; +const { PathnameContext } = require('next/dist/shared/lib/hooks-client-context.shared-runtime'); +const html = renderToStaticMarkup(React.createElement(PathnameContext.Provider, { value: '/lab/' }, React.createElement(Shell, null, React.createElement(Feed)))); +const document = new JSDOM(html).window.document; +const css = file => postcss.parse(readFileSync(file, 'utf8')); +const base = css('src/app/lab/lab.css'); +const composition = css('src/components/lab/lab-composition.css'); +const shell = css('src/components/lab/lab-app-shell.css'); +const feed = css('src/components/lab/feed/feed.module.css'); + +// Bounded CSS cascade regression, NOT a browser getComputedStyle substitute. +// All matching declarations are read from source, including media queries and +// specificity. Test both chunk orders because the reported bug was a tied rule. +const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +const add = (a, b) => a.map((n, i) => n + b[i]); +function specificity(selector) { + let weight = [0, 0, 0]; + selector = selector.replace(/:(is|not|has|where)\(([^()]*)\)/g, (_, kind, body) => { + if (kind !== 'where') weight = add(weight, postcss.list.comma(body).map(specificity).sort(compare).at(-1)); + return ''; + }); + assert.ok(!selector.includes('('), `Unsupported selector in cascade test: ${selector}`); + selector = selector.replace(/\[[^\]]+\]/g, () => { weight[1]++; return ''; }); + selector = selector.replace(/#[\w-]+/g, () => { weight[0]++; return ''; }); + selector = selector.replace(/\.[\w-]+|:(?!:)[\w-]+/g, () => { weight[1]++; return ''; }); + weight[2] += (selector.match(/[a-zA-Z][\w-]*/g) || []).length; + return weight; +} +function active(rule, width) { + for (let parent = rule.parent; parent; parent = parent.parent) { + if (parent.type !== 'atrule') continue; + assert.equal(parent.name, 'media'); + if (parent.params.includes('prefers-reduced-motion')) return false; + const limits = [...parent.params.matchAll(/(max|min)-width:\s*(\d+)px/g)]; + assert.ok(limits.length, `Unmodeled query: ${parent.params}`); + if (limits.some(([, kind, n]) => kind === 'max' ? width > +n : width < +n)) return false; + } + return true; +} +function winner(element, prop, sheets, width) { + let result; + for (const sheet of sheets) sheet.walkRules(rule => { + if (!active(rule, width)) return; + for (const original of postcss.list.comma(rule.selector)) { + const selector = original.replace(/:global\(([^)]+)\)/g, '$1'); + if (!element.matches(selector)) continue; + rule.walkDecls(prop, d => { + const rank = [Number(!!d.important), ...specificity(selector)]; + const rankCompare = result ? rank[0] - result.rank[0] || compare(rank.slice(1), result.rank.slice(1)) : 1; + if (rankCompare >= 0) result = { value: d.value, rank, selector }; + }); + } + }); + return result; +} +test('workshop heading wins at <=22px over the actual shell in either CSS chunk order, including mobile', () => { + const h1 = document.querySelector('h1'); + assert.equal(h1.textContent, 'Catch up'); + assert.ok(h1.closest('[aria-label="Workshop invitation"]')); + for (const order of [[base, composition, feed, shell], [base, composition, shell, feed]]) { + for (const width of [1440, 800, 390]) { + const actual = winner(h1, 'font-size', order, width); + assert.match(actual.value, /^\d+px$/); + assert.ok(parseFloat(actual.value) <= 22, `${width}px viewport: ${actual.value} from ${actual.selector}`); + assert.equal(actual.rank[0], 0, 'No !important workaround'); + } + } +}); +test('small-phone work search is a visible icon that expands to a readable native input on focus', () => { + const icon = document.querySelector('.lab-global-search > svg'); + const input = document.querySelector('.lab-global-search input'); + for (const width of [320,390]) { + assert.equal(winner(icon, 'display', [base, composition, shell], width)?.value, 'block'); + assert.equal(winner(input, 'color', [base, composition, shell], width)?.value, 'transparent'); + } + assert.equal(declarations(shell, '.open-lab.lab-app-shell .lab-global-search:focus-within').position, 'absolute'); + assert.equal(declarations(shell, '.open-lab.lab-app-shell .lab-global-search:focus-within input').color, 'var(--lab-ink)'); +}); +test('app-wide shell headings stay compact so activity, not display type, leads the screen', () => { + const size = selector => parseFloat(declarations(shell, selector)['font-size']); + assert.ok(size('.open-lab.lab-app-shell h1') <= 22, 'Shell h1 still uses marketing display type'); + assert.ok(size('.open-lab.lab-app-shell h2') <= 18); + assert.ok(size('.open-lab.lab-app-shell h3') <= 16); + assert.ok(size('.open-lab.lab-app-shell .lab-brand > span') <= 18); + assert.ok(size('.open-lab.lab-app-shell .lab-workbench-heading h1') <= 22); + assert.ok(size('.open-lab.lab-app-shell .lab-dialog h2') <= 20); +}); +test('feed and bench headings stay compact so activity, not display type, leads the screen', () => { + const size = selector => parseFloat(declarations(feed, selector)['font-size']); + assert.ok(size('.root h1') <= 22, 'Feed and people page h1 still uses marketing display type'); + assert.ok(size('.profileRoot :global(.lab-workbench-heading) h1') <= 22, 'My bench heading still uses marketing display type'); + assert.ok(size('.root h2') <= 18); +}); +test('bottleneck and effort workbenches stay compact inside the app', () => { + const bottleneck = css('src/components/lab/BottleneckWorkbench.css'); + const effort = css('src/components/lab/EffortBacking.css'); + const sizes = (sheet, selector) => { + const values = []; + sheet.walkRules(selector, rule => rule.walkDecls('font-size', d => values.push(d.value))); + assert.ok(values.length, `Missing ${selector}`); + return values.map(parseFloat); + }; + assert.ok(sizes(bottleneck, '.open-lab .bottleneck-workbench h1').every(n => n <= 22), 'Bottleneck h1 still uses marketing display type'); + assert.ok(sizes(bottleneck, '.open-lab .bottleneck-workbench h2').every(n => n <= 18), 'Bottleneck h2 still uses marketing display type'); + assert.ok(sizes(effort, '.effort-backing h1').every(n => n <= 22), 'Effort h1 still uses marketing display type'); + assert.ok(sizes(effort, '.effort-backing h2').every(n => n <= 18), 'Effort h2 still uses marketing display type'); +}); + +const declarations = (sheet, selector) => { + const values = {}; + sheet.walkRules(selector, rule => rule.walkDecls(d => { values[d.prop] = d.value; })); + return values; +}; +function palette(dark) { + const values = { ...declarations(shell, '.open-lab.lab-app-shell'), ...(dark ? declarations(shell, '.dark .open-lab.lab-app-shell') : {}) }; + const resolve = (name, seen = []) => { + assert.ok(!seen.includes(name), `Cyclic token ${name}`); + const value = values[name]; assert.ok(value, `Missing semantic token ${name}`); + const alias = value.match(/^var\((--[\w-]+)\)$/); + return alias ? resolve(alias[1], [...seen, name]) : value; + }; + return resolve; +} +function contrast(a, b) { + const luminance = hex => { + assert.match(hex, /^#[\da-f]{6}$/i, 'Contrast evidence requires resolved opaque source colors'); + const rgb = hex.slice(1).match(/../g).map(n => parseInt(n, 16) / 255).map(n => n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4); + return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722; + }; + const values = [luminance(a), luminance(b)].sort((a, b) => b - a); + return (values[0] + .05) / (values[1] + .05); +} +test('feed, details and composer contain no fixed theme paint or !important color patches', () => { + feed.walkDecls(d => { + if (!/^(color|background(?:-color)?|border(?:-.*)?|outline(?:-.*)?)$/.test(d.prop)) return; + assert.doesNotMatch(d.value, /#[\da-f]{3,8}\b|\b(?:white|black|rgba?|hsla?)\b/i, `${d.parent.selector} ${d.prop}: ${d.value}`); + assert.equal(!!d.important, false, `${d.parent.selector} must win through scope, not !important`); + }); +}); +for (const dark of [false, true]) { + test(`${dark ? 'dark' : 'light'} source token ratios: body, muted, links, selections, primary ink, errors and control outlines`, t => { + const token = palette(dark); + const pairs = [ + ['--lab-ink', '--lab-paper', 4.5], ['--lab-ink', '--lab-card', 4.5], + ['--lab-muted', '--lab-paper', 4.5], ['--lab-muted', '--lab-card', 4.5], + ['--lab-ink', '--lab-input', 4.5], ['--lab-muted', '--lab-input', 4.5], + ['--lab-blue', '--lab-paper', 4.5], ['--lab-blue', '--lab-card', 4.5], + ['--lab-selected-ink', '--lab-selected-bg', 4.5], + ['--lab-primary-ink', '--lab-primary', 4.5], + ['--lab-error-ink', '--lab-error-bg', 4.5], + ['--lab-error-line', '--lab-error-bg', 3], + ['--lab-control-line', '--lab-input', 3], ['--lab-control-line', '--lab-paper', 3], + ['--lab-selected-line', '--lab-selected-bg', 3], ['--lab-selected-line', '--lab-card', 3], + ['--lab-blue', '--lab-input', 3], + ]; + for (const [ink, background, minimum] of pairs) { + const ratio = contrast(token(ink), token(background)); + t.diagnostic(`${ink} ${token(ink)} / ${background} ${token(background)} = ${ratio.toFixed(3)}:1 (source, not browser)`); + assert.ok(ratio >= minimum, `${ink} / ${background}: ${ratio.toFixed(3)} < ${minimum}`); + } + }); +} +test('real feed/composer selectors bind readable tokens including primary and selected button ink', async () => { + const Composer = source('components/lab/feed/InventionComposer.tsx').default; + const tree = () => React.createElement(PathnameContext.Provider, { value: '/lab/' }, React.createElement(Shell, null, React.createElement(Composer, { onClose() {} }))); + const server = new JSDOM(renderToStaticMarkup(tree())).window.document; + assert.equal(server.querySelector('input[aria-label="Build title"]'), null, 'SSR must not expose an unrestored identity-scoped editor'); + assert.match(server.body.textContent, /Restoring your scoped bench/); + const dom = new JSDOM('
', {url:'http://localhost/lab/'}); + const keys = ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLDialogElement','Event','StorageEvent','localStorage','sessionStorage','self','IS_REACT_ACT_ENVIRONMENT','fetch']; + const originals = new Map(keys.map(k => [k, Object.getOwnPropertyDescriptor(globalThis, k)])); + for (const k of keys.filter(k => !['self','IS_REACT_ACT_ENVIRONMENT','fetch'].includes(k))) Object.defineProperty(globalThis, k, {value:dom.window[k], configurable:true, writable:true}); + globalThis.self = dom.window; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + globalThis.fetch = async () => new Response(JSON.stringify({canSignIn:false,canPublish:false,mode:'unconfigured'}), {headers:{'content-type':'application/json'}}); + dom.window.HTMLDialogElement.prototype.showModal = function(){ this.open = true; }; + dom.window.HTMLDialogElement.prototype.close = function(){ this.open = false; }; + sessionStorage.setItem('open-lab:welcome:v2', 'seen'); + const {createRoot} = await import('react-dom/client'); + const root = createRoot(dom.window.document.getElementById('root')); + try { + await React.act(async () => { root.render(tree()); }); + const composer = dom.window.document; + const input = composer.querySelector('input[aria-label="Build title"]'); assert.ok(input, 'Restored scoped bench must render the actual editor'); + const primary = composer.querySelector('button.primary'); assert.ok(primary); + const selected = composer.querySelector('.chips button'); assert.ok(selected); selected.setAttribute('aria-pressed', 'true'); + for (const order of [[base, composition, feed, shell], [base, composition, shell, feed]]) { + assert.equal(winner(document.querySelector('.layout'), 'color', order, 1440).value, 'var(--lab-ink)'); + assert.equal(winner(composer.querySelector('.detail'), 'color', order, 1440).value, 'var(--lab-ink)'); + assert.equal(winner(input, 'color', order, 1440).value, 'var(--lab-ink)'); + assert.equal(winner(input, 'background', order, 1440).value, 'var(--lab-input)'); + assert.equal(winner(primary, 'background', order, 1440).value, 'var(--lab-primary)'); + assert.equal(winner(primary, 'color', order, 1440).value, 'var(--lab-primary-ink)'); + assert.equal(winner(selected, 'background', order, 1440).value, 'var(--lab-selected-bg)'); + assert.equal(winner(selected, 'color', order, 1440).value, 'var(--lab-selected-ink)'); + } + assert.match(declarations(feed, '.layout input, .detail input, .detail textarea').border, /var\(--lab-control-line\)/); + const paints = []; + feed.walkRules(rule => { if (rule.selector.includes(':focus-visible')) rule.walkDecls('outline', d => paints.push(d.value)); }); + assert.ok(paints.length > 0 && paints.every(value => /2px solid var\(--lab-blue\)/.test(value)), 'Focus stays visible in both palettes'); + const errors = []; + feed.walkRules(rule => { if (rule.selector.includes('.detail [role="alert"]')) rule.walkDecls('color', d => errors.push(d.value)); }); + assert.ok(errors.includes('var(--lab-error-ink)'), 'Actual detail/composer role=alert messages need error ink too'); + } finally { + await React.act(async () => { root.unmount(); }); + dom.window.close(); + for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; } + } +}); diff --git a/scripts/lab-arcade-responsive.test.mjs b/scripts/lab-arcade-responsive.test.mjs new file mode 100644 index 00000000..baec54a0 --- /dev/null +++ b/scripts/lab-arcade-responsive.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +test('Arcade keeps its word boundary and shrinkable single-column layout on narrow screens', () => { + // Reproduced in real Chrome at a 320px device viewport: the hidden last
+ // joined Unexpected + worlds and inflated the layout viewport to 350px. + const css = readFileSync('src/components/lab/explorations/lab-explorations.module.css', 'utf8') + assert.doesNotMatch(css, /\.cabinetIntro h1 br:last-child\s*\{\s*display:\s*none/) + assert.match(css, /\.arcadeGrid\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\);\s*padding-top:\s*32px/) +}) diff --git a/scripts/lab-auth-profile.test.mjs b/scripts/lab-auth-profile.test.mjs new file mode 100644 index 00000000..513fa5e6 --- /dev/null +++ b/scripts/lab-auth-profile.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const auth = source('lib/lab-auth.tsx') +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', other = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +const origin = 'https://lab.example.org' +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin }) +const profile = { did, handle: 'science.bsky.social', displayName: 'Public Scientist', avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/bafkreia@jpeg' } +const tick = () => new Promise(r => setImmediate(r)) +function harness(loadProfile) { + let deleted + const redirects = [], session = { sub: did, did, signOut: async () => {} } + const runtime = auth.createLabAuthRuntime({ loadProfile, loadConfig: async () => config, loadClient: async (_c, cb) => { deleted = cb; return { init: async () => ({ session, state: '/lab/profile/' }) } }, location: () => ({ origin, pathname: '/lab/oauth/return/' }), replace: x => redirects.push(x) }) + return { runtime, redirects, deleted: () => deleted(did) } +} + +test('synchronous runtime session guard rejects old work immediately on logout, before any React render', async () => { + const h = harness(async () => profile) + await h.runtime.initialize() + const sdk = h.runtime.getSnapshot().oauthSession + assert.equal(h.runtime.isSessionCurrent(sdk), true) + const logout = h.runtime.logout() + assert.equal(h.runtime.isSessionCurrent(sdk), false) + await logout +}) + +test('local injected profile enrichment follows SDK identity without any writes', async () => { + let lookedUp + const h = harness(async x => { lookedUp = x; return profile }) + await h.runtime.initialize() + assert.equal(lookedUp, did) + assert.deepEqual(h.runtime.getSnapshot().session, profile) + assert.deepEqual(h.redirects, ['/lab/profile/']) +}) + +test('profile outage/mismatched DID keep authenticated DID fallback; late lookup cannot undo logout/invalidation', async () => { + for (const load of [async () => { throw Error('private detail') }, async () => ({ ...profile, did: other })]) { + const h = harness(load); await h.runtime.initialize() + assert.deepEqual(h.runtime.getSnapshot().session, { did, handle: did }) + assert.equal(h.runtime.getSnapshot().isAuthenticated, true) + assert.equal(h.runtime.getSnapshot().error, null) + } + for (const cancel of ['logout', 'deleted']) { + let resolve + const h = harness(() => new Promise(r => { resolve = r })) + const pending = h.runtime.initialize(); await tick() + if (cancel === 'logout') await h.runtime.logout(); else h.deleted() + resolve(profile); await pending + assert.equal(h.runtime.getSnapshot().session, null) + assert.deepEqual(h.redirects, []) + } +}) + +test('official public profile reader binds exact DID and forward handle, dropping unsafe avatar URLs', async () => { + const calls = [] + let data = profile, resolved = did + const read = auth.createLabProfileReader(async (url, init) => { + const u = new URL(url); calls.push([u, init]) + return Response.json(u.pathname.endsWith('resolveHandle') ? { did: resolved } : data) + }) + assert.deepEqual(await read(did), profile) + assert.deepEqual(calls.map(([u]) => u.hostname), ['public.api.bsky.app','public.api.bsky.app']) + assert.ok(calls.every(([, init]) => init.method === 'GET' && init.credentials === 'omit' && init.redirect === 'error')) + data = { ...profile, did: other }; await assert.rejects(() => read(did), /identity/i) + data = profile; resolved = other; await assert.rejects(() => read(did), /handle/i) + resolved = did + for (const avatar of ['javascript:alert(1)', 'http://cdn.bsky.app/a', 'https://evil.example.org/a', 'https://cdn.bsky.app@evil.example.org/a', 'https://cdn.bsky.app/a?token=private', 'https://cdn.bsky.app:444/a']) { + data = { ...profile, avatar }; assert.equal((await read(did)).avatar, undefined) + } +}) diff --git a/scripts/lab-bottleneck-compact.test.mjs b/scripts/lab-bottleneck-compact.test.mjs new file mode 100644 index 00000000..b808bd96 --- /dev/null +++ b/scripts/lab-bottleneck-compact.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { source } from './velocity/test-source-loader.mjs'; +import { createRequire } from 'node:module'; +createRequire(import.meta.url).extensions['.css'] = () => {}; +const dom = new JSDOM('
', { url: 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility' }); +for (const key of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent']) globalThis[key] = dom.window[key]; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const React = await import('react'); +const { createRoot } = await import('react-dom/client'); +const Component = source('components/lab/BottleneckWorkbench.tsx').default; +test('proposal progressively reveals three coherent steps without unmounting or losing draft fields', async () => { + window.localStorage.clear(); + let root = createRoot(document.getElementById('root')); + const render = async () => React.act(async () => root.render(React.createElement(Component))); + await render(); + try { + const steps = [...document.querySelectorAll('[data-proposal-step]')]; + assert.equal(steps.length, 3, 'Three manageable steps replace eight always-expanded fields'); + assert.equal(steps[0].open, true); + assert.equal(steps[1].open, false); + assert.equal(steps[2].open, false); + assert.equal(document.querySelectorAll('.bottleneck-proposal textarea:not([readonly])').length, 8); + const hypothesis = document.getElementById('bottleneck-hypothesis'); + await React.act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(hypothesis, 'A compact synthetic proposal'); + hypothesis.dispatchEvent(new Event('input', { bubbles: true })); + }); + assert.match(document.querySelector('[data-proposal-progress]').textContent, /1 of 8/); + steps[0].open = false; steps[1].open = true; + assert.equal(hypothesis.value, 'A compact synthetic proposal'); + await React.act(async () => root.unmount()); + root = createRoot(document.getElementById('root')); + await render(); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'A compact synthetic proposal'); + assert.match(document.querySelector('[data-proposal-progress]').textContent, /1 of 8/); + assert.ok(document.querySelector('[data-baseline] details'), 'Source context is reachable through progressive disclosure'); + } finally { await React.act(async () => root.unmount()); } +}); + +test('discussion disclosure exists only in demo mode and starts closed on the source-backed case', async () => { + const identity = source('lib/lab-identity.ts'); + const provider = source('components/lab/demo/DemoCommunityProvider.tsx'); + const community = source('components/lab/demo/DemoCommunity.tsx'); + const original = [identity.useLabIdentity, provider.useDemoCommunity, community.DemoCommunityPanel]; + let isDemo = false; + identity.useLabIdentity = () => ({ session: null, isLoading: false }); + provider.useDemoCommunity = () => ({ isDemo }); + community.DemoCommunityPanel = () => React.createElement('p', null, 'Synthetic community-panel fixture'); + const Experience = source('components/lab/LabBottleneckExperience.tsx').default; + const root = createRoot(document.getElementById('root')); + try { + await React.act(async () => root.render(React.createElement(Experience))); + assert.ok(document.querySelector('.bottleneck-community-drawer') === null, 'Demo-only discussion controls must be absent with Demo off'); + isDemo = true; + await React.act(async () => root.render(React.createElement(Experience))); + const panel = document.querySelector('.bottleneck-community-drawer'); + assert.ok(panel); + assert.equal(panel.open, false); + assert.match(panel.textContent, /Illustrative community story/); + } finally { + await React.act(async () => root.unmount()); + [identity.useLabIdentity, provider.useDemoCommunity, community.DemoCommunityPanel] = original; + } +}); diff --git a/scripts/lab-bottlenecks-ui.test.mjs b/scripts/lab-bottlenecks-ui.test.mjs new file mode 100644 index 00000000..74fd3b03 --- /dev/null +++ b/scripts/lab-bottlenecks-ui.test.mjs @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { source } from './velocity/test-source-loader.mjs'; +const dom = new JSDOM('
', { url: 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility' }); +for (const k of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent']) globalThis[k] = dom.window[k]; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +globalThis.fetch = () => { throw Error('No network allowed'); }; +const React = await import('react'); +const { act } = React; +const { createRoot } = await import('react-dom/client'); +let root; +const text = () => document.body.textContent; +const button = name => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === name); +async function click(name) { const b = button(name); assert.ok(b, name); assert.equal(b.disabled, false); await act(async () => b.click()); } +async function input(id, value) { + const el = document.getElementById(id); assert.ok(el, id); + await act(async () => { + const proto = el.tagName === 'SELECT' ? dom.window.HTMLSelectElement.prototype : HTMLTextAreaElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); + el.dispatchEvent(new Event(el.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })); + }); +} +async function mount(props = {}) { + root = createRoot(document.getElementById('root')); + await act(async () => root.render(React.createElement(source('components/lab/BottleneckWorkbench.tsx').default, props))); +} +async function unmount() { await act(async () => root.unmount()); } + +test('quota failure never leaves a saved status and oversized multibyte recovery stays bounded', async () => { + const m = source('lib/lab-bottlenecks.ts'); + window.localStorage.clear(); + const d = m.createBottleneckDraft('reproducibility'); + window.localStorage.setItem(m.bottleneckDraftKey(d), '🧪'.repeat(15000)); + await mount(); + assert.match(document.querySelector('[role="alert"]').textContent, /Oversized data remains/); + assert.equal(document.querySelector('textarea[readonly]'), null); + await unmount(); + window.localStorage.clear(); await mount(); + await input('bottleneck-hypothesis', 'Saved first'); + const oldSet = dom.window.Storage.prototype.setItem; + dom.window.Storage.prototype.setItem = () => { throw Error('Quota exceeded'); }; + try { + await input('bottleneck-hypothesis', 'Not saved second'); + assert.match(document.querySelector('[role="status"]').textContent, /not saved/i); + assert.equal(JSON.parse(window.localStorage.getItem(m.bottleneckDraftKey(d))).fields.hypothesis, 'Saved first'); + } finally { dom.window.Storage.prototype.setItem = oldSet; await unmount(); } +}); + +test('incomplete drafts autosave before a mode switch or reload', async () => { + window.localStorage.clear(); + await mount(); + await input('bottleneck-hypothesis', 'Retain this incomplete idea'); + await click('Refine diagnosis'); await click('Design intervention'); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Retain this incomplete idea'); + await unmount(); await mount(); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Retain this incomplete idea'); + await unmount(); +}); + +test('callback prepares a contribution without publishing; corrupt storage is preserved and blocked storage is honest', async () => { + const m = source('lib/lab-bottlenecks.ts'); + const d = m.createBottleneckDraft('reproducibility'); + window.localStorage.clear(); + window.localStorage.setItem(m.bottleneckDraftKey(d), JSON.stringify({ ...d, sourceRevision: 'old' })); + await mount(); + assert.match(text(), /original was not overwritten/); + assert.equal(button('Save local draft').disabled, true); + assert.ok(document.querySelector('textarea[readonly]')); + await unmount(); + assert.equal(JSON.parse(window.localStorage.getItem(m.bottleneckDraftKey(d))).sourceRevision, 'old'); + window.localStorage.clear(); + let prepared; + await mount({ onPrepareContribution: initial => { prepared = initial; } }); + for (const f of m.PROPOSAL_FIELDS) await input(`bottleneck-${f.key}`, `Proposed ${f.key}.`); + await click('Prepare contribution draft'); + assert.equal(prepared.targetUrl, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility'); + assert.match(prepared.observation, /not executed or published/); + await unmount(); + const descriptor = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw Error('Storage blocked'); } }); + try { + await mount(); assert.match(text(), /storage is unavailable/); + await input('bottleneck-hypothesis', 'Still editable'); await click('Save local draft'); + assert.match(document.querySelector('[role="alert"]').textContent, /Not saved/); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Still editable'); + await unmount(); + } finally { Object.defineProperty(window, 'localStorage', descriptor); } +}); + +test('URL field filters and Back/Forward are authoritative; owners and proposal kinds stay isolated', async () => { + window.localStorage.clear(); + window.history.replaceState(null, '', '/lab/bottlenecks/?case=reproducibility&field=ai-robotics'); + await mount({ owner: 'alpha' }); + await input('bottleneck-hypothesis', 'Alpha intervention'); await click('Save local draft'); + await click('Refine diagnosis'); + assert.equal(document.getElementById('bottleneck-hypothesis').value, ''); + await input('bottleneck-hypothesis', 'Alpha refinement'); await click('Save local draft'); + await click('Design intervention'); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Alpha intervention'); + await input('bottleneck-field', 'neurotech'); + assert.match(text(), /No editorial case matches/); + assert.equal(document.querySelector('textarea'), null); + assert.equal(new URL(window.location.href).searchParams.get('field'), 'neurotech'); + await act(async () => { const done = new Promise(resolve => window.addEventListener('popstate', resolve, { once: true })); window.history.back(); await done; }); + assert.equal(document.getElementById('bottleneck-field').value, 'ai-robotics'); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Alpha intervention'); + await act(async () => { const done = new Promise(resolve => window.addEventListener('popstate', resolve, { once: true })); window.history.forward(); await done; }); + assert.match(text(), /No editorial case matches/); + await unmount(); + window.history.replaceState(null, '', '/lab/bottlenecks/?case=reproducibility'); + await mount({ owner: 'beta' }); + assert.equal(document.getElementById('bottleneck-hypothesis').value, ''); + await unmount(); +}); + +test('mounted proposal saves, survives reload, and downloads the actual version-linked JSON', async () => { + window.localStorage.clear(); + await mount(); + assert.match(text(), /Editorial starter/); + assert.match(text(), /not live/); + assert.match(text(), /No notebook has been audited/); + await click('Export proposal JSON'); + assert.match(document.querySelector('[role="alert"]').textContent, /Complete every/); + const m = source('lib/lab-bottlenecks.ts'); + for (const f of m.PROPOSAL_FIELDS) await input(`bottleneck-${f.key}`, `Proposed ${f.key}, including a negative result.`); + await click('Save local draft'); + assert.match(text(), /Yay — this is a new beginning of infinity\. Let's go explore it/); + assert.match(text(), /Saved on this device/); + await unmount(); await mount(); + assert.equal(document.getElementById('bottleneck-hypothesis').value, 'Proposed hypothesis, including a negative result.'); + let blob, filename; + const create = URL.createObjectURL, revoke = URL.revokeObjectURL, oldClick = dom.window.HTMLAnchorElement.prototype.click; + URL.createObjectURL = value => { blob = value; return 'blob:packet'; }; + URL.revokeObjectURL = () => {}; + dom.window.HTMLAnchorElement.prototype.click = function () { filename = this.download; }; + try { + await click('Export proposal JSON'); + assert.equal(filename, 'open-lab-reproducibility-intervention.json'); + const packet = JSON.parse(await blob.text()); + assert.equal(packet.target.caseId, 'reproducibility'); + assert.equal(packet.proposal.fields.risks, 'Proposed risks, including a negative result.'); + assert.equal(packet.execution, 'not-executed'); + assert.equal(packet.publication, 'not-published'); + } finally { URL.createObjectURL = create; URL.revokeObjectURL = revoke; dom.window.HTMLAnchorElement.prototype.click = oldClick; } + assert.match(document.querySelector('[data-baseline]').textContent, /When preprocessing learns from held-out data/); + await unmount(); +}); diff --git a/scripts/lab-bottlenecks.test.mjs b/scripts/lab-bottlenecks.test.mjs new file mode 100644 index 00000000..1d92eb02 --- /dev/null +++ b/scripts/lab-bottlenecks.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { source } from './velocity/test-source-loader.mjs'; + +test('the route mounts the workbench with only locally scoped responsive styles', () => { + const page = fs.readFileSync('src/app/lab/bottlenecks/page.tsx', 'utf8'); + assert.match(page, //); + const adapter = fs.readFileSync('src/components/lab/LabBottleneckExperience.tsx', 'utf8'); + assert.match(adapter, / source('lib/lab-bottlenecks.ts'); + +test('contribution preparation includes all proposal fields within the existing 1200-character contract', () => { + const m = model(); + const d = m.createBottleneckDraft('reproducibility', 'guest', 'intervention'); + for (const f of m.PROPOSAL_FIELDS) d.fields[f.key] = '🧪'.repeat(500); + const initial = m.prepareBottleneckContribution(d, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility&field=neurotech#secret'); + assert.deepEqual(Object.keys(initial).sort(), ['evidenceUrl', 'field', 'observation', 'targetUrl']); + assert.ok(initial.observation.length <= 1200); + for (const f of m.PROPOSAL_FIELDS) assert.ok(initial.observation.includes(f.label)); + assert.ok(initial.observation.includes(d.sourceRevision)); + assert.match(initial.observation, /Excerpts/); + assert.equal(initial.field, 'ai-robotics'); + assert.equal(initial.targetUrl, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility'); + assert.equal(initial.evidenceUrl, m.BOTTLENECK_CASES[0].source.url); + source('lib/lab-validation.ts').validateLabData('contribution', initial); + for (const location of ['http://localhost:3000/lab/', 'https://preview.internal/lab/', 'https://u:p@example.org/lab/', 'javascript:alert(1)']) assert.throws(() => m.prepareBottleneckContribution(d, location)); +}); + +test('a proposal targets one editorial case revision, never mutates its baseline', () => { + const m = model(); + const baseline = JSON.stringify(m.BOTTLENECK_CASES); + const d = m.createBottleneckDraft('reproducibility', 'guest', 'intervention'); + assert.equal(d.caseId, 'reproducibility'); + assert.equal(d.sourceRevision, m.BOTTLENECK_CASES[0].source.revision); + assert.equal(d.status, 'local-draft'); + assert.equal(d.fields.hypothesis, ''); + d.fields.hypothesis = 'Test whether a split-first pipeline prevents held-out data leakage.'; + assert.equal(JSON.stringify(m.BOTTLENECK_CASES), baseline); + assert.throws(() => m.createBottleneckDraft('unknown', 'guest', 'intervention'), /case/i); + assert.throws(() => m.createBottleneckDraft('reproducibility', 'guest', 'execution'), /kind/i); +}); + +test('recoverable drafts and exports reject stale, oversized, incomplete, or foreign payloads', () => { + const m = model(); + const d = m.createBottleneckDraft('reproducibility', 'guest', 'refinement'); + const key = m.bottleneckDraftKey(d); + assert.notEqual(key, m.bottleneckDraftKey({ ...d, owner: 'different' })); + assert.notEqual(key, m.bottleneckDraftKey({ ...d, kind: 'intervention' })); + assert.deepEqual(m.parseBottleneckDraft(JSON.stringify(d), d), d); + assert.throws(() => m.exportBottleneckDraft(d, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility'), /complete/i); + for (const f of m.PROPOSAL_FIELDS) d.fields[f.key] = `Proposed ${f.key}; not a measured outcome.`; + const exported = JSON.parse(m.exportBottleneckDraft(d, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility&token=secret')); + assert.equal(exported.execution, 'not-executed'); + assert.equal(exported.publication, 'not-published'); + assert.equal(exported.target.caseId, d.caseId); + assert.equal(exported.target.sourceRevision, d.sourceRevision); + assert.equal(exported.target.publicPermalink, 'https://www.plrd.org/lab/bottlenecks/?case=reproducibility'); + assert.equal(exported.provenance, 'editorial-public-source-starter'); + assert.ok(!JSON.stringify(exported).includes('secret')); + for (const change of [{ owner: 'other' }, { sourceRevision: 'old' }, { version: 2 }, { status: 'accepted' }, { workspace: 'private' }, { caseId: 'unknown' }, { fields: { ...d.fields, risks: 'x'.repeat(1001) } }]) { + assert.throws(() => m.parseBottleneckDraft(JSON.stringify({ ...d, ...change }), d)); + } + assert.throws(() => m.parseBottleneckDraft(' '.repeat(m.MAX_BOTTLENECK_BYTES + 1), d), /size/i); + assert.throws(() => m.exportBottleneckDraft({ ...d, fields: { ...d.fields, action: '' } }, 'https://www.plrd.org/lab/bottlenecks/'), /complete/i); +}); diff --git a/scripts/lab-catchup.test.mjs b/scripts/lab-catchup.test.mjs new file mode 100644 index 00000000..8563bb8e --- /dev/null +++ b/scripts/lab-catchup.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const storage = () => { const values = new Map(); return { values, getItem: k => values.get(k) ?? null, setItem: (k,v) => values.set(k,v) } } +const row = (id, text = 'Source-authored update') => ({ id, ideaId: id, title: id, text, kind: 'Help wanted', origin: 'local', author: 'Your local build', disciplines: ['math'], artifact: 'Test fixture', request: 'Check the fixture', stage: 'idea', action: 'Help build' }) + +test('needs-a-hand uses explicit requests, not every tool, question, or unvalidated result', () => { + const feed = source('lib/lab-feed-model.ts'), demo = source('lib/lab-demo.ts') + assert.equal(typeof feed.needsAHand,'function','Explicit help classifier missing') + assert.equal(feed.needsAHand(row('a')),true) + assert.equal(feed.needsAHand({...row('b'),kind:'help-wanted'}),true) + for (const kind of ['Tool','Research question','test-result','Evidence gap','note']) assert.equal(feed.needsAHand({...row('c'),kind}),false) + const rows=feed.buildFeedRows({isDemo:true,demo:demo.emptyDemoState(),drafts:[]}) + assert.ok(rows.some(r=>r.action==='View app')) + assert.ok(rows.filter(feed.needsAHand).every(r=>r.kind==='Help wanted')) + assert.ok(!rows.some(r=>r.action==='Try prototype')) +}) + +test('catch-up records only reviewed exact revisions, persists reload, and never clears hidden updates', () => { + assert.ok(existsSync('src/lib/lab-catchup.ts'), 'Scoped catch-up model missing') + const m = source('lib/lab-catchup.ts'), s = storage(), a = row('a'), b = row('b') + assert.equal(m.loadCatchup(s,'guest','live').state.seen.length,0) + const reviewed = [m.catchupReceipt(a)] + const result = m.markCaughtUp(s,'guest','live',[a,b],reviewed) + assert.equal(result.ok,true); assert.equal(result.count,1) + const state = m.loadCatchup(s,'guest','live').state + assert.deepEqual([a,b].filter(r => !m.isCaughtUp(state,r)).map(r=>r.id),['b']) + assert.equal(m.isCaughtUp(state,row('a','Actually changed source')),false) + assert.equal(m.markCaughtUp(s,'guest','live',[b],reviewed).count,0,'A filtered-out revision is not eligible') + assert.equal(m.markCaughtUp(s,'guest','live',[row('a','Changed while drawer open')],reviewed).count,0,'Review of an older revision cannot acknowledge a new one') + assert.equal(m.loadCatchup(s,'other','live').state.seen.length,0) + assert.equal(m.loadCatchup(s,'guest','demo').state.seen.length,0) +}) + +test('F1: acknowledging then returning evidence reopens only the exact source and persists the new acknowledgement', () => { + const feed=source('lib/lab-feed-model.ts'), demo=source('lib/lab-demo.ts'), bench=source('lib/lab-inventions.ts'), m=source('lib/lab-catchup.ts'), s=storage() + const args={isDemo:true,demo:demo.emptyDemoState(),drafts:[]} + const before=feed.buildFeedRows(args), first=before[0], oldReceipt=m.catchupReceipt(first) + const task={id:first.ideaId,sourceId:first.ideaId,title:first.title,request:first.request,artifact:first.artifact,artifactUrl:first.artifactUrl||''} + assert.equal(bench.changeBench(s,'guest','demo',{type:'take-task',task}).ok,true) + assert.equal(m.markCaughtUp(s,'guest','demo',before,before.map(m.catchupReceipt)).count,before.length) + const history=s.getItem(m.catchupKey('guest','demo')) + const result={note:'Synthetic counterexample found',outcome:'did-not-work',artifactUrl:'https://example.org/failure'} + assert.equal(bench.changeBench(s,'guest','demo',{type:'return-result',id:task.id,result}).ok,true) + const saved=bench.loadBench(s,'guest','demo').state + assert.deepEqual(saved.tasks,[{...task,result}],'Saved task snapshot is unchanged') + const after=feed.buildFeedRows({...args,updates:saved.updates,tasks:saved.tasks}), updated=after.find(r=>r.id===first.id) + assert.equal(after.length,before.length) + assert.notDeepEqual(m.catchupReceipt(updated),oldReceipt,'F1: returned evidence must change the acknowledged source receipt') + assert.equal(s.getItem(m.catchupKey('guest','demo')),history,'Returning evidence must not rewrite any acknowledgement history') + const state=m.loadCatchup(s,'guest','demo').state + assert.deepEqual(after.filter(r=>!m.isCaughtUp(state,r)).map(r=>r.id),before.filter(r=>r.ideaId===first.ideaId).map(r=>r.id)) + assert.equal(m.markCaughtUp(s,'guest','demo',[updated],[oldReceipt]).count,0,'Stale review cannot acknowledge the new result') + assert.equal(m.markCaughtUp(s,'guest','demo',[updated],[m.catchupReceipt(updated)]).count,1) + const reloaded=feed.buildFeedRows({...args,...bench.loadBench(s,'guest','demo').state}) + assert.equal(m.isCaughtUp(m.loadCatchup(s,'guest','demo').state,reloaded.find(r=>r.id===first.id)),true) + assert.equal(m.loadCatchup(s,'guest','demo').state.seen.length,before.length) + assert.equal(m.loadCatchup(s,'other','demo').state.seen.length,0) + assert.equal(m.loadCatchup(s,'guest','live').state.seen.length,0) +}) + +test('catch-up writer preserves a full history instead of producing an unreadable 1001-entry store', () => { + const m = source('lib/lab-catchup.ts'), s = storage(), key = m.catchupKey('guest','live') + const rows = Array.from({length:1000},(_,i)=>row(`item-${i}`)) + assert.equal(m.markCaughtUp(s,'guest','live',rows,rows.map(m.catchupReceipt)).ok,true) + assert.equal(m.loadCatchup(s,'guest','live').state.seen.length,1000) + const original = s.getItem(key), extra = row('overflow') + const result = m.markCaughtUp(s,'guest','live',[extra],[m.catchupReceipt(extra)]) + assert.equal(result.ok,false,'A full store must not be replaced with data the loader rejects') + assert.equal(result.count,0); assert.match(result.error,/limit|full/i) + assert.equal(s.getItem(key),original) + const changed = row('item-0','A revised source at capacity') + assert.equal(m.markCaughtUp(s,'guest','live',[changed],[m.catchupReceipt(changed)]).ok,true) + assert.equal(m.loadCatchup(s,'guest','live').error,'') + assert.equal(m.isCaughtUp(m.loadCatchup(s,'guest','live').state,changed),true) +}) + +test('catch-up reader and writer share UTF-8 envelope and receipt schema limits without destructive repair', () => { + const m = source('lib/lab-catchup.ts'), key = m.catchupKey('guest','live'), s = storage() + const large = Array.from({length:4},(_,i)=>row(`large-${i}`,'界'.repeat(100000))) + assert.equal(m.markCaughtUp(s,'guest','live',large.slice(0,3),large.map(m.catchupReceipt)).ok,true) + const original = s.getItem(key) + assert.equal(m.loadCatchup(s,'guest','live').error,'') + const result = m.markCaughtUp(s,'guest','live',large,large.map(m.catchupReceipt)) + assert.equal(result.ok,false,'UTF-8 envelope limit must be checked before writing') + assert.equal(result.count,0); assert.equal(s.getItem(key),original) + const oversized = JSON.stringify({...m.emptyCatchup('guest','live'),seen:large.map(m.catchupReceipt)}) + assert.ok(oversized.length < 1048576 && Buffer.byteLength(oversized,'utf8') > 1048576) + s.setItem(key,oversized) + assert.ok(m.loadCatchup(s,'guest','live').error,'Initial reads use the same byte bound') + assert.equal(m.markCaughtUp(s,'guest','live',[row('small')],[m.catchupReceipt(row('small'))]).ok,false) + assert.equal(s.getItem(key),oversized) + for (const invalid of [row('__proto__'),row('x'.repeat(4097)),row('bad\u0000id'),row('long','a'.repeat(131072))]) { + s.setItem(key,original) + assert.equal(m.markCaughtUp(s,'guest','live',[invalid],[m.catchupReceipt(invalid)]).ok,false,'Invalid receipt must not poison readable history') + assert.equal(s.getItem(key),original) + } + const empty = JSON.stringify(m.emptyCatchup('guest','live')) + s.setItem(key,empty.padEnd(1048576,' ')) + assert.equal(m.loadCatchup(s,'guest','live').error,'','Exact envelope limit is inclusive') + s.setItem(key,empty.padEnd(1048577,' ')) + assert.ok(m.loadCatchup(s,'guest','live').error,'Check serialized bound before parsing padding away') +}) + +test('catch-up preserves corrupt, unknown, wrong-scope and unavailable stores; failed writes never claim success', () => { + const m = source('lib/lab-catchup.ts'), a = row('a'), key = m.catchupKey('guest','live') + const valid = m.emptyCatchup('guest','live') + for (const raw of ['{broken', 'null', JSON.stringify({...valid,version:2}), JSON.stringify({...valid,owner:'other'}), JSON.stringify({...valid,extra:'future'}), JSON.stringify({...valid,seen:[{id:'a'}]}), JSON.stringify({...valid,seen:[{id:'a',revision:'v',extra:true}]}), JSON.stringify({...valid,seen:[{id:'__proto__',revision:'v'}]})]) { + const s = storage();s.setItem(key,raw) + assert.ok(m.loadCatchup(s,'guest','live').error,raw) + assert.equal(m.markCaughtUp(s,'guest','live',[a],[m.catchupReceipt(a)]).ok,false) + assert.equal(s.getItem(key),raw) + } + let writes = 0 + const unavailable={getItem(){throw Error('blocked')},setItem(){writes++}} + assert.equal(m.markCaughtUp(unavailable,'guest','live',[a],[m.catchupReceipt(a)]).ok,false);assert.equal(writes,0) + const rejected={getItem(){return null},setItem(){throw Error('quota')}} + assert.equal(m.markCaughtUp(rejected,'guest','live',[a],[m.catchupReceipt(a)]).ok,false) + const mismatch={getItem(){return null},setItem(){}} + assert.equal(m.markCaughtUp(mismatch,'guest','live',[a],[m.catchupReceipt(a)]).ok,false) + const s=storage();m.markCaughtUp(s,'guest','live',[a],[m.catchupReceipt(a)]) + m.markCaughtUp(s,'guest','live',[row('b')],[m.catchupReceipt(row('b'))]) + assert.equal(m.loadCatchup(s,'guest','live').state.seen.length,2,'Re-read current state before merging another tab’s acknowledgement') +}) diff --git a/scripts/lab-composition-ui.test.mjs b/scripts/lab-composition-ui.test.mjs new file mode 100644 index 00000000..8eb18d43 --- /dev/null +++ b/scripts/lab-composition-ui.test.mjs @@ -0,0 +1,124 @@ +import { test, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; +process.env.__NEXT_TRAILING_SLASH='true'; +const require = createRequire(import.meta.url); +require.extensions['.css'] = m => { m.exports = new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)}); }; +const dom = new JSDOM('
', {url:'https://lab.example.org/lab/'}); +for (const key of ['window','document','HTMLElement','HTMLInputElement','HTMLTextAreaElement','Event','MouseEvent','KeyboardEvent','StorageEvent','localStorage']) globalThis[key]=dom.window[key]; +window.matchMedia=()=>({matches:true,addEventListener(){},removeEventListener(){}}); +globalThis.self=window; globalThis.IS_REACT_ACT_ENVIRONMENT=true; +window.HTMLDialogElement.prototype.showModal=function(){this.open=true;}; +window.HTMLDialogElement.prototype.close=function(){this.open=false;}; +const { createRoot } = await import('react-dom/client'); +const { PathnameContext }=require('next/dist/shared/lib/hooks-client-context.shared-runtime'); +const did='did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', other='did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +let root, identity; +const auth=source('lib/lab-identity.ts'); +const Shell=source('components/lab/LabShell.tsx').default; +const click=async text=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes(text)||b.getAttribute('aria-label')===text);assert.ok(b,text);await act(()=>b.click());}; +const mount=async (C,props={})=>act(async()=>{root.render(React.createElement(PathnameContext.Provider,{value:window.location.pathname},React.createElement(C,props)));await new Promise(r=>setTimeout(r,10));}); +const fill=async(selector,value)=>{const el=document.querySelector(selector);assert.ok(el,selector);await act(()=>{Object.getOwnPropertyDescriptor(el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(el,value);el.dispatchEvent(new Event('input',{bubbles:true}));});}; +beforeEach(()=>{localStorage.clear();for(const owner of [did,other])source('lib/lab-drafts.ts').saveDraft(localStorage,'social',owner,{onboardingSkipped:true});window.history.replaceState(null,'','/lab/');identity={session:{did,handle:'real-person.example.org'},isAuthenticated:true,isLoading:false,oauthSession:null,logout:async()=>{},login:async()=>{}};mock.method(auth,'useLabIdentity',()=>identity);mock.method(globalThis,'fetch',async path=>{assert.ok(['/api/lab/feed/','/api/lab/capabilities/'].includes(path),'No ghost records API or writes');return Response.json(path.includes('feed')?{items:[],status:'empty'}:{canSignIn:false,canPublish:false,mode:'unconfigured'});});root=createRoot(document.getElementById('root'));}); +afterEach(async()=>{await act(()=>root.unmount());mock.restoreAll();}); + +test('one global demo control scopes feed and discussion while the app catalog stays source-linked',async()=>{ + mock.method(source('lib/lab-protocol.ts'),'listLabRecords',async(owner,kind)=>({authorDid:owner,kind,records:[]})); + const cases=[['components/lab/Landing.tsx','feed'],['components/lab/FeedWorkbench.tsx','feed'],['app/lab/atlas/page.tsx','duration-denominator'],['app/lab/apps/page.tsx','apps'],['app/lab/collaborate/page.tsx','split-boundary'],['app/lab/profile/page.tsx','bench']]; + for(const [file,kind] of cases){ + const Page=source(file).default;await mount(Shell,{children:React.createElement(Page)}); + const rows=document.querySelectorAll('[data-feed-row]').length; + if(kind==='feed') {assert.ok(document.querySelector('[aria-label="Mixed science feed"]'));assert.ok(rows>1);assert.equal(document.querySelector('[data-demo-community-panel]'),null);} + else if(kind==='apps') {assert.ok(document.querySelector('[aria-label="App collections"]'));assert.equal(document.querySelectorAll('[data-app-card]').length,Object.keys(source('lib/lab-app-catalog.ts').APP_CATALOG).length);assert.equal(document.querySelector('[data-demo-community-panel]'),null);} + else if(kind==='bench') {assert.ok(document.querySelector('[aria-label="Personal invention bench"]'));assert.ok(!document.querySelector('[aria-label="Fictional demo people"]'),'Bench must not append a duplicate people directory');} + else {assert.ok(document.querySelector(`[data-thread="${kind}"]`),file);assert.equal(document.querySelectorAll('[aria-label="Demo community discussion"]').length,1,file);} + if(file.includes('atlas'))assert.equal(document.querySelector('[data-thread="split-boundary"]'),null); + assert.equal(document.querySelectorAll('[data-lab-scope-control]').length,1);await act(()=>document.querySelector('[data-lab-scope-control]').click()); + await click('Show real / empty view');assert.equal(document.querySelector('[data-thread]'),null);assert.equal(document.querySelector('[aria-label="Fictional demo people"]'),null); + if(kind==='feed')assert.ok(document.querySelectorAll('[data-feed-row]').length{ + window.history.replaceState(null,'','/lab/bottlenecks/?case=reproducibility'); + const Page=source('app/lab/bottlenecks/page.tsx').default; + identity={...identity,isLoading:true};await mount(Shell,{children:React.createElement(Page)});assert.ok(!document.querySelector('#bottleneck-hypothesis'),'No owner editor during restoration'); + identity={...identity,isLoading:false};await mount(Shell,{children:React.createElement(Page)}); + assert.ok(document.querySelector('[data-thread="split-boundary"]')); + assert.ok(document.querySelector('[data-thread]').compareDocumentPosition(document.querySelector('.bottleneck-proposal')) & window.Node.DOCUMENT_POSITION_FOLLOWING); + for(const el of document.querySelectorAll('.bottleneck-fields textarea'))await fill('#'+el.id,'A bounded test with a review and stop condition.'); + await click('Prepare contribution draft');assert.ok(document.querySelector('dialog #entry-observation')); + const initial=document.querySelector('#entry-observation').value;assert.match(initial,/bounded test/); + await fill('#entry-observation','Preserve my contribution edits');await click('Cancel');await click('Prepare contribution draft');assert.equal(document.querySelector('#entry-observation').value,'Preserve my contribution edits'); + await click('Cancel');identity={...identity,session:{did:other}};await mount(Shell,{children:React.createElement(Page)});assert.equal(document.querySelector('.bottleneck-fields textarea').value,''); + await act(()=>{window.history.pushState(null,'','/lab/bottlenecks/?case=neural-measurement');window.dispatchEvent(new window.PopStateEvent('popstate'));}); + assert.ok(document.querySelector('[data-thread="duration-denominator"]'));assert.match(document.body.textContent,/only.*reproducibility|reproducibility.*only/i); + assert.ok(document.querySelector('a[href="/lab/bottlenecks/?case=reproducibility"]'));assert.equal(document.querySelector('.bottleneck-proposal'),null); +}); + +test('bench preserves public LinkedIn into review and local recovery with one editor',async()=>{ + const data={workingOn:'Source methods',lookingFor:'Reviewers',interests:['neurotech'],linkedinUrl:'https://www.linkedin.com/in/test-fixture/',scholarUrl:'https://scholar.google.com/citations?user=fixture',githubUrl:'https://github.com/fixture'}; + mock.method(source('lib/lab-protocol.ts'),'listLabRecords',async(owner,kind)=>({authorDid:owner,kind,records:kind==='profile'?[{uri:`at://${owner}/org.plresearch.lab.profile/self`,cid:'reviewed-cid',kind,authorDid:owner,data,pds:'https://pds.example.org'}]:[]})); + const Page=source('components/lab/ProfileWorkbench.tsx').default; + identity={...identity,isLoading:true};await mount(Shell,{children:React.createElement(Page)});assert.ok(!document.querySelector('.lab-profile-card')); + identity={...identity,isLoading:false};await mount(Shell,{children:React.createElement(Page)}); + assert.ok(document.querySelector(`a[href="${data.linkedinUrl}"]`));assert.ok(document.querySelector('a[href="/lab/onboarding/#profile-completion"]')); + await click('Edit profile draft');assert.equal(document.querySelectorAll('#entry-linkedinUrl').length,1);assert.equal(document.querySelector('#entry-linkedinUrl').value,data.linkedinUrl); + await click('Review draft');assert.match(document.querySelector('.lab-record-preview').textContent,/linkedin.com\/in\/test-fixture/);assert.match(document.querySelector('.lab-record-preview').textContent,/reviewed-cid/); + await click('Cancel');await click('Edit profile draft');assert.equal(document.querySelector('#entry-linkedinUrl').value,data.linkedinUrl); +}); + +test('onboarding uses the existing real identity provider and waits during restoration',async()=>{ + const authModule=source('lib/lab-auth.tsx'); + mock.method(authModule,'useLabAuth',()=>identity); + mock.method(authModule,'LabAuthProvider',()=>{throw Error('No nested auth provider allowed');}); + const Page=source('app/lab/onboarding/page.tsx').default; + identity={...identity,isLoading:true};await mount(Page);assert.ok(!document.querySelector('form')); + identity={...identity,isLoading:false};await mount(Page);assert.match(document.body.textContent,/Save starting choices/);assert.match(document.body.textContent,/Save profile locally/); +}); + +test('feed reads the explicit DID through the real notebook adapter and isolates late accounts',async()=>{ + const protocol=source('lib/lab-protocol.ts');let finish;const reads=[]; + mock.method(protocol,'listLabRecords',async(owner,kind,options)=>{ + reads.push({owner,kind,options}); + if(owner===did) await new Promise(r=>{ if(kind==='note')finish=r; else r(); }); + const data={text:owner===did?'LATE OLD ACCOUNT':'Current account note',field:'neurotech',postType:'question'}; + const record={uri:`at://${owner}/org.plresearch.lab.note/one`,cid:'test-cid',kind:'note',authorDid:owner,data,pds:'https://pds.example.org',provenance:'pds-https-unverified-signature'}; + return {authorDid:owner,kind,records:kind==='note'?[record]:[],cursor:kind==='note'?'next':undefined}; + }); + const Feed=source('components/lab/FeedWorkbench.tsx').default; + await mount(Feed);assert.equal(reads.length,0,'The default feed does not eagerly read notebook records');await click('Public sources');assert.equal(reads.length,5);assert.ok(reads.every(r=>r.owner===did)); + identity={...identity,session:{did:other}};await mount(Feed);await click('Your records'); + assert.match(document.body.textContent,/Current account note/); + assert.match(document.body.textContent,/up to 30 per collection.*more exist/); + assert.match(document.body.textContent,/Current PDS: https:\/\/pds.example.org/); + assert.ok(document.querySelector(`a[href="/lab/record/?uri=${encodeURIComponent(`at://${other}/org.plresearch.lab.note/one`)}"]`)); + await act(()=>finish());assert.doesNotMatch(document.body.textContent,/LATE OLD ACCOUNT/); +}); + +test('composed shell has one demo control and top-right bell, live switch preserves real drafts and hides restoring inbox',async()=>{ + const drafts=source('lib/lab-drafts.ts');drafts.saveDraft(localStorage,'note',did,{text:'Real private local draft'}); + const before=localStorage.getItem(drafts.draftKey('note',did)); + const Page=source('app/lab/demo/page.tsx').default; + await mount(Shell,{children:React.createElement(Page)}); + assert.equal(document.querySelectorAll('[data-lab-scope-control]').length,1);assert.equal(document.querySelectorAll('[aria-label="Community preview mode"]').length,0);await act(()=>document.querySelector('[data-lab-scope-control]').click());assert.equal(document.querySelectorAll('[aria-label="Community preview mode"]').length,1); + assert.equal(document.querySelectorAll('[aria-label^="Demo notifications:"]').length,1); + assert.ok(document.querySelector('.lab-header-actions [aria-label^="Demo notifications:"]')); + assert.equal(document.querySelectorAll('[aria-label^="Your next actions"]').length,0); + assert.match(document.querySelector('.lab-header-actions').textContent,/real-person/); + await click('Show real / empty view'); + assert.equal(document.querySelectorAll('[aria-label^="Demo notifications:"]').length,0); + assert.equal(document.querySelectorAll('.lab-header-actions [aria-label^="Your next actions"]').length,1); + assert.doesNotMatch(document.body.textContent,/Mira Sen|fictional points/); + assert.equal(localStorage.getItem(drafts.draftKey('note',did)),before); + identity={...identity,isLoading:true};await mount(Shell,{children:React.createElement(Page)}); + assert.equal(document.querySelectorAll('[aria-label^="Your next actions"]').length,0); + assert.ok(document.querySelector('nav a[href="/lab/bottlenecks/"]')); + assert.ok(document.querySelector('a[href="/lab/profile/"]')); + assert.ok(document.querySelector('a[href="/lab/efforts/"]')); +}); diff --git a/scripts/lab-composition.test.mjs b/scripts/lab-composition.test.mjs new file mode 100644 index 00000000..7f3a482b --- /dev/null +++ b/scripts/lab-composition.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { JSDOM } from 'jsdom' +import { createRequire } from 'node:module' +import { source } from './velocity/test-source-loader.mjs' +// Node does not load stylesheets; layout/contrast are separately checked in Chrome. +createRequire(import.meta.url).extensions['.css'] = module => { module.exports = {} } +process.env.__NEXT_TRAILING_SLASH = 'true' + +test('the home uses a single compact workshop invitation above the actual feed', () => { + const Provider=source('lib/lab-auth.tsx').LabAuthProvider + const html = renderToStaticMarkup(React.createElement(Provider,null,React.createElement(source('components/lab/Landing.tsx').default))) + const doc = new JSDOM(html).window.document + const invitation=doc.querySelector('[aria-label="Workshop invitation"]') + assert.ok(invitation.querySelector('button[aria-label="What are you making? Show a build →"]')) + assert.match(invitation.textContent, /Catch up.*Find what changed/) + assert.equal(doc.querySelector('.lab-welcome'),null) + assert.ok(doc.querySelector('[aria-label="Mixed science feed"]'));assert.equal(doc.querySelector('.lab-hero'),null) +}) + +test('login preserves only known demo discussion contexts without normalizing the original', () => { + const {safeLabReturnTo}=source('lib/lab-oauth-config.ts'); + for(const id of ['split-boundary','duration-denominator','receipt-permission']){ + const path=`/lab/demo/?discussion=${id}#demo-discussion-${id}`; + assert.equal(safeLabReturnTo(path),path); + } + for(const path of ['/lab/demo/?discussion=unknown','/lab/feed/?discussion=split-boundary','/lab/demo/?discussion=split-boundary&redirect=evil','/lab/demo/?discussion=split-boundary&discussion=split-boundary','/lab/demo/?discussion=split-boundary#demo-discussion-duration-denominator','/lab/%64emo/?discussion=split-boundary','/lab/demo/?discussion=split%252dboundary'])assert.equal(safeLabReturnTo(path),'/lab/',path); +}); + +test('composition responsive overrides outrank base styles regardless of CSS import order', async () => { + const {readFileSync}=await import('node:fs'); + const css=readFileSync('src/components/lab/lab-app-shell.css','utf8'); + const shell=readFileSync('src/components/lab/LabShell.tsx','utf8'); + assert.match(shell,/className="open-lab lab-composed lab-app-shell"/); + assert.match(css,/\.open-lab\.lab-app-shell/); + assert.match(css,/min-height: 44px/);assert.match(css,/@media/); +}); + +test('LinkedIn survives profile defaults and public draft preparation with format validation', () => { + const entry = source('lib/lab-entry.ts') + const values = {...entry.entryDefaults('profile'), workingOn:'Reproducibility tools', interests:'ai-robotics', lookingFor:'A reviewer', linkedinUrl:'https://www.linkedin.com/in/demo-person/'} + assert.equal(entry.entryDefaults('profile').linkedinUrl, '') + assert.equal(entry.entryPayload('profile', values).linkedinUrl, values.linkedinUrl) + assert.equal(entry.validateEntry('profile', values).linkedinUrl, undefined) + assert.ok(entry.validateEntry('profile', {...values, linkedinUrl:'https://example.org/not-linkedin'}).linkedinUrl) +}) + +test('the collaboration route contains the actual researcher and reviewer return workbench', () => { + const Page = source('app/lab/collaborate/page.tsx').default + const doc = new JSDOM(renderToStaticMarkup(React.createElement(Page))).window.document + assert.ok(doc.querySelector('textarea[aria-label="Research return JSON"]')) + assert.ok(doc.querySelector('textarea[aria-label="Review return JSON"]')) + assert.ok(doc.querySelector('#evidence-pilot')) +}) diff --git a/scripts/lab-connection-cancellation.test.mjs b/scripts/lab-connection-cancellation.test.mjs new file mode 100644 index 00000000..5e2469db --- /dev/null +++ b/scripts/lab-connection-cancellation.test.mjs @@ -0,0 +1,279 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const dom = new JSDOM('
', { url: 'https://lab.example.org/lab/people/' }) +for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Event', 'localStorage']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) +globalThis.IS_REACT_ACT_ENVIRONMENT = true +globalThis.fetch = async () => { throw Error('Live network forbidden in F1 local tests') } +const React = await import('react'), { createRoot } = await import('react-dom/client') +const req = createRequire(import.meta.url) +const sdk = createRequire(req.resolve('@atproto/oauth-client-browser')) +const { OAuthSession } = sdk('@atproto/oauth-client') +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', subject = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +const origin = 'https://lab.example.org', collection = 'app.bsky.graph.follow' +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin }) +const deferred = () => { let resolve; const promise = new Promise(r => resolve = r); return { promise, resolve } } +const drain = () => new Promise(r => setImmediate(r)) +function storage() { const m = new Map(); return { getItem: k => m.get(k) ?? null, setItem: (k,v) => m.set(k,v), removeItem: k => m.delete(k) } } + +// Portable version of the critic's installed-SDK F1 repro, first observed RED +// unchanged at the proof boundary. This fixture uses the REAL OAuthSession +// constructor (which installs the real dpopFetchWrapper), and the same supported +// fetch option supplied to BrowserOAuthClient.load by the app. Only keys/token +// storage/revocation and the final HTTP boundary are synthetic. A raw signal-blind +// stub bypassing that configured boundary is not the app's HTTP transport. +export async function sdkHarness({ stage = 'proof', action = 'create', store = storage() } = {}) { + const gate = deferred(), entered = deferred(), panel = new AbortController() + const calls = [], rows = new Map() + const existing = { uri: `at://${did}/${collection}/3labexisting2a`, cid, value: { $type: collection, subject, createdAt: '2026-01-01T00:00:00Z' } } + if (action === 'delete') rows.set(existing.uri, existing) + let session, deleted, mutating = false, paused = false, requestSignal, refreshes = 0 + const posts = () => calls.filter(c => c.method === 'POST') + async function pause(at) { + if (stage === at && !paused) { paused = true; entered.resolve(); await gate.promise } + } + const rawFetch = async request => { + assert.equal(new URL(request.url).origin, 'https://pds.example.org') + calls.push({ method: request.method, path: new URL(request.url).pathname, aborted: request.signal.aborted }) + const path = new URL(request.url).pathname + if (path.endsWith('listRecords')) return Response.json({ records: [...rows.values()] }) + if (path.endsWith(`${action}Record`)) { + assert.ok(store.getItem(`open-lab:connection:v1:${did}:${subject}`), 'journal exists before transmission') + if (posts().length === 1 && stage.startsWith('nonce-')) return Response.json({ error: 'use_dpop_nonce' }, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'synthetic-next-nonce' } }) + if (posts().length === 1 && stage.startsWith('token-')) return Response.json({ error: 'invalid_token' }, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }) + const body = await request.json(), uri = `at://${did}/${collection}/${body.rkey}` + if (action === 'delete') { assert.equal(body.swapRecord, cid); rows.delete(uri) } + else rows.set(uri, { uri, cid, value: body.record }) + await pause('transmitted') + return Response.json(action === 'delete' ? {} : { uri, cid }) + } + if (path.endsWith('getRecord')) { + const uri = `at://${did}/${collection}/${new URL(request.url).searchParams.get('rkey')}` + return rows.has(uri) ? Response.json(rows.get(uri)) : Response.json({ error: 'RecordNotFound' }, { status: 400 }) + } + throw Error('Unexpected synthetic transport request') + } + const sessionGetter = { + delStored: async () => deleted(did), + getSession: async (_sub, refresh) => { + if (mutating && refresh === 'auto') await pause('token') + if (mutating && refresh === true) { refreshes++; await pause('token-refresh') } + return { tokenSet: { sub: did, aud: 'https://pds.example.org', scope: `atproto repo:${collection}?action=${action}`, token_type: 'DPoP', access_token: 'SYNTHETIC-NON-CREDENTIAL' } } + }, + } + const server = { + revoke: async () => {}, + dpopKey: { algorithms: ['ES256'], bareJwk: { kty: 'EC' }, createJwt: async (_header, payload) => { + if (payload.htu.endsWith(`${action}Record`)) { + await pause('proof') + if (posts().length) { await pause('nonce-proof'); await pause('token-proof') } + } + return 'SYNTHETIC-PROOF' + } }, + serverMetadata: { dpop_signing_alg_values_supported: ['ES256'] }, + runtime: { sha256: async () => { if (mutating) await pause('hash'); return new Uint8Array(32) } }, + dpopNonces: { get: async () => { if (mutating) await pause('nonce'); return undefined }, set: async () => pause('nonce-store') }, + } + const oldFetch = globalThis.fetch + // Exercise the actual runtime-supplied final transport, not a reimplementation + // of its AbortSignal fence. This synthetic global is never a network client. + globalThis.fetch = rawFetch + const runtime = source('lib/lab-auth.tsx').createLabAuthRuntime({ + loadConfig: async () => config, + loadClient: async (_config, onDeleted, fetcher) => { + deleted = onDeleted + session = new OAuthSession(server, did, sessionGetter, fetcher ?? rawFetch) + const official = session.fetchHandler.bind(session) + session.fetchHandler = (path, init) => { mutating = path.includes(`${action}Record`); requestSignal = init?.signal; return official(path, init) } + return { init: async () => ({ session }) } + }, + location: () => ({ origin, pathname: '/lab/' }), replace: () => { throw Error('Unexpected navigation') }, + }) + try { await runtime.initialize() } finally { globalThis.fetch = oldFetch } + const deps = { isCurrent: () => runtime.isSessionCurrent(session), signal: panel.signal, loadConfig: async () => config, storage: store, lock: async (_key, work) => work(), transport: { timeoutMs: 1000 } } + const client = source('lib/lab-connections.ts').createLabConnectionClient(session, deps) + return { session, runtime, client, deps, panel, existing, calls, rows, posts, store, gate, entered, signal: () => requestSignal, refreshes: () => refreshes, invalidate: () => deleted(did) } +} +const consent = { public: true, did, subject, action: 'create' } + +async function mountPanel(h, t) { + localStorage.clear() + const identityModule = source('lib/lab-identity.ts'), authModule = source('lib/lab-auth.tsx'), connectionModule = source('lib/lab-connections.ts') + const originalIdentity = identityModule.useLabIdentity, originalProfile = authModule.createLabProfileReader, originalClient = connectionModule.createLabConnectionClient + const profileSignals = [] + identityModule.useLabIdentity = () => ({ ...h.runtime.getSnapshot(), isSessionCurrent: h.runtime.isSessionCurrent }) + authModule.createLabProfileReader = (_fetcher, signal) => async actor => { profileSignals.push(signal); return { did: actor.startsWith('did:') ? actor : subject, handle: 'target.bsky.social' } } + connectionModule.createLabConnectionClient = (session, deps) => originalClient(session, { ...h.deps, ...deps }) + const Component = source('components/lab/social/BlueskyConnections.tsx').default + const root = createRoot(document.getElementById('root')) + let unmounted = false + const render = props => React.act(async () => root.render(React.createElement(Component, props))) + const unmount = async () => { if (!unmounted) { unmounted = true; await React.act(async () => root.unmount()) } } + t.after(async () => { await unmount(); await h.runtime.logout(); h.gate.resolve(); await drain(); await drain(); identityModule.useLabIdentity = originalIdentity; authModule.createLabProfileReader = originalProfile; connectionModule.createLabConnectionClient = originalClient }) + const click = async text => { const button = [...document.querySelectorAll('button')].find(b => b.textContent === text); assert.ok(button, `Missing ${text}`); await React.act(async () => button.click()) } + return { render, unmount, click, profileSignals, agree: () => React.act(async () => document.querySelector('input[type=checkbox]').click()) } +} +for (const cancel of ['unmount', 'target-change', 'cancel-button']) { + test(`F1 mounted panel: ${cancel} aborts installed SDK proof and preserves pending target`, { timeout: 5000 }, async t => { + const h = await sdkHarness({ stage: 'proof' }), panel = await mountPanel(h, t) + await panel.render({ personDid: subject }) + await panel.agree(); await panel.click('Confirm public follow'); await h.entered.promise + const signal = h.signal() + if (cancel === 'unmount') await panel.unmount() + else if (cancel === 'target-change') await panel.render({ personDid: 'did:plc:cccccccccccccccccccccccc' }) + else await panel.click('Cancel pending work') + assert.equal(signal.aborted, true, 'panel must cancel SDK work, not merely ignore its result') + await React.act(async () => { h.gate.resolve(); await drain(); await drain() }) + assert.equal(h.posts().length, 0) + assert.ok(h.store.getItem(`open-lab:connection:v1:${did}:${subject}`)) + assert.ok(localStorage.getItem(`open-lab:connection-draft:v1:${did}`)) + if (cancel === 'cancel-button') { + assert.ok(document.body.textContent.includes('Only check the exact record')) + assert.equal(document.querySelector('input[type=checkbox]'), null) + } + }) +} +test('F1 mounted panel: editing the lookup target invalidates its lookup lifetime and confirmation', async t => { + const h = await sdkHarness({ stage: 'ok' }), panel = await mountPanel(h, t) + await panel.render({}) + const input = document.querySelector('[aria-label="Bluesky handle to find"]') + const fill = value => React.act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })) }) + await fill('target.bsky.social'); await panel.click('Find person'); await panel.agree() + const signal = panel.profileSignals.at(-1) + assert.ok(signal, 'public profile reader needs the same target lifetime') + await fill('other.bsky.social') + assert.equal(signal.aborted, true) + assert.equal(document.querySelector('input[type=checkbox]'), null) + assert.equal(h.posts().length, 0) +}) + + +for (const cancel of ['logout', 'sdk-invalidation', 'panel']) { + for (const stage of ['proof', 'hash', 'nonce', 'token', 'nonce-store', 'nonce-proof', 'token-refresh', 'token-proof']) { + test(`F1: ${cancel} during installed SDK ${stage} prevents later HTTP transmission/retry`, { timeout: 5000 }, async () => { + const h = await sdkHarness({ stage }) + const outcome = h.client.follow(subject, consent).catch(e => e) + await h.entered.promise + const before = h.posts().length + assert.equal(before, stage.includes('-') ? 1 : 0) + if (cancel === 'logout') { + const pendingLogout = h.runtime.logout() + assert.equal(h.signal().aborted, true, 'abort precedes await/React notification') + await pendingLogout + } else if (cancel === 'sdk-invalidation') h.invalidate() + else h.panel.abort(new DOMException('Panel canceled', 'AbortError')) + assert.equal(h.signal().aborted, true) + h.gate.resolve() + const error = await outcome + // Cancellation settles locally before SDK work necessarily unwinds. Drain + // it before checking transmissions: no false green from early rejection. + await drain(); await drain() + assert.equal(error.name, 'LabConnectionUnknownError') + assert.equal(h.posts().length, before, 'No subsequent public request after cancellation') + assert.ok(h.store.getItem(`open-lab:connection:v1:${did}:${subject}`), 'recovery journal retained') + }) + } +} +test('F1 cancel control is for connection work, not a false promise to cancel provider authorization', async t => { + const h = await sdkHarness({ action: 'read' }), panel = await mountPanel(h, t) + const authorization = deferred(), entered = deferred() + source('lib/lab-identity.ts').useLabIdentity = () => ({ ...h.runtime.getSnapshot(), isSessionCurrent: h.runtime.isSessionCurrent, authorizeConnection: async () => { entered.resolve(); await authorization.promise } }) + await panel.render({ personDid: subject }) + await panel.agree(); await panel.click('Confirm public follow') + await panel.click('Authorize follow permission'); await entered.promise + const hasCancel = [...document.querySelectorAll('button')].some(b => b.textContent === 'Cancel pending work') + await React.act(async () => authorization.resolve()) + assert.equal(hasCancel, false) + assert.equal(h.posts().length, 0) +}) + +test('F1 mounted panel: logout aborts public lookup synchronously, before a React rerender', async t => { + const h = await sdkHarness({ stage: 'ok' }), panel = await mountPanel(h, t) + await panel.render({ personDid: subject }) + const signal = panel.profileSignals[0] + const logout = h.runtime.logout() + assert.equal(signal?.aborted, true) + await logout +}) +test('F1 init profile enrichment captures the account lifetime before its public reads', async () => { + const gate = deferred(), entered = deferred(); let signal + const session = { sub: did, signOut: async () => {} } + const runtime = source('lib/lab-auth.tsx').createLabAuthRuntime({ + loadConfig: async () => config, loadClient: async () => ({ init: async () => ({ session }) }), + location: () => ({ origin, pathname: '/lab/' }), replace: () => {}, + loadProfile: async (_did, captured) => { signal = captured; entered.resolve(); await gate.promise; return { did, handle: 'author.bsky.social' } }, + }) + const initializing = runtime.initialize() + await entered.promise + const logout = runtime.logout() + const aborted = signal?.aborted + gate.resolve(); await logout; await initializing + assert.equal(aborted, true) + assert.equal(runtime.getSnapshot().session, null) +}) + +test('F1 profile lookup: cancellation while resolving a handle prevents the subsequent profile request', async () => { + const gate = deferred(), entered = deferred(), controller = new AbortController(), calls = [] + const reader = source('lib/lab-auth.tsx').createLabProfileReader(async (input, init) => { + calls.push(new URL(input).pathname) + if (calls.length === 1) { entered.resolve(); await gate.promise; return Response.json({ did: subject }) } + return Response.json({ did: subject, handle: 'target.bsky.social' }) + }, controller.signal) + const outcome = reader('target.bsky.social').catch(e => e) + await entered.promise; controller.abort(); gate.resolve() + assert.equal((await outcome).cause?.name, 'AbortError', 'official XRPCError retains the cancellation cause') + await drain(); await drain() + assert.equal(calls.length, 1) +}) + +test('F1: already-transmitted public write stays ambiguous and recovers read-only with no duplicate', async () => { + const h = await sdkHarness({ stage: 'transmitted' }) + const outcome = h.client.follow(subject, consent).catch(e => e) + await h.entered.promise + assert.equal(h.posts().length, 1) + h.panel.abort() + h.gate.resolve() + assert.equal((await outcome).name, 'LabConnectionUnknownError') + await drain() + const recoveredClient = source('lib/lab-connections.ts').createLabConnectionClient(h.session, { ...h.deps, signal: new AbortController().signal }) + await assert.rejects(() => recoveredClient.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + const recovered = await recoveredClient.recover(subject) + assert.equal(recovered.status, 'following') + assert.equal(recoveredClient.pending(subject), null) + assert.equal(h.posts().length, 1) +}) +for (const stage of ['proof', 'token-proof', 'transmitted']) { + test(`F1 unfollow: cancellation at ${stage} cannot dispatch a delete/retry or discard recovery`, async () => { + const h = await sdkHarness({ stage, action: 'delete' }) + const consent = { public: true, did, subject, action: 'delete', uri: h.existing.uri, expectedCid: h.existing.cid } + const outcome = h.client.unfollow(subject, consent).catch(e => e) + await h.entered.promise + const before = h.posts().length + h.panel.abort(); h.gate.resolve() + assert.equal((await outcome).name, 'LabConnectionUnknownError') + await drain(); await drain() + assert.equal(h.posts().length, before) + const fresh = source('lib/lab-connections.ts').createLabConnectionClient(h.session, { ...h.deps, signal: new AbortController().signal }) + assert.equal(fresh.pending(subject).action, 'delete') + await assert.rejects(() => fresh.unfollow(subject, consent), e => e.name === 'LabConnectionUnknownError') + if (stage === 'transmitted') { + assert.equal((await fresh.recover(subject)).status, 'not-following') + assert.equal(fresh.pending(subject), null) + } + assert.equal(h.posts().length, before) + }) +} + +for (const stage of ['ok', 'nonce-proof', 'token-proof']) { + test(`F1 control: official SDK ${stage} completes when lifecycle stays valid`, async () => { + const h = await sdkHarness({ stage }); h.gate.resolve() + const receipt = await h.client.follow(subject, consent) + assert.equal(receipt.status, 'following') + assert.equal(receipt.verification, 'pds-readback') + assert.equal(h.posts().length, stage === 'ok' ? 1 : 2) + assert.ok(h.calls.every(c => !c.aborted)) + }) +} diff --git a/scripts/lab-connection-ui.test.mjs b/scripts/lab-connection-ui.test.mjs new file mode 100644 index 00000000..4641e4fc --- /dev/null +++ b/scripts/lab-connection-ui.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const dom = new JSDOM('
', { url: 'https://lab.example.org/lab/profile/' }) +for (const key of ['window','document','navigator','HTMLElement','HTMLInputElement','Event','localStorage']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) +globalThis.IS_REACT_ACT_ENVIRONMENT = true +globalThis.fetch = async () => { throw Error('Live network forbidden in local UI tests') } +const React = await import('react'), { createRoot } = await import('react-dom/client') +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', subject = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +const target = { did: subject, handle: 'target.bsky.social', displayName: 'Public Target' } +const follow = { uri: `at://${did}/app.bsky.graph.follow/3labexisting2a`, cid: 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa', value: { $type: 'app.bsky.graph.follow', subject, createdAt: '2026-01-01T00:00:00Z' } } +const noFollow = { status: 'not-following', verification: 'pds-scan' }, following = { status: 'following', record: follow, verification: 'pds-readback' } +let identity, calls, client, profileRead +function setup() { + localStorage.clear(); calls = [] + identity = { session: { did, handle: 'author.bsky.social', displayName: 'Public Author' }, oauthSession: { sub: did, did }, isAuthenticated: true, isLoading: false, capabilities: source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: window.location.origin }), authorizeConnection: async (...args) => calls.push(['authorize', ...args]), login: async (...args) => calls.push(['login', ...args]) } + client = { inspect: async () => noFollow, pending: () => null, follow: async (...args) => { calls.push(['follow', ...args]); return following }, unfollow: async (...args) => { calls.push(['unfollow', ...args]); return noFollow }, recover: async (...args) => { calls.push(['recover', ...args]); return following } } + profileRead = async () => target + source('lib/lab-identity.ts').useLabIdentity = () => identity + source('lib/lab-auth.tsx').createLabProfileReader = () => (...args) => profileRead(...args) + source('lib/lab-connections.ts').createLabConnectionClient = () => client +} +const button = name => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === name) +const click = async name => { const el = button(name); assert.ok(el, `Missing ${name}`); await React.act(async () => el.click()) } +const agree = async () => { const el = document.querySelector('input[type=checkbox]'); assert.ok(el); await React.act(async () => el.click()) } +async function mount(props = {}) { + assert.ok(existsSync('src/components/lab/social/BlueskyConnections.tsx'), 'real connection component missing') + const Component = source('components/lab/social/BlueskyConnections.tsx').default + const root = createRoot(document.getElementById('root')) + await React.act(async () => root.render(React.createElement(Component, props))) + return { unmount: () => React.act(async () => root.unmount()), render: () => React.act(async () => root.render(React.createElement(Component, props))) } +} +test('local injected UI finds a real-profile-shaped result, identifies both DIDs, and requires exact public confirmation', async () => { + setup(); const h = await mount() + const input = document.querySelector('[aria-label="Bluesky handle to find"]'); assert.ok(input) + await React.act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input, target.handle); input.dispatchEvent(new Event('input',{bubbles:true})) }) + await click('Find person') + assert.match(document.body.textContent, /Public Target/); assert.match(document.body.textContent, /author.bsky.social/) + assert.ok(document.body.textContent.includes(did) && document.body.textContent.includes(subject)) + assert.match(document.body.textContent, /one-way/) + assert.equal(button('Confirm public follow').disabled, true) + assert.equal(calls.length, 0) + await agree(); await click('Confirm public follow') + assert.deepEqual(calls[0], ['follow', subject, { public: true, did, subject, action: 'create' }]) + assert.match(document.body.textContent, /Following on Bluesky/) + await h.unmount() +}) +test('compact connection disclosure keeps public consent visible and technical account details expandable', async () => { + setup(); const h = await mount({ personDid: subject }) + const details = document.querySelector('details[data-connection-details]') + assert.ok(details, 'Account identifiers belong in an expandable disclosure') + assert.equal(details.open, false) + assert.ok(details.textContent.includes(did) && details.textContent.includes(subject)) + assert.match(details.querySelector('summary').textContent, /Account details/) + const confirmation = document.querySelector('input[type=checkbox]').closest('label') + assert.equal(confirmation.closest('details'), null) + assert.match(confirmation.textContent, /public follow/) + assert.match(confirmation.textContent, /may be notified/) + assert.equal(button('Confirm public follow').disabled, true) + assert.equal(calls.length, 0) + await h.unmount() +}) + +test('changing a lookup handle discards the old profile and public confirmation', async () => { + setup(); const h = await mount() + const input = document.querySelector('[aria-label="Bluesky handle to find"]') + const fill = value => React.act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,value); input.dispatchEvent(new Event('input',{bubbles:true})) }) + await fill(target.handle); await click('Find person'); await agree() + assert.equal(button('Confirm public follow').disabled, false) + await fill('other.bsky.social') + assert.equal(Boolean(button('Confirm public follow')), false) + assert.equal(calls.length, 0) + await h.unmount() +}) + +test('scope escalation saves this target, returns to the draft and requires confirmation again; never auto-follows', async () => { + setup() + client.follow = async () => { throw new (source('lib/lab-connections.ts').LabConnectionPermissionError)('create') } + let h = await mount({ personDid: subject }) + await agree(); await click('Confirm public follow') + await click('Authorize follow permission') + assert.deepEqual(calls, [['authorize','create','/lab/profile/#bluesky-connections']]) + await h.unmount() + client.follow = async (...args) => { calls.push(['follow',...args]); return following } + h = await mount() + assert.match(document.body.textContent, /Public Target/) + assert.equal(button('Confirm public follow').disabled, true) + assert.equal(calls.length, 1) + await agree(); await click('Confirm public follow'); assert.equal(calls.length, 2) + await h.unmount() +}) +test('an already-authorized ambiguous follow restores its exact target after reload and offers recovery only', async () => { + setup() + client.follow = async () => { client.pending = () => ({ uri: follow.uri, subject, action: 'create' }); throw new (source('lib/lab-connections.ts').LabConnectionUnknownError)(follow.uri) } + let h = await mount({ personDid: subject }) + await agree(); await click('Confirm public follow'); await h.unmount() + h = await mount() + assert.ok(button('Check exact public outcome'), 'lost-response target must survive reload even without scope escalation') + assert.equal(Boolean(button('Confirm public follow')), false) + assert.equal(calls.length, 0) + await h.unmount() +}) + +test('unknown outcome offers read-only recovery, and unfollow passes exact reviewed CID', async () => { + setup(); client.pending = () => ({ uri: follow.uri, subject, action: 'create' }) + let h = await mount({ personDid: subject }) + assert.equal(Boolean(button('Confirm public follow')), false) + await click('Check exact public outcome') + assert.equal(calls[0][0], 'recover') + await h.unmount() + setup(); client.inspect = async () => following + h = await mount({ personDid: subject }) + await agree(); await click('Confirm public unfollow') + assert.deepEqual(calls[0], ['unfollow', subject, { public: true, did, subject, action: 'delete', uri: follow.uri, expectedCid: follow.cid }]) + await h.unmount() +}) +test('sign-out resets confirmation and a late public lookup cannot install a profile after unmount', async () => { + setup(); let resolve + profileRead = () => new Promise(r => { resolve = r }) + const h = await mount({ personDid: subject }) + await h.unmount(); await React.act(async () => resolve(target)) + assert.equal(document.body.textContent, '') + profileRead = async () => target + const next = await mount({ personDid: subject }) + await agree() + identity = { ...identity, session: null, oauthSession: null, isAuthenticated: false } + await next.render() + assert.equal(Boolean(button('Confirm public follow')), false) + assert.equal(calls.length, 0) + await next.unmount() +}) diff --git a/scripts/lab-connection-writes.test.mjs b/scripts/lab-connection-writes.test.mjs new file mode 100644 index 00000000..e5feb2ce --- /dev/null +++ b/scripts/lab-connection-writes.test.mjs @@ -0,0 +1,155 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', subject = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const origin = 'https://lab.example.org', collection = 'app.bsky.graph.follow' +globalThis.window = { location: { origin } } +const consent = { public: true, did, subject, action: 'create' } +const storage = () => { const m = new Map(); return { getItem: k => m.get(k) ?? null, setItem: (k,v) => m.set(k,v), removeItem: k => m.delete(k) } } +function harness(options = {}) { + assert.ok(existsSync('src/lib/lab-connections.ts'), 'native Bluesky connection implementation missing') + const api = source('lib/lab-connections.ts') + const rows = new Map((options.initial ?? []).map(r => [r.uri,r])) + const calls = [], store = options.storage ?? storage() + let current = true, mode = options.mode + const session = { sub: did, did, getTokenInfo: async () => ({ sub: did, aud: 'https://pds.example.org', scope: options.scope ?? 'atproto repo:app.bsky.graph.follow?action=create&action=delete' }), fetchHandler: async (path, init) => { + const u = new URL(path, 'https://pds.example.org'), method = u.pathname.split('.').at(-1) + const body = init?.body ? await new Response(init.body).json() : null + calls.push({ u, method, init, body }) + if (method === 'listRecords') return Response.json({ records: [...rows.values()], ...(options.cursor ? { cursor: options.cursor } : {}) }) + const uri = `at://${did}/${collection}/${body?.rkey ?? u.searchParams.get('rkey')}` + if (method === 'createRecord') { + if (mode !== 'uncommitted') rows.set(uri, { uri, cid, value: body.record }) + if (mode === 'lost' || mode === 'uncommitted') throw Error('Synthetic lost response') + if (mode === 'hanging') return new Promise(() => {}) + return Response.json({ uri, cid }) + } + if (method === 'deleteRecord') { rows.delete(uri); return Response.json({}) } + if (mode === 'read-outage' && calls.some(c => c.method === 'createRecord' || c.method === 'deleteRecord')) return Response.json({ error: 'InternalServerError' }, { status: 500 }) + const row = rows.get(uri) + return row ? Response.json({ ...row, ...(options.readPatch ?? {}) }) : Response.json({ error: 'RecordNotFound' }, { status: 400 }) + } } + // Leave room for cold SDK/body-reader scheduling under shared CI load; the + // hanging-write fixture still exercises the real bounded deadline below. + const deps = { loadConfig: async () => ({ ...source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin }), ...(options.configPatch ?? {}) }), storage: store, isCurrent: () => current, lock: async (_key, work) => work(), transport: { timeoutMs: 500 } } + const client = api.createLabConnectionClient(session, deps) + return { api, client, session, deps, calls, rows, store, setMode: m => { mode = m }, invalidate: () => { current = false } } +} +test('ambiguous create persists exact recovery across client recreation, never automatically retries or claims absence', async () => { + for (const mode of ['lost', 'uncommitted', 'hanging', 'read-outage']) { + const h = harness({ mode }) + await assert.rejects(() => h.client.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + const pending = h.client.pending(subject) + assert.equal(pending.action, 'create') + const again = h.api.createLabConnectionClient(h.session, h.deps) + await assert.rejects(() => again.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + assert.equal(h.calls.filter(c => c.method === 'createRecord').length, 1) + h.setMode('ok') + if (mode === 'uncommitted') { + await assert.rejects(() => again.recover(subject), e => e.name === 'LabConnectionUnknownError') + assert.ok(again.pending(subject), 'absence is not proof a delayed create cannot commit') + } else { + const recovered = await again.recover(subject) + assert.equal(recovered.status, 'following'); assert.equal(recovered.record.uri, pending.uri) + assert.equal(again.pending(subject), null) + } + assert.equal(h.calls.filter(c => c.method === 'createRecord').length, 1) + } +}) + +test('own unfollow checks reviewed URI/CID, CAS deletes, and exact not-found readback; never deletes others', async () => { + const h = harness({ initial: [existing] }) + const reviewed = { public: true, did, subject, action: 'delete', uri: existing.uri, expectedCid: cid } + for (const patch of [{ uri: existing.uri.replace(did,subject) }, { expectedCid: 'stale' }, { subject: did }]) { + await assert.rejects(() => h.client.unfollow(subject, { ...reviewed, ...patch })) + assert.equal(h.calls.filter(c => c.method === 'deleteRecord').length, 0) + } + const receipt = await h.client.unfollow(subject, reviewed) + assert.equal(receipt.status, 'not-following') + assert.equal(receipt.verification, 'pds-record-not-found') + assert.equal(h.calls.find(c => c.method === 'deleteRecord').body.swapRecord, cid) + const outage = harness({ initial: [existing], mode: 'read-outage' }) + await assert.rejects(() => outage.client.unfollow(subject, reviewed), e => e.name === 'LabConnectionUnknownError') + await assert.rejects(() => outage.client.unfollow(subject, reviewed), e => e.name === 'LabConnectionUnknownError') + outage.setMode('ok') + assert.equal((await outage.client.recover(subject)).status, 'not-following') + assert.equal(outage.calls.filter(c => c.method === 'deleteRecord').length, 1) +}) + +test('incomplete pagination, blocked recovery storage, lost identity and readback mismatch cannot produce a new success', async () => { + const looping = harness({ cursor: 'same' }); await assert.rejects(() => looping.client.follow(subject, consent), /cursor/) + assert.equal(looping.calls.filter(c => c.body).length, 0) + const blocked = harness({ storage: { getItem: () => null, setItem: () => { throw Error('blocked') }, removeItem: () => {} } }) + await assert.rejects(() => blocked.client.follow(subject, consent), /storage|recovery/i) + assert.equal(blocked.calls.filter(c => c.body).length, 0) + const signedOut = harness(); signedOut.invalidate() + await assert.rejects(() => signedOut.client.follow(subject, consent), /signed out/) + assert.equal(signedOut.calls.length, 0) + const mismatch = harness({ readPatch: { cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } }) + await assert.rejects(() => mismatch.client.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + await assert.rejects(() => mismatch.client.recover(subject), e => e.name === 'LabConnectionUnknownError') +}) + +const existing = { uri: `at://${did}/${collection}/3labexisting2a`, cid, value: { $type: collection, subject, createdAt: '2026-01-01T00:00:00.000Z' } } + +test('local security probes: oversized/mismatched readback, concurrent action, logout mid-preflight, and unavailable Web Lock fail safely', async () => { + for (const readPatch of [{ uri: existing.uri.replace(did, subject) }, { value: { ...existing.value, subject: did } }, { value: { ...existing.value, extra: 'x'.repeat(1_048_576) } }]) { + const h = harness({ readPatch }); await assert.rejects(() => h.client.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + assert.ok(h.client.pending(subject)); assert.equal(h.calls.filter(c => c.method === 'createRecord').length, 1) + } + const concurrent = harness({ mode: 'hanging' }) + const first = assert.rejects(() => concurrent.client.follow(subject, consent), e => e.name === 'LabConnectionUnknownError') + await new Promise(r => setImmediate(r)) + await assert.rejects(() => concurrent.client.follow(subject, consent), /already in progress/) + await first + assert.equal(concurrent.calls.filter(c => c.method === 'createRecord').length, 1) + const logout = harness(), original = logout.session.fetchHandler + logout.session.fetchHandler = async (...args) => { const response = await original(...args); logout.invalidate(); return response } + await assert.rejects(() => logout.client.follow(subject, consent), /signed out/) + assert.equal(logout.calls.filter(c => c.body).length, 0) + const noLock = harness() + const unlocked = noLock.api.createLabConnectionClient(noLock.session, { ...noLock.deps, lock: undefined }) + const nav = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true }) + try { await assert.rejects(() => unlocked.follow(subject, consent), /browser|lock/i) } + finally { if (nav) Object.defineProperty(globalThis, 'navigator', nav); else delete globalThis.navigator } + assert.equal(noLock.calls.length, 0) +}) + +test('local official-SDK transport creates a native public follow only after consent and exact latest readback', async () => { + const h = harness() + const receipt = await h.client.follow(subject, consent) + assert.equal(receipt.status, 'following') + assert.equal(receipt.verification, 'pds-readback') + assert.equal(receipt.record.value.subject, subject) + const write = h.calls.find(c => c.method === 'createRecord') + assert.equal(write.body.repo, did); assert.equal(write.body.collection, collection) + assert.equal(write.body.validate, true) + assert.match(write.body.rkey, /^[234567abcdefghijklmnopqrstuvwxyz]{13}$/) + assert.deepEqual(Object.keys(write.body.record).sort(), ['$type','createdAt','subject']) + assert.equal(h.calls.at(-1).method, 'getRecord') + assert.equal(h.calls.at(-1).u.searchParams.has('cid'), false) +}) + +test('prior native follow detection returns exact existing own record without creating a duplicate', async () => { + const h = harness({ initial: [existing] }) + const receipt = await h.client.follow(subject, consent) + assert.equal(receipt.record.uri, existing.uri) + assert.equal(h.calls.filter(c => c.body).length, 0) +}) + +test('missing exact consent, scope, own DID, origin or capability fails before any PDS mutation', async () => { + for (const bad of [undefined, {}, { ...consent, public: false }, { ...consent, subject: did }, { ...consent, did: subject }, { ...consent, action: 'delete' }]) { + const h = harness(); await assert.rejects(() => h.client.follow(subject, bad)); assert.equal(h.calls.length, 0) + } + for (const opts of [{ scope: 'atproto' }, { scope: 'atproto repo:app.bsky.feed.post?action=create' }, { configPatch: { canConnect: false } }, { configPatch: { origin: 'https://wrong.example.org' } }]) { + const h = harness(opts); await assert.rejects(() => h.client.follow(subject, consent)); assert.equal(h.calls.length, 0) + } + const h = harness(); h.session.did = subject + await assert.rejects(() => h.client.follow(subject, consent)); assert.equal(h.calls.length, 0) + for (const target of [did, '@someone.bsky.social', 'https://pds.example.org', 'did:web:localhost', 'did:plc:short']) { + const h = harness(); await assert.rejects(() => h.client.follow(target, { ...consent, subject: target })); assert.equal(h.calls.length, 0) + } +}) diff --git a/scripts/lab-daily-shell-layout.test.mjs b/scripts/lab-daily-shell-layout.test.mjs new file mode 100644 index 00000000..cdc14f7c --- /dev/null +++ b/scripts/lab-daily-shell-layout.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import postcss from 'postcss' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { JSDOM } from 'jsdom' +import { createRequire } from 'node:module' +import { source } from './velocity/test-source-loader.mjs' +createRequire(import.meta.url).extensions['.css'] = module => { module.exports = {} } + +test('returning-reader instructions are available on demand, not an extra paragraph before every update', () => { + const Daily = source('components/lab/feed/DailyCatchup.tsx').default + const daily = { state: {}, ready: true, isReviewed: () => false, notice: '', error: '' } + const html = renderToStaticMarkup(React.createElement(Daily, { daily, rows: [], view: 'unread', setView: () => {}, hasFollows: true, isDemo: true, status: 'ready', followedCount: 0, onFollowing: () => {} })) + const doc = new JSDOM(html).window.document + assert.match(doc.querySelector('details').textContent, /Unread means not yet acknowledged here/) + assert.ok([...doc.querySelectorAll('section > p')].every(p => !p.textContent.includes('Unread means'))) +}) +const shell=()=>postcss.parse(readFileSync('src/components/lab/lab-app-shell.css','utf8')) +const feed=()=>postcss.parse(readFileSync('src/components/lab/feed/feed.module.css','utf8')) +const declarations=(root,selector)=>{const out={};root.walkRules(selector,r=>r.walkDecls(d=>out[d.prop]=d.value));return out} +test('daily surface uses the available width and separates white stream from tinted context',()=>{ + assert.equal(declarations(shell(),'.open-lab.lab-app-shell')['--lab-content-width'],'1600px','Wide working canvas missing') + assert.equal(declarations(feed(),'.root')['max-width'],'var(--lab-content-width, 1600px)') + assert.equal(declarations(feed(),'.stream').background,'var(--lab-card)') + assert.equal(declarations(feed(),'.context').background,'var(--lab-context-bg)') + assert.match(declarations(feed(),'.layout')['grid-template-columns'],/minmax\(0,\s*1fr\)/) + assert.match(declarations(feed(),'.row')['border-top'],/var\(--lab-line\)/) + const css=readFileSync('src/components/lab/feed/feed.module.css','utf8') + assert.match(css,/\.dailyActions/);assert.match(css,/\.reviewLine/) + assert.match(css,/@media\s*\(max-width:\s*600px\)/) +}) + +test('activity context is available on demand rather than repeated above every row action',()=>{ + const code=readFileSync('src/components/lab/feed/MixedScienceFeed.tsx','utf8') + assert.match(code,/
/,'Row provenance and tags need progressive disclosure') + assert.match(code,/Demo scenario/,'Illustrative rows retain visible provenance even when context is closed') + assert.match(code,/Explore the tech tree/) +}) diff --git a/scripts/lab-daily-shell-ui.test.mjs b/scripts/lab-daily-shell-ui.test.mjs new file mode 100644 index 00000000..90067bb6 --- /dev/null +++ b/scripts/lab-daily-shell-ui.test.mjs @@ -0,0 +1,168 @@ +import { test, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = m => { m.exports = new Proxy({}, {get: (_,p) => p === '__esModule' ? false : String(p)}) } +const dom = new JSDOM('
',{url:'https://fixture.example/lab/feed/'}) +for (const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','HTMLDialogElement','Event','MouseEvent','KeyboardEvent','StorageEvent','localStorage']) Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +globalThis.self=window;globalThis.IS_REACT_ACT_ENVIRONMENT=true +HTMLDialogElement.prototype.showModal=function(){this.open=true} +HTMLDialogElement.prototype.close=function(){this.open=false} +const React=await import('react'), {createRoot}=await import('react-dom/client') +const auth=source('lib/lab-identity.ts'), D=source('components/lab/demo/DemoCommunityProvider.tsx'), M=source('lib/lab-catchup.ts') +let root, identity +function Mode(){const d=D.useDemoCommunity();return React.createElement('button',{onClick:()=>d.setMode(d.isDemo?'live':'demo')},'Fixture mode')} +const Feed=source('components/lab/FeedWorkbench.tsx').default +const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,{storageScope:identity.session?.did||'browser'},React.createElement(Mode),React.createElement(Feed)))) +const button=label=>[...document.querySelectorAll('button')].find(b=>b.getAttribute('aria-label')===label||b.textContent.trim()===label) +const click=async label=>{const el=button(label);assert.ok(el,'Missing action: '+label);await React.act(()=>el.click())} +const rows=()=>[...document.querySelectorAll('[data-feed-row]')] +const fill=async(label,value)=>{const e=document.querySelector(`[aria-label="${label}"]`);assert.ok(e);await React.act(()=>{Object.getOwnPropertyDescriptor(e instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,value);e.dispatchEvent(new Event('input',{bubbles:true}))})} +beforeEach(()=>{localStorage.clear();window.history.replaceState(null,'','/lab/feed/');identity={isLoading:false,isAuthenticated:false,session:null};mock.method(auth,'useLabIdentity',()=>identity);mock.method(globalThis,'fetch',()=>{throw Error('Unexpected network from local daily journey')});root=createRoot(document.getElementById('root'))}) +afterEach(async()=>{await React.act(()=>root.unmount());mock.restoreAll()}) + +test('needs-a-hand narrows to actual requests and the same saved task returns a result',async()=>{ + await render();await click('Needs a hand') + assert.ok(rows().length>0);assert.ok(rows().every(r=>r.textContent.includes('Help wanted'))) + const first=rows()[0], title=first.querySelector('h2').textContent + await click(`Open details: ${title}`);await click('Save this test to My bench');await click('Close dialog') + assert.ok(document.querySelector('[aria-label="Continue your work"]'),'Saved-task continuation missing') + await click(`Return a result: ${title}`) + assert.ok(document.querySelector('[aria-label="Result note"]')) + assert.ok(document.querySelector('dialog').textContent.includes(title)) + await fill('Result note','Synthetic fixture: the declared test failed on the negative control.') + await fill('Result artifact URL','https://fixture.example/results/negative-control') + await click('Outcome: did-not-work');await click('Save result to My bench') + assert.ok(!document.querySelector('dialog')) + assert.ok(!document.querySelector('[aria-label="Continue your work"]'),'Completed task leaves the continuation queue') + const bench=source('lib/lab-inventions.ts').loadBench(localStorage,'guest','demo').state + assert.equal(bench.tasks.length,1);assert.equal(bench.tasks[0].title,title) + assert.equal(bench.tasks[0].result.outcome,'did-not-work') + assert.equal(bench.tasks[0].result.artifactUrl,'https://fixture.example/results/negative-control') + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render() + assert.ok(!document.querySelector('[aria-label="Continue your work"]')) + await click('All activity');assert.equal(rows().length,8) +}) + +test('F1 UI: acknowledge -> return -> new unread -> acknowledge -> reload without replay',async()=>{ + await render() + const title='Split-before-fit inspector', first=rows()[0].dataset.feedRow + const unrelated=rows().find(r=>r.querySelector('h2').textContent==='marimo') + assert.ok(unrelated) + await click('Reviewed: marimo');await click(`Open details: ${title}`) + await click('Save this test to My bench');await click('Close dialog');await click('Mark reviewed caught up') + assert.equal(rows().length,6);assert.ok(!rows().some(r=>r.dataset.feedRow===first)) + const bench=source('lib/lab-inventions.ts'), snapshot=bench.loadBench(localStorage,'guest','demo').state.tasks[0] + const history=localStorage.getItem(M.catchupKey('guest','demo')) + await click(`Return a result: ${title}`) + await fill('Result note','Synthetic counterexample: negative control failed.') + await fill('Result artifact URL','https://fixture.example/results/counterexample') + await click('Outcome: did-not-work');await click('Save result to My bench') + assert.equal(rows().length,7,'Only the acknowledged source returns to unread') + assert.ok(rows().some(r=>r.dataset.feedRow===first),'Returned evidence must reopen its source') + assert.ok(!rows().some(r=>r.dataset.feedRow===unrelated.dataset.feedRow),'Unrelated acknowledgement survives') + assert.equal(localStorage.getItem(M.catchupKey('guest','demo')),history) + assert.equal(button('Mark reviewed caught up').disabled,true,'Returning is not reviewing the new revision') + await click(`Open details: ${title}`) + assert.match(document.querySelector('[aria-label="Results for this source"]').textContent,/negative control failed/) + await click('Close dialog');await click('Mark reviewed caught up') + assert.equal(rows().length,6) + const saved=bench.loadBench(localStorage,'guest','demo').state.tasks[0] + const {result,...original}=saved;assert.deepEqual(original,snapshot);assert.equal(result.outcome,'did-not-work') + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render() + assert.equal(rows().length,6);assert.ok(!rows().some(r=>r.dataset.feedRow===first)) + assert.deepEqual(bench.loadBench(localStorage,'guest','demo').state.tasks[0],saved) + await click('Fixture mode');assert.equal(rows().length,3) + assert.equal(bench.loadBench(localStorage,'guest','live').state.tasks.length,0) + await click('Fixture mode');assert.equal(rows().length,6) + identity={isLoading:false,isAuthenticated:true,session:{did:'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb',handle:'b.example.org'}};await render() + assert.equal(rows().length,8,'Another identity cannot inherit returned evidence or acknowledgements') + await click(`Open details: ${title}`) + assert.equal(document.querySelector('[aria-label="Results for this source"]'),null) + await click('Close dialog') + identity={isLoading:false,isAuthenticated:false,session:null};await render() + assert.equal(rows().length,6,'Returning to the original scope restores the acknowledged result revision') +}) + +test('catch-up, reviewed selections, and bench drawers stay isolated across restored identities',async()=>{ + const a={did:'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',handle:'a.example.org'}, b={did:'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb',handle:'b.example.org'} + identity={isLoading:false,isAuthenticated:true,session:a} + await render();await click('Open details: Split-before-fit inspector');await click('Save this test to My bench');await click('Close dialog');await click('Mark reviewed caught up') + assert.equal(rows().length,7) + await click('Return a result: Split-before-fit inspector') + identity={...identity,session:b};await render() + assert.equal(rows().length,8);assert.ok(!document.querySelector('dialog')) + assert.ok(!document.querySelector('[aria-label="Continue your work"]')) + assert.equal(button('Mark reviewed caught up').disabled,true) + assert.equal(M.loadCatchup(localStorage,b.did,'demo').state.seen.length,0) + identity={...identity,session:a};await render() + assert.equal(rows().length,7);assert.ok(document.querySelector('[aria-label="Continue your work"]')) + assert.ok(!document.querySelector('dialog'),'An old result editor does not reopen after switching back') + assert.equal(button('Mark reviewed caught up').disabled,true) +}) + +test('reset filters leaves an empty help view and returns to unread discovery',async()=>{ + await render();await click('Fixture mode');await click('Needs a hand') + assert.equal(rows().length,0) + await click('Reset filters') + assert.equal(rows().length,3,'Reset must clear the help filter as well as branches and text') + assert.equal(button('Not caught up').getAttribute('aria-pressed'),'true') +}) + +test('failed catch-up preserves source storage and replaces any earlier success notice',async()=>{ + await render();await click('Open details: Split-before-fit inspector');await click('Close dialog');await click('Mark reviewed caught up') + assert.match(document.querySelector('[aria-label="Daily catch-up"]').textContent,/1 update marked caught up/) + const title=rows()[0].querySelector('h2').textContent + await click(`Reviewed: ${title}`) + const key=M.catchupKey('guest','demo'), corrupt='{future unreadable history' + localStorage.setItem(key,corrupt) + await click('Mark reviewed caught up') + assert.equal(localStorage.getItem(key),corrupt) + assert.equal(rows().length,7,'Failed acknowledgement does not remove another row') + const summary=document.querySelector('[aria-label="Daily catch-up"]') + assert.match(summary.textContent,/could not be read/) + assert.ok(summary.querySelector('[role="status"]')===null,'A prior success must not describe the failed save') +}) + +test('incomplete public reads never become an all-caught-up claim',async()=>{ + const pub=source('components/lab/feed/usePublicFollowing.ts') + mock.method(pub,'usePublicFollowing',()=>({requested:true,loading:false,records:[],feed:null,error:'Fixture source failed',refresh(){}})) + await render();await click('Following') + assert.match(document.querySelector('[aria-label="Daily catch-up"]').textContent,/Catch-up is incomplete/) + assert.doesNotMatch(document.body.textContent,/You’re caught up|Nothing unread/) + assert.match(document.body.textContent,/Fixture source failed/) +}) + +test('returning readers can open unread followed work directly from the daily summary',async()=>{ + await render();await click('Follow idea: Split-before-fit inspector') + assert.ok(button('Catch up on followed work'),'Followed-work shortcut missing') + assert.match(button('Catch up on followed work').textContent,/2 unread from your follows/) + await click('Catch up on followed work');assert.equal(rows().length,2) + await click('Open details: Split-before-fit inspector');await click('Close dialog');await click('Mark reviewed caught up');assert.equal(rows().length,1) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();assert.equal(rows().length,1) +}) + +test('daily cold start is honest; explicit reviewed-only catch-up persists without replaying hidden work',async()=>{ + await render() + assert.ok(document.querySelector('[aria-label="Daily catch-up"]'),'Daily catch-up missing') + assert.equal(document.querySelector('h1').textContent,'Catch up') + assert.match(document.body.textContent,/Follow an idea, person, or branch/) + assert.match(document.body.textContent,/not a live activity timeline/) + assert.equal(rows().length,8) + const mark=()=>button('Mark reviewed caught up') + assert.equal(mark().disabled,true,'Rendering rows is not reviewing them') + const first=rows()[0].dataset.feedRow, second=rows()[1].dataset.feedRow + await click('Open details: Split-before-fit inspector');await click('Close dialog') + assert.equal(mark().disabled,false) + await fill('Search the feed','marimo');assert.equal(mark().disabled,true,'Hidden review cannot clear a different filter') + await fill('Search the feed','');await click('Mark reviewed caught up') + assert.equal(rows().length,7);assert.ok(!rows().some(r=>r.dataset.feedRow===first));assert.ok(rows().some(r=>r.dataset.feedRow===second)) + assert.match(document.querySelector('[aria-label="Daily catch-up"]').textContent,/1 update marked caught up/) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render() + assert.equal(rows().length,7,'Exact acknowledged update stays out after reload') + await click('All activity');assert.equal(rows().length,8);assert.match(rows()[0].textContent,/Caught up/) + await click('Fixture mode');assert.equal(rows().length,3);assert.equal(M.loadCatchup(localStorage,'guest','live').state.seen.length,0) + await click('Fixture mode');assert.equal(rows().length,7) +}) diff --git a/scripts/lab-demo-data.test.mjs b/scripts/lab-demo-data.test.mjs new file mode 100644 index 00000000..48a1cb8d --- /dev/null +++ b/scripts/lab-demo-data.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { source } from './velocity/test-source-loader.mjs'; + +function memoryStorage() { const values = new Map(); return { values, getItem: k => values.get(k) ?? null, setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) }; } +const load = () => { assert.ok(existsSync('src/lib/lab-demo.ts'), 'demo fixture module must exist'); return source('lib/lab-demo.ts'); }; +test('fictional community has coherent people, cases, proposals, threaded evidence and route targets', () => { + const d = load(); + assert.equal(d.DEMO_PEOPLE.length, 6); + assert.deepEqual(d.DEMO_CASES.map(x => x.id), ['reproducibility', 'neural-measurement', 'open-artifacts']); + const people = new Set(d.DEMO_PEOPLE.map(x => x.id)); + const cases = new Set(d.DEMO_CASES.map(x => x.id)); + const proposals = new Set(d.DEMO_PROPOSALS.map(x => x.id)); + const threads = new Set(d.DEMO_THREADS.map(x => x.id)); + assert.equal(people.size, 6); + for (const p of d.DEMO_PEOPLE) { assert.equal(p.fictional, true); assert.ok(p.role && p.bio); } + for (const p of d.DEMO_PROPOSALS) { assert.ok(cases.has(p.caseId)); assert.ok(people.has(p.authorId)); assert.ok(p.test && p.stop && p.revision); } + for (const t of d.DEMO_THREADS) { + assert.ok(cases.has(t.caseId)); assert.ok(proposals.has(t.proposalId)); + const seen = new Set(); + for (const m of t.messages) { assert.ok(people.has(m.authorId)); if(m.parentId) assert.ok(seen.has(m.parentId)); seen.add(m.id); assert.match(m.sequence, /^Illustrative day /); } + assert.equal(seen.size, t.messages.length); + const url = new URL(d.demoThreadHref(t.id), 'https://example.test'); + assert.equal(url.pathname, '/lab/demo/'); assert.equal(url.searchParams.get('discussion'), t.id); + } + for (const n of d.DEMO_NOTIFICATIONS) { assert.ok(threads.has(n.threadId)); assert.ok(people.has(n.actorId)); assert.equal(n.href, d.demoThreadHref(n.threadId)); } + for (const a of d.DEMO_SUPPORT) { assert.ok(people.has(a.personId)); assert.ok(proposals.has(a.proposalId)); assert.ok(a.points > 0 && a.points <= 3); } + const all = JSON.stringify([d.DEMO_PEOPLE,d.DEMO_THREADS,d.DEMO_PROPOSALS]); + assert.doesNotMatch(all, /did:|at:\/\/|linkedin\.com|scholar\.google|https?:\/\/.*\.(png|jpg)/); + assert.ok(d.DEMO_THREADS.flatMap(t => t.messages).some(m => m.kind === 'dissent')); + assert.ok(d.DEMO_THREADS.flatMap(t => t.messages).some(m => m.kind === 'uncertain')); +}); + +test('demo actions persist in an isolated scope, count fixture + local events, and reset preserves real drafts', () => { + const d = load(); const store = memoryStorage(); + assert.equal(typeof d.emptyDemoState, 'function', 'local demo reducer must exist'); + store.setItem('open-lab:draft:real-owner:note', 'precious real draft'); + let state = d.emptyDemoState('alpha'); + state = d.reduceDemoState(state, {type:'reply', threadId:'split-boundary', parentId:'r6', text:' I can try the narrow check. '}); + state = d.reduceDemoState(state, {type:'save', threadId:'split-boundary'}); + state = d.reduceDemoState(state, {type:'follow', personId:'mira'}); + state = d.reduceDemoState(state, {type:'allocate', proposalId:'split-check', delta:1}); + state = d.reduceDemoState(state, {type:'read', notificationId:'revision-ready'}); + state = d.reduceDemoState(state, {type:'dismiss', notificationId:'denominator-help'}); + assert.equal(d.saveDemoState(store, state).ok, true); + assert.deepEqual(d.loadDemoState(store,'alpha').state,state); + assert.equal(d.loadDemoState(store,'beta').state.replies.length,0); + assert.equal(state.replies[0].authorId,'demo-visitor'); + assert.equal(state.replies[0].text,'I can try the narrow check.'); + const counts = d.demoCounts(state,'reproducibility'); + assert.equal(counts.people, d.DEMO_PEOPLE.filter(p=>p.caseIds.includes('reproducibility')).length); + assert.equal(counts.messages, d.DEMO_THREADS.filter(t=>t.caseId==='reproducibility').reduce((n,t)=>n+t.messages.length,0)+1); + assert.equal(counts.points, d.DEMO_SUPPORT.filter(a=>a.proposalId==='split-check').reduce((n,a)=>n+a.points,0)+1); + assert.equal(d.demoUnread(state).length,1); + assert.equal(d.saveDemoMode(store,'live').ok,true); + assert.equal(d.loadDemoMode(store,'demo').mode,'live'); + assert.equal(d.resetDemoState(store,'alpha',false).ok,false); + assert.equal(d.loadDemoState(store,'alpha').state.replies.length,1); + assert.equal(d.resetDemoState(store,'alpha',true).ok,true); + assert.equal(d.loadDemoState(store,'alpha').state.replies.length,0); + assert.equal(store.getItem('open-lab:draft:real-owner:note'),'precious real draft'); + assert.equal(d.loadDemoMode(store,'demo').mode,'live'); + assert.ok([...store.values.keys()].filter(k=>k!=='open-lab:draft:real-owner:note').every(k=>k.startsWith('app-demo:'))); +}); + +test('malformed, mismatched, oversized, blocked storage and invalid actions fail safely', () => { + const d=load(); const store=memoryStorage(); const key=d.demoStorageKey('alpha'); + store.setItem(key,'{broken'); + assert.equal(d.loadDemoState(store,'alpha').status,'corrupt'); + assert.equal(d.saveDemoState(store,d.emptyDemoState('alpha')).ok,false); + assert.equal(store.getItem(key),'{broken'); + store.setItem(key,JSON.stringify(d.emptyDemoState('beta'))); + assert.equal(d.loadDemoState(store,'alpha').status,'corrupt'); + store.setItem(key,'x'.repeat(260000)); + assert.equal(d.loadDemoState(store,'alpha').status,'corrupt'); + const blocked={getItem(){throw Error('blocked')},setItem(){throw Error('quota')},removeItem(){throw Error('blocked')}}; + assert.equal(d.loadDemoState(blocked,'alpha').status,'unavailable'); + assert.equal(d.saveDemoMode(blocked,'live').ok,false); + assert.equal(d.resetDemoState(blocked,'alpha',true).ok,false); + let state=d.emptyDemoState('alpha'); + for(const action of [ + {type:'reply',threadId:'missing',text:'hello'}, {type:'reply',threadId:'split-boundary',parentId:'n1',text:'wrong parent'}, + {type:'reply',threadId:'split-boundary',text:' '}, {type:'reply',threadId:'split-boundary',text:'a'.repeat(2001)}, + {type:'follow',personId:'unknown'}, {type:'read',notificationId:'unknown'}, {type:'allocate',proposalId:'missing',delta:1}, + ]) assert.throws(()=>d.reduceDemoState(state,action)); + for(let i=0;id.reduceDemoState(state,{type:'allocate',proposalId:'duration-note',delta:1})); + state=d.reduceDemoState(state,{type:'allocate',proposalId:'split-check',delta:-1}); + assert.equal(d.demoPointsRemaining(state),1); +}); diff --git a/scripts/lab-demo-ui.test.mjs b/scripts/lab-demo-ui.test.mjs new file mode 100644 index 00000000..6b27e2fe --- /dev/null +++ b/scripts/lab-demo-ui.test.mjs @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { JSDOM } from 'jsdom'; +import { source } from './velocity/test-source-loader.mjs'; +const require=createRequire(import.meta.url); +require.extensions['.css']=(m)=>{m.exports=new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)});}; +async function harness(run, url='http://localhost/lab/demo/') { + assert.ok(existsSync('src/components/lab/demo/index.ts'), 'composable demo components must exist'); + const dom=new JSDOM('
',{url}); const saved={}; + for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','Event','KeyboardEvent','MouseEvent','StorageEvent']) {saved[k]=Object.getOwnPropertyDescriptor(globalThis,k); Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true});} + globalThis.IS_REACT_ACT_ENVIRONMENT=true; + const oldFetch=globalThis.fetch; let fetches=0; globalThis.fetch=()=>{fetches++;throw Error('Demo must not fetch');}; + const React=await import('react'); const {createRoot}=await import('react-dom/client'); let root=createRoot(document.getElementById('root')); + const C=source('components/lab/demo/index.ts'); + const click=async text=>{const b=[...document.querySelectorAll('button,a')].find(e=>e.textContent.trim()===text||e.getAttribute('aria-label')===text); assert.ok(b,'Missing control: '+text); await React.act(()=>{b.focus();b.click();}); return b;}; + const render=async (props={})=>React.act(()=>root.render(React.createElement(C.DemoCommunityProvider,props,React.createElement(C.DemoCommunityExperience)))); + const reload=async()=>{await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();}; + try { await run({dom,React,C,render,reload,click,root}); assert.equal(fetches,0); } + finally {await React.act(()=>root.unmount());dom.window.close();globalThis.fetch=oldFetch;for(const [k,d] of Object.entries(saved)) {if(d)Object.defineProperty(globalThis,k,d);else delete globalThis[k];}delete globalThis.IS_REACT_ACT_ENVIRONMENT;} +} + +test('real components: fictional profile, local thread reply, save, support, read/dismiss, live switch and reset', async()=>harness(async({dom,React,render,reload,click})=>{ + dom.window.localStorage.setItem('open-lab:draft:real:note','untouched'); await render(); + assert.match(document.body.textContent,/Fictional people/); + const opener=await click('View Ada Lovelace’s demo profile'); + assert.match(document.querySelector('[role="dialog"]').textContent,/Research software maintainer/); + await click('Follow in demo'); + await React.act(()=>document.dispatchEvent(new dom.window.KeyboardEvent('keydown',{key:'Escape',bubbles:true}))); + assert.equal(document.querySelector('[role="dialog"]'),null);assert.ok(document.activeElement===opener, 'Escape returns focus to profile trigger'); + await click('Open discussion: What would actually catch the leak?'); + assert.match(document.body.textContent,/false alarm/i); + await click('Reply to Hedy Lamarr: r6'); + const textarea=document.querySelector('textarea'); + await React.act(()=>{Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype,'value').set.call(textarea,'A narrower local test');textarea.dispatchEvent(new dom.window.Event('input',{bubbles:true}));}); + await click('Save demo reply'); + assert.match(document.body.textContent,/A narrower local test<\/b>/);assert.equal(document.querySelector('b'),null); + await click('Save discussion');await click('Allocate 1 demo point'); + await click('Demo notifications: 3 unread'); + await click('Dismiss: Marie kept an unknown instead of estimating missing hours.'); + const link=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('Inspect the revised test')); + assert.match(link.getAttribute('href'),/^\/lab\/demo\/\?discussion=split-boundary/); + await React.act(()=>link.click()); + assert.ok(document.querySelector('[data-thread="split-boundary"] textarea')); + await reload(); + assert.ok(document.querySelector('[data-thread="split-boundary"] textarea'), 'notification permalink reopens discussion after reload'); + assert.match(document.body.textContent,/A narrower local test<\/b>/); + assert.match(document.body.textContent,/Saved discussion/);assert.match(document.body.textContent,/4 fictional points/); + await click('View Ada Lovelace’s demo profile');assert.match(document.querySelector('[role="dialog"]').textContent,/Following in demo/);await click('Close dialog'); + assert.ok(document.querySelector('[aria-label="Demo notifications: 1 unread"]')); + await click('Show real / empty view'); + assert.doesNotMatch(document.body.textContent,/Ada Lovelace|fictional points|What would actually catch/); + assert.equal(document.querySelector('[aria-label^="Demo notifications:"]'),null); + await reload();assert.doesNotMatch(document.body.textContent,/Ada Lovelace/); + await click('Show demo community');await click('Reset demo'); + await click('Cancel'); + assert.equal(JSON.parse(dom.window.localStorage.getItem('app-demo:community:v1:browser')).replies.length,1); + await click('Reset demo');await click('Reset demo changes'); + assert.equal(dom.window.localStorage.getItem('app-demo:community:v1:browser'),null); + assert.equal(dom.window.localStorage.getItem('open-lab:draft:real:note'),'untouched'); +})); + +test('direct notification URL opens a visible discussion; unknown IDs remain honest',async()=>harness(async({render})=>{ + await render();assert.ok(document.querySelector('[data-thread="duration-denominator"] textarea')); + assert.match(document.body.textContent,/overlap metadata/); +},'http://localhost/lab/demo/?discussion=duration-denominator#demo-discussion-duration-denominator')); + +test('outside provider renders safely and hides examples; scoped filters do not leak other cases',async()=>harness(async({C,React,root,render})=>{ + await React.act(()=>root.render(React.createElement(React.Fragment,null,React.createElement(C.DemoPeople),React.createElement(C.DemoNotifications),React.createElement(C.DemoActivityFeed)))); + assert.equal(document.body.textContent,''); + await React.act(()=>root.render(React.createElement(C.DemoCommunityProvider,null,React.createElement(C.DemoCommunityPanel,{context:'atlas'})))); + assert.match(document.body.textContent,/Two durations/);assert.doesNotMatch(document.body.textContent,/catch the leak|A working link/); +})); + +test('notification can reopen the same collapsed target; dialogs trap Tab; corrupt saves retain reply text',async()=>harness(async({dom,React,render,click})=>{ + await render(); + await click('Demo notifications: 3 unread'); await click('Inspect the revised test →'); + await click('Close discussion: What would actually catch the leak?'); + await click('Demo notifications: 2 unread'); await click('Inspect the revised test →'); + assert.ok(document.querySelector('[data-thread="split-boundary"] textarea'), 'same notification reopens a collapsed discussion'); + await click('View Ada Lovelace’s demo profile'); + const dialog=document.querySelector('[role="dialog"]'); const first=dialog.querySelector('button'); + first.focus(); + await React.act(()=>document.dispatchEvent(new dom.window.KeyboardEvent('keydown',{key:'Tab',shiftKey:true,bubbles:true,cancelable:true}))); + assert.ok(document.activeElement.textContent.includes('Follow in demo'),'Shift-Tab wraps to final dialog control'); + await click('Close dialog'); + dom.window.localStorage.setItem('app-demo:community:v1:browser','{bad'); + const textarea=document.querySelector('textarea'); + await React.act(()=>{Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype,'value').set.call(textarea,'Keep this unsaved text');textarea.dispatchEvent(new dom.window.Event('input',{bubbles:true}));}); + await click('Save demo reply'); + assert.equal(textarea.value,'Keep this unsaved text'); + assert.match(document.body.textContent,/preserved/); + assert.equal(dom.window.localStorage.getItem('app-demo:community:v1:browser'),'{bad'); +})); + +test('provider remount isolates event scopes and storage events synchronize view preference',async()=>harness(async({dom,React,render,click})=>{ + await render({storageScope:'alpha'});await click('Save discussion'); + await render({storageScope:'beta'});assert.doesNotMatch(document.body.textContent,/Saved discussion/); + await render({storageScope:'alpha'});assert.match(document.body.textContent,/Saved discussion/); + dom.window.localStorage.setItem('app-demo:mode:v1','live'); + await React.act(()=>dom.window.dispatchEvent(new dom.window.StorageEvent('storage',{key:'app-demo:mode:v1',newValue:'live'}))); + assert.doesNotMatch(document.body.textContent,/Ada Lovelace|fictional points/); +})); + +test('unknown discussion does not pretend a target exists',async()=>harness(async({render})=>{ + await render();assert.match(document.body.textContent,/That demo discussion does not exist/);assert.equal(document.querySelector('textarea'),null); +},'http://localhost/lab/demo/?discussion=not-a-thread')); + +test('demo modules have no network, identity, raw HTML, remote imagery or shared store dependencies',()=>{ + const files=['src/lib/lab-demo.ts',...readdirSync('src/components/lab/demo').filter(x=>/\.tsx?$/.test(x)).map(x=>'src/components/lab/demo/'+x)]; + for(const file of files){const code=readFileSync(file,'utf8');assert.doesNotMatch(code,/\bfetch\s*\(|XMLHttpRequest|sendBeacon|WebSocket|dangerouslySetInnerHTML|localStorage\.clear|lab-drafts|lab-auth|lab-protocol|lab-social|
', { url: 'https://local.test/lab/efforts/' }); +for (const name of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent']) globalThis[name] = dom.window[name]; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +// Any component-initiated request fails the test immediately. +globalThis.fetch = () => { throw Error('Network is forbidden in this local experiment'); }; +const React = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { act } = React; +let root; +const text = () => document.body.textContent; +const button = name => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === name || b.getAttribute('aria-label') === name); +async function click(name) { + const b = button(name); assert.ok(b, `button: ${name}`); assert.equal(b.disabled, false); + await act(async () => b.click()); +} +async function input(id, value) { + const el = document.getElementById(id); assert.ok(el, id); + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + await act(async () => { + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + }); +} +async function mount() { + const Component = source('components/lab/EffortBacking.tsx').default; + root = createRoot(document.getElementById('root')); + await act(async () => root.render(React.createElement(Component))); +} +async function unmount() { await act(async () => root.unmount()); } + +test('real actions allocate, reject overspend, reclaim, shift and reload locally', async () => { + window.localStorage.clear(); + await mount(); + assert.match(text(), /Local allocation experiment; no money, global vote, or funding commitment/); + assert.match(text(), /100 points available/); + assert.match(text(), /Proposed work scope/); + assert.match(text(), /Acceptance criterion/); + assert.match(text(), /If it fails/); + await input('effort-support', '80'); await click('Set support'); + assert.match(text(), /20 points available/); + await click('Inspect Trace a source claim'); + await input('effort-support', '21'); await click('Set support'); + assert.match(document.querySelector('[role="alert"]').textContent, /budget/i); + assert.ok(document.querySelector('.effort-support-form [role="alert"]'), 'budget error belongs beside support controls'); + assert.match(text(), /20 points available/); + await input('effort-support', '20'); await click('Set support'); + assert.match(text(), /0 points available/); + assert.equal(button('Add one point').disabled, true); + await click('Reclaim support'); + assert.match(text(), /20 points available/); + await click('Inspect Improve a reusable notebook'); + await input('effort-support', '20'); await click('Set support'); + await input('effort-support', '1.5'); await click('Set support'); + assert.match(document.querySelector('[role="alert"]').textContent, /whole number/i); + assert.match(text(), /0 points available/); + await unmount(); await mount(); + assert.match(text(), /0 points available/); + assert.equal(document.getElementById('effort-support').value, '80'); + await unmount(); +}); + +test('evidence saves without support, unsafe URLs fail, export downloads actual JSON', async () => { + window.localStorage.clear(); await mount(); + await input('effort-evidence-url', 'javascript:alert(1)'); + await input('effort-evidence-summary', 'This needs independent review.'); + await click('Save local evidence'); + assert.match(document.querySelector('[role="alert"]').textContent, /http\(s\)/); + await input('effort-evidence-url', 'https://example.org/result'); + await click('Save local evidence'); + assert.match(text(), /100 points available/); + const link = document.querySelector('a[href="https://example.org/result"]'); + assert.ok(link); assert.match(link.rel, /noopener/); assert.match(link.rel, /noreferrer/); + let blob; let download; + const oldCreate = URL.createObjectURL; const oldRevoke = URL.revokeObjectURL; + URL.createObjectURL = value => { blob = value; return 'blob:local-test'; }; + URL.revokeObjectURL = () => {}; + const oldClick = dom.window.HTMLAnchorElement.prototype.click; + dom.window.HTMLAnchorElement.prototype.click = function () { download = this.download; }; + try { + await click('Export portfolio JSON'); + assert.equal(download, 'open-lab-effort-portfolio.json'); + const exported = JSON.parse(await blob.text()); + assert.equal(exported.portfolio.evidence['reproduce-tutorial'].summary, 'This needs independent review.'); + assert.equal(exported.portfolio.allocations['reproduce-tutorial'], 0); + } finally { + URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; + dom.window.HTMLAnchorElement.prototype.click = oldClick; + } + await unmount(); await mount(); + assert.equal(document.getElementById('effort-evidence-url').value, 'https://example.org/result'); + await unmount(); +}); + +test('corrupt or unavailable storage is visible, never a crash or false save claim', async () => { + const { STORAGE_KEY } = source('lib/lab-efforts.ts'); + window.localStorage.setItem(STORAGE_KEY, '{"broken":true}'); + await mount(); assert.match(text(), /invalid.*ignored/i); assert.match(text(), /100 points available/); await unmount(); + const descriptor = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw Error('blocked'); } }); + try { + await mount(); assert.match(text(), /storage is unavailable/i); + await click('Add one point'); assert.match(text(), /99 points available/); + assert.match(text(), /not saved/i); + await unmount(); + } finally { Object.defineProperty(window, 'localStorage', descriptor); } +}); diff --git a/scripts/lab-efforts.test.mjs b/scripts/lab-efforts.test.mjs new file mode 100644 index 00000000..2a50592b --- /dev/null +++ b/scripts/lab-efforts.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { source } from './velocity/test-source-loader.mjs'; + +const model = () => source('lib/lab-efforts.ts'); + +test('a local portfolio begins with zero support and a finite illustrative budget', () => { + const { POINT_BUDGET, EFFORTS, createPortfolio, totalSupport } = model(); + assert.equal(POINT_BUDGET, 100); + assert.equal(EFFORTS.length, 3); + const portfolio = createPortfolio(); + assert.equal(totalSupport(portfolio), 0); + assert.deepEqual(Object.keys(portfolio.allocations), EFFORTS.map(e => e.id)); + assert.ok(Object.values(portfolio.allocations).every(n => n === 0)); +}); + +test('allocate, reclaim and shift preserve the joint budget without mutating prior state', () => { + const { createPortfolio, setSupport, totalSupport } = model(); + const initial = createPortfolio(); + let p = setSupport(initial, 'reproduce-tutorial', 80); + assert.equal(totalSupport(initial), 0); + p = setSupport(p, 'trace-source', 20); + assert.equal(totalSupport(p), 100); + assert.throws(() => setSupport(p, 'reusable-notebook', 1), /budget/i); + p = setSupport(p, 'reproduce-tutorial', 40); + p = setSupport(p, 'reusable-notebook', 40); + assert.equal(totalSupport(p), 100); + p = setSupport(p, 'trace-source', 0); + assert.equal(totalSupport(p), 80); + for (const bad of [-1, NaN, Infinity, 0.5, 101, '4', null, undefined]) { + assert.throws(() => setSupport(p, 'trace-source', bad)); + } + assert.throws(() => setSupport(p, 'unknown', 1)); + assert.throws(() => setSupport(p, '__proto__', 1)); +}); + +test('deterministic allocation sequences never overspend', () => { + const { EFFORTS, POINT_BUDGET, createPortfolio, setSupport, totalSupport } = model(); + let p = createPortfolio(); + let seed = 37; + let accepted = 0; + for (let i = 0; i < 2000; i++) { + seed = (seed * 16807) % 2147483647; + const id = EFFORTS[i % EFFORTS.length].id; + const amount = seed % 130; + try { p = setSupport(p, id, amount); accepted++; } catch (error) { assert.match(error.message, /budget|whole number/i); } + assert.ok(totalSupport(p) <= POINT_BUDGET); + assert.ok(Object.values(p.allocations).every(n => Number.isInteger(n) && n >= 0)); + } + assert.ok(accepted > 100); +}); + +test('persistence reloads valid support and rejects corrupt portfolios as a whole', () => { + const { createPortfolio, setSupport, parsePortfolio, loadPortfolio, savePortfolio, STORAGE_KEY } = model(); + const p = setSupport(createPortfolio(), 'trace-source', 45); + assert.deepEqual(parsePortfolio(JSON.stringify(p)), p); + const bad = [null, [], {}, { ...p, version: 2 }, { ...p, surprise: 1 }, { ...p, allocations: {} }]; + for (const n of [-1, NaN, Infinity, 1.2, 101, '5', null]) { + bad.push({ ...p, allocations: { ...p.allocations, 'trace-source': n } }); + } + bad.push({ ...p, allocations: { ...p.allocations, unknown: 0 } }); + bad.push({ ...p, allocations: { ...p.allocations, 'reproduce-tutorial': 90 } }); + for (const value of bad) assert.throws(() => parsePortfolio(JSON.stringify(value))); + for (const value of ['{', '{"allocations":NaN}', 'x'.repeat(20001)]) assert.throws(() => parsePortfolio(value)); + const data = new Map(); + const storage = { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v) }; + assert.equal(loadPortfolio(storage).status, 'empty'); + assert.equal(savePortfolio(storage, p), true); + assert.deepEqual(loadPortfolio(storage), { status: 'loaded', portfolio: p }); + data.set(STORAGE_KEY, '{"malformed":true}'); + assert.equal(loadPortfolio(storage).status, 'invalid'); + assert.deepEqual(loadPortfolio(storage).portfolio, createPortfolio()); + const blocked = { getItem() { throw Error('blocked'); }, setItem() { throw Error('quota'); } }; + assert.equal(loadPortfolio(blocked).status, 'unavailable'); + assert.equal(savePortfolio(blocked, p), false); +}); + +test('local evidence stays separate from support and allows only explicit http(s) links', () => { + const { createPortfolio, setEvidence, parsePortfolio, totalSupport } = model(); + const initial = createPortfolio(); + const entry = { url: 'https://example.org/result', summary: 'A discrepancy, not a reviewed result.' }; + const p = setEvidence(initial, 'trace-source', entry); + assert.equal(totalSupport(p), 0); + assert.deepEqual(initial.evidence, {}); + assert.deepEqual(parsePortfolio(JSON.stringify(p)), p); + for (const url of ['javascript:alert(1)', 'data:text/html,hi', '//example.org', '/relative', 'ftp://example.org', 'https://u:pass@example.org', 'https://exa mple.org', '', 'https://example.org/\nfoo']) { + assert.throws(() => setEvidence(p, 'trace-source', { ...entry, url })); + assert.throws(() => parsePortfolio(JSON.stringify({ ...p, evidence: { 'trace-source': { ...entry, url } } }))); + } + for (const evidence of [{ unknown: entry }, { 'trace-source': null }, { 'trace-source': { ...entry, reviewed: true } }, { 'trace-source': { ...entry, summary: '' } }]) { + assert.throws(() => parsePortfolio(JSON.stringify({ ...p, evidence }))); + } + assert.throws(() => setEvidence(p, 'unknown', entry)); + assert.throws(() => setEvidence(p, 'trace-source', { ...entry, summary: 'x'.repeat(1201) })); + assert.deepEqual(setEvidence(p, 'trace-source', null).evidence, {}); +}); + +test('export is a named local portfolio with assumptions and a non-issued design sketch', () => { + const { createPortfolio, setSupport, setEvidence, exportPortfolio, effortDraft, EFFORTS } = model(); + let p = setSupport(createPortfolio(), 'trace-source', 12); + p = setEvidence(p, 'trace-source', { url: 'http://example.org/note', summary: 'Unreviewed observation.' }); + const exported = JSON.parse(exportPortfolio(p)); + assert.equal(exported.name, 'Open Lab — local effort portfolio'); + assert.deepEqual(exported.portfolio, p); + assert.equal(exported.budget, 100); + assert.match(exported.assumptions.join(' '), /illustrative|design assumption/i); + assert.match(exported.disclaimers.join(' '), /no money/i); + assert.match(exported.disclaimers.join(' '), /different devices/i); + assert.equal(exported.proposals.length, 3); + const draft = effortDraft(EFFORTS[0].id); + assert.equal(draft.status, 'NOT ISSUED'); + assert.equal(draft.possibleFutureSchema, 'org.hypercerts.claim.activity'); + assert.match(draft.format, /not a validated/i); + assert.ok(!JSON.stringify(draft).includes('did:')); + assert.ok(!JSON.stringify(draft).includes('$type')); + assert.throws(() => effortDraft('unknown')); +}); diff --git a/scripts/lab-evidence-recovery.test.mjs b/scripts/lab-evidence-recovery.test.mjs new file mode 100644 index 00000000..54d58e85 --- /dev/null +++ b/scripts/lab-evidence-recovery.test.mjs @@ -0,0 +1,197 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = () => {} +const key = 'plrd-open-lab:evidence-pilot:v1' +const empty = { version: 1, researchText: '', reviewText: '', by: '', note: '' } +const { evidenceTask, parseEvidenceResult } = source('lib/lab-evidence-ledger.ts') +const result = (role) => JSON.stringify({ schemaVersion: 1, taskId: evidenceTask.id, sourceUrl: evidenceTask.sourceUrl, role, contributor: `Synthetic ${role}`, runner: 'human', quote: 'Synthetic test excerpt, not evidence.', location: 'Synthetic location', assessment: role === 'research' ? 'supports' : 'contradicts', limitation: 'Synthetic UI fixture only.' }) + +async function mounted(raw, run, { unavailable = false } = {}) { + const dom = new JSDOM('
', { url: 'http://localhost/lab/collaborate/' }) + const saved = {} + for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'localStorage', 'fetch', 'IS_REACT_ACT_ENVIRONMENT']) { + saved[name] = Object.getOwnPropertyDescriptor(globalThis, name) + Object.defineProperty(globalThis, name, { value: name === 'fetch' ? () => { throw Error('Unexpected network') } : name === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[name], configurable: true, writable: true }) + } + const store = dom.window.localStorage + if (raw !== null) store.setItem(key, raw) + store.setItem('unrelated-draft', 'must survive') + let writes = 0 + const originalSet = dom.window.Storage.prototype.setItem + dom.window.Storage.prototype.setItem = function (...args) { writes++; return originalSet.apply(this, args) } + const originalGet = dom.window.Storage.prototype.getItem + if (unavailable) dom.window.Storage.prototype.getItem = function () { throw Error('read blocked') } + const React = await import('react') + const { createRoot } = await import('react-dom/client') + const Component = source('components/lab/LabEvidenceWorkbench.tsx').default + let root = createRoot(document.getElementById('root')) + const render = () => React.act(() => root.render(React.createElement(React.StrictMode, null, React.createElement(Component)))) + const read = () => originalGet.call(store, key) + const fill = async (label, value) => { + const el = document.querySelector(`[aria-label="${label}"]`) + assert.ok(el, label) + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + await React.act(() => { Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })) }) + } + const click = async text => { + const el = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === text) + assert.ok(el, text) + await React.act(() => el.click()) + } + try { + await render() + await run({ read, fill, click, React, dom, writes: () => writes, remount: async () => { await React.act(() => root.unmount()); root = createRoot(document.getElementById('root')); await render() } }) + assert.equal(originalGet.call(store, 'unrelated-draft'), 'must survive') + } finally { + await React.act(() => root.unmount()) + dom.window.close() + for (const [name, descriptor] of Object.entries(saved)) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); else delete globalThis[name] } + } +} + +for (const [name, raw] of [ + ['empty saved string', ''], + ['malformed JSON', '{broken original\r\n'], + ['unknown version 2', JSON.stringify({ ...empty, version: 2, researchText: 'future recoverable work' })], + ['oversized raw', 'x'.repeat(900_000)], + ['invalid field', JSON.stringify({ ...empty, note: { recoverable: 'do not erase' } })], +]) test(`F1 preserves ${name} on mount, edits, failed export, and remount`, async () => { + await mounted(raw, async ({ read, fill, click, React, remount, writes }) => { + assert.ok(read() === raw, 'original must survive mount byte-for-byte') + assert.equal(writes(), 0, 'failed restore must inhibit autosave') + assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /original.*(preserv|not overwritten)|preserv.*original/i) + assert.doesNotMatch(document.querySelector('[role="alert"]').textContent, /storage is unavailable/i) + await fill('Research return JSON', 'edited scratch text') + await React.act(() => document.querySelector('form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))) + assert.ok(read() === raw, 'edits and failed review export cannot release recovery hold') + assert.equal(writes(), 0) + await remount() + assert.ok(read() === raw) + }) +}) + +test('F1 two valid 40000-newline-prefixed returns survive save and remount', async () => { + await mounted(null, async ({ read, fill, click, remount }) => { + const inputs = {} + for (const role of ['research', 'review']) { + const label = `${role === 'research' ? 'Research' : 'Review'} return JSON` + inputs[role] = '\n'.repeat(40000) + result(role) + assert.ok(inputs[role].length < document.querySelector(`[aria-label="${label}"]`).maxLength) + assert.ok(new TextEncoder().encode(inputs[role]).length < 64000) + assert.equal(parseEvidenceResult(inputs[role], role).role, role) + await fill(label, inputs[role]); await click(`Import ${role}`) + } + assert.match(document.body.textContent, /Different judgments/) + const raw = read() + assert.ok(raw.length > 150000 && raw.length > 128000) + await remount() + assert.ok(read() === raw, 'successful save must remain restorable, not become empty') + for (const role of ['research', 'review']) assert.equal(document.querySelector(`[aria-label="${role === 'research' ? 'Research' : 'Review'} return JSON"]`).value, inputs[role]) + }) +}) + +test('F1 unavailable restore never writes even if storage writes work', async () => { + const raw = JSON.stringify({ ...empty, note: 'unreadable but recoverable' }) + await mounted(raw, async ({ read, fill, writes }) => { + assert.ok(read() === raw) + await fill('Resolution note', 'scratch edit') + assert.equal(writes(), 0) + assert.ok(read() === raw) + assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /storage is unavailable/i) + }, { unavailable: true }) +}) + +test('F1 exact raw download and failed download never authorize replacement', async () => { + const raw = ' \r\n{broken "😀"\r\n' + await mounted(raw, async ({ read, click, dom }) => { + const create = URL.createObjectURL, revoke = URL.revokeObjectURL + const anchorClick = dom.window.HTMLAnchorElement.prototype.click + let blob + try { + URL.createObjectURL = () => { throw Error('download blocked') } + await click('Download saved original') + assert.ok(read() === raw) + assert.match(document.body.textContent, /Could not.*download/i) + URL.createObjectURL = value => { blob = value; return 'blob:fixture' } + URL.revokeObjectURL = () => {} + dom.window.HTMLAnchorElement.prototype.click = () => {} + await click('Download saved original') + assert.equal(await blob.text(), raw) + assert.ok(read() === raw) + } finally { URL.createObjectURL = create; URL.revokeObjectURL = revoke; dom.window.HTMLAnchorElement.prototype.click = anchorClick } + }) +}) + +test('F1 explicit confirmed replacement only replaces the evidence key', async () => { + await mounted('{bad', async ({ read, fill, click, React, remount }) => { + const button = [...document.querySelectorAll('button')].find(b => b.textContent === 'Replace saved evidence with current draft') + assert.ok(button, 'explicit replacement control') + assert.equal(button.disabled, true) + await fill('Resolution note', 'manually reconciled copy') + await React.act(() => document.querySelector('[aria-label="Confirm replacement of saved evidence"]').click()) + await click('Replace saved evidence with current draft') + assert.equal(JSON.parse(read()).note, 'manually reconciled copy') + await remount() + assert.equal(document.querySelector('[aria-label="Resolution note"]').value, 'manually reconciled copy') + }) +}) + +test('F1 replacement failure preserves the original and input edits require fresh confirmation', async () => { + await mounted('{bad', async ({ read, fill, click, React, dom, writes }) => { + const confirm = () => document.querySelector('[aria-label="Confirm replacement of saved evidence"]') + await React.act(() => confirm().click()) + await fill('Resolution note', 'changed after confirmation') + assert.equal(confirm().checked, false) + await click('Replace saved evidence with current draft') + assert.equal(read(), '{bad') + const set = dom.window.Storage.prototype.setItem + try { + dom.window.Storage.prototype.setItem = () => { throw Error('quota exceeded') } + await React.act(() => confirm().click()) + await click('Replace saved evidence with current draft') + assert.equal(read(), '{bad') + assert.match(document.body.textContent, /Could not replace.*autosave remains paused/) + } finally { dom.window.Storage.prototype.setItem = set } + await fill('Resolution note', 'another unsaved edit') + assert.equal(read(), '{bad') + assert.equal(writes(), 0) + }) +}) + +for (const raw of ['x'.repeat(900_000), '{broken\ud800']) test(`F1 unsafe recovery export is withheld (${raw.length} code units)`, async () => { + await mounted(raw, async ({ read }) => { + assert.ok(read() === raw) + assert.equal([...document.querySelectorAll('button')].some(b => b.textContent === 'Download saved original'), false) + assert.match(document.body.textContent, /Exact-raw download is unavailable.*browser developer tools/) + }) +}) + +test('F1 an out-of-control-limit edit cannot replace a restorable saved draft', async () => { + const raw = JSON.stringify({ ...empty, note: 'saved work' }) + await mounted(raw, async ({ read, fill, writes, remount }) => { + assert.equal(writes(), 0, 'opening even a valid draft need not rewrite it') + await fill('Research return JSON', 'x'.repeat(64001)) + assert.ok(read() === raw) + assert.match(document.body.textContent, /Could not save locally/) + await remount() + assert.equal(document.querySelector('[aria-label="Resolution note"]').value, 'saved work') + }) +}) + +test('F1 worst-case JSON escaping at every permitted control limit is restorable', async () => { + await mounted(null, async ({ fill, read, remount }) => { + for (const label of ['Research return JSON', 'Review return JSON', 'Local reviewer name', 'Resolution note']) { + const el = document.querySelector(`[aria-label="${label}"]`) + await fill(label, '\u0000'.repeat(el.maxLength)) + } + const raw = read() + assert.equal(JSON.parse(raw).researchText.length, 64000) + await remount() + assert.ok(read() === raw) + assert.equal(document.querySelector('[aria-label="Resolution note"]').value.length, 2000) + }) +}) diff --git a/scripts/lab-evidence-ui.test.mjs b/scripts/lab-evidence-ui.test.mjs new file mode 100644 index 00000000..88425bee --- /dev/null +++ b/scripts/lab-evidence-ui.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = () => {} +const makeResult = (role, assessment) => ({ schemaVersion: 1, taskId: 'flywire-source-audit-v1', sourceUrl: 'https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain', role, contributor: `Test ${role}`, runner: 'human', quote: 'Synthetic test excerpt, not evidence.', location: 'Test-only location', assessment, limitation: 'This is a synthetic UI fixture.' }) + +test('visitor imports research and review, sees dissent, and explicitly exports a local review with no network publish', async () => { + assert.ok(existsSync('src/components/lab/LabEvidenceWorkbench.tsx'), 'missing evidence workbench') + const dom = new JSDOM('
', { url: 'http://localhost/lab/collaborate/' }) + const saved = {} + for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'localStorage']) { + saved[key] = Object.getOwnPropertyDescriptor(globalThis, key) + Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) + } + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const React = await import('react') + const { createRoot } = await import('react-dom/client') + const root = createRoot(document.getElementById('root')) + let exported = null + const Component = source('components/lab/LabEvidenceWorkbench.tsx').default + try { + await React.act(() => root.render(React.createElement(Component, { onExport: bundle => { exported = bundle } }))) + const fill = async (label, value) => { + const el = document.querySelector(`[aria-label="${label}"]`) + assert.ok(el, label) + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + await React.act(() => { Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })) }) + } + const click = async text => { const el = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === text); assert.ok(el, text); await React.act(() => el.click()) } + await fill('Research return JSON', JSON.stringify(makeResult('research', 'supports'))) + await click('Import research') + await fill('Review return JSON', JSON.stringify(makeResult('review', 'contradicts'))) + await click('Import review') + assert.match(document.body.textContent, /Different judgments/) + assert.equal(exported, null) + await fill('Local reviewer name', 'Test local editor') + await fill('Resolution note', 'The conflicting judgments need further source inspection.') + await React.act(() => document.querySelector('[aria-label="I inspected the source evidence"]').click()) + await click('Export review bundle') + assert.equal(exported.status, 'local-review-not-atlas-acceptance') + assert.equal(exported.comparison.status, 'disagreement') + assert.equal(exported.results.length, 2) + assert.ok(localStorage.getItem('plrd-open-lab:evidence-pilot:v1')) + assert.match(document.body.textContent, /not published/i) + } finally { + await React.act(() => root.unmount()) + dom.window.close() + for (const [key, descriptor] of Object.entries(saved)) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key] } + delete globalThis.IS_REACT_ACT_ENVIRONMENT + } +}) diff --git a/scripts/lab-evidence.test.mjs b/scripts/lab-evidence.test.mjs new file mode 100644 index 00000000..3a98a996 --- /dev/null +++ b/scripts/lab-evidence.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const result = (overrides = {}) => ({ schemaVersion: 1, taskId: 'flywire-source-audit-v1', sourceUrl: 'https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain', role: 'research', contributor: 'Test researcher', runner: 'human', quote: 'A synthetic test quotation, not a scientific result.', location: 'Test paragraph', assessment: 'supports', limitation: 'Synthetic test fixture only.', ...overrides }) + +test('result import is bounded, strict, source-pinned and role-specific', () => { + const { parseEvidenceResult } = source('lib/lab-evidence-ledger.ts') + assert.deepEqual(parseEvidenceResult(JSON.stringify(result()), 'research'), result()) + for (const value of [null, [], {}, result({ taskId: 'different' }), result({ sourceUrl: 'http://127.0.0.1/' }), result({ role: 'review' }), result({ assessment: 'peer-reviewed' }), result({ runner: 'verified-scientist' }), result({ assessment: ['supports'] }), result({ runner: ['human'] }), result({ quote: '' }), result({ contributor: ' ' }), result({ quote: 'x'.repeat(4001) }), result({ extra: 'secret' }), result({ schemaVersion: 2 })]) { + assert.throws(() => parseEvidenceResult(JSON.stringify(value), 'research')) + } + assert.throws(() => parseEvidenceResult('{broken', 'research')) + assert.throws(() => parseEvidenceResult(' '.repeat(65001), 'research')) + assert.throws(() => parseEvidenceResult(JSON.stringify(result({ quote: 'bad\u0000text' })), 'research')) +}) + +test('paired comparison preserves dissent and never turns agreement into scientific acceptance', () => { + const { compareEvidence, buildReviewBundle } = source('lib/lab-evidence-ledger.ts') + const a = result() + const b = result({ role: 'review', contributor: 'Test reviewer', assessment: 'contradicts' }) + assert.equal(compareEvidence(a, null).status, 'awaiting-review') + assert.equal(compareEvidence(null, b).status, 'awaiting-research') + assert.equal(compareEvidence(a, b).status, 'disagreement') + assert.equal(compareEvidence(a, { ...b, assessment: 'supports' }).status, 'agreement-not-validation') + assert.equal(compareEvidence(a, { ...b, contributor: ' TEST researcher ' }).status, 'same-attribution') + const resolution = { by: 'Local editor', decision: 'needs-work', note: 'The sources still need careful human inspection.', checkedSource: true } + const bundle = buildReviewBundle(a, b, resolution) + assert.equal(bundle.status, 'local-review-not-atlas-acceptance') + assert.equal(bundle.comparison.status, 'disagreement') + assert.deepEqual(bundle.results, [a, b]) + assert.deepEqual(bundle.resolution, resolution) + assert.throws(() => buildReviewBundle(a, null, resolution)) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, checkedSource: false })) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, decision: 'accepted-by-atlas' })) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, note: '' })) +}) + +test('paired agent packets pin the same claim and source, but demand separate research and review', () => { + assert.ok(existsSync('src/lib/lab-evidence-ledger.ts'), 'missing evidence loop') + const { evidenceTask, buildEvidencePacket } = source('lib/lab-evidence-ledger.ts') + const a = buildEvidencePacket('research', 20) + const b = buildEvidencePacket('review', 20) + assert.equal(a.taskId, b.taskId) + assert.equal(a.claim, b.claim) + assert.equal(a.sourceUrl, b.sourceUrl) + assert.equal(a.taskId, evidenceTask.id) + assert.notEqual(a.instructions, b.instructions) + assert.equal(a.execution, 'not-dispatched') + assert.equal(a.budget.enforced, false) + assert.ok(a.stopConditions.some(x => x.includes('instructions in source material'))) + assert.throws(() => buildEvidencePacket('administrator', 20)) + for (const n of [0, 241, NaN, 20.5]) assert.throws(() => buildEvidencePacket('research', n)) +}) diff --git a/scripts/lab-explorations-ui.test.mjs b/scripts/lab-explorations-ui.test.mjs new file mode 100644 index 00000000..601f6fb9 --- /dev/null +++ b/scripts/lab-explorations-ui.test.mjs @@ -0,0 +1,183 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' + +const require = createRequire(import.meta.url) +require.extensions['.css'] = module => { + module.exports = { __esModule: true, default: new Proxy({}, { get: (_, key) => String(key) }) } +} +const dom = new JSDOM('', { url: 'https://example.org/lab/explorations/arcade/' }) +globalThis.window = dom.window +globalThis.document = dom.window.document +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const React = await import('react') +const { createRoot } = await import('react-dom/client') +const { act } = React + +async function mount(t, file, props = {}) { + assert.ok(existsSync(`src/components/lab/explorations/${file}.tsx`), `${file} entrance is missing`) + const Component = source(`components/lab/explorations/${file}.tsx`).default + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + await act(async () => root.render(React.createElement(Component, props))) + t.after(async () => { await act(async () => root.unmount()); container.remove() }) + return container +} +const click = async element => { assert.ok(element, 'expected interactive control'); await act(async () => element.click()) } +const button = (view, text) => [...view.querySelectorAll('button')].find(el => el.textContent.includes(text)) + +test('Arcade changes its actual plot and downloads the current settings and result', async t => { + const view = await mount(t, 'ScienceArcade') + assert.match(view.textContent, /synthetic|educational/i) + const originalPath = view.querySelector('[data-automaton-path]').getAttribute('d') + await click(button(view, '30')) + assert.notEqual(view.querySelector('[data-automaton-path]').getAttribute('d'), originalPath) + const seed = view.querySelector('select[name="seed"]') + await act(async () => { seed.value = 'pair'; seed.dispatchEvent(new dom.window.Event('change', { bubbles: true })) }) + const boundary = view.querySelector('select[name="boundary"]') + await act(async () => { boundary.value = 'wrap'; boundary.dispatchEvent(new dom.window.Event('change', { bubbles: true })) }) + const files = [] + const blobs = [] + const originalCreate = URL.createObjectURL + const originalRevoke = URL.revokeObjectURL + const originalClick = dom.window.HTMLAnchorElement.prototype.click + URL.createObjectURL = blob => { blobs.push(blob); return 'blob:lab-export' } + URL.revokeObjectURL = () => {} + dom.window.HTMLAnchorElement.prototype.click = function () { files.push(this.download) } + t.after(() => { URL.createObjectURL = originalCreate; URL.revokeObjectURL = originalRevoke; dom.window.HTMLAnchorElement.prototype.click = originalClick }) + await click(button(view, 'Download data')) + const packet = JSON.parse(await blobs[0].text()) + assert.equal(packet.config.rule, 30) + assert.equal(packet.config.seed, 'pair') + assert.equal(packet.config.boundary, 'wrap') + assert.match(files[0], /rule-30.*\.json$/) + await click(button(view, 'Download print')) + assert.ok((await blobs[1].text()).includes(view.querySelector('[data-automaton-path]').getAttribute('d'))) + assert.match(files[1], /\.svg$/) + assert.equal(view.querySelectorAll('iframe').length, 0) + assert.ok(view.querySelector('a[href="/lab/"]')) +}) + +test('small screens start with an uncluttered question list and can explicitly choose the map', async t => { + const original = window.matchMedia; + window.matchMedia = () => ({matches:true}); + t.after(() => { window.matchMedia = original; }); + const view = await mount(t, 'Observatory'); + assert.equal(button(view, 'List').getAttribute('aria-pressed'), 'true'); + assert.equal(view.querySelectorAll('a[data-question]').length, 4); + assert.equal(view.querySelector('.mapCenter'), null, 'Decorative text must not compete with question cards'); + await click(button(view, 'Map')); + assert.equal(button(view, 'Map').getAttribute('aria-pressed'), 'true'); + assert.equal(view.querySelector('.mapCenter'), null); +}); + +test('Observatory selection updates a shareable route and supports history and list navigation', async t => { + window.history.replaceState({}, '', '/lab/explorations/observatory/neural-measurements/') + const view = await mount(t, 'Observatory', { initialQuestion: 'neural-measurements' }) + assert.match(view.querySelector('[data-question-brief]').textContent, /Which neural measurements/) + assert.match(view.textContent, /not.*live|not.*relationships/i) + const title = view.querySelector('#brief-title') + const originalFocus = title.focus.bind(title) + let focusOptions + title.focus = options => { focusOptions = options; originalFocus(options) } + await click(view.querySelector('a[data-question="portable-evaluations"]')) + assert.ok(!focusOptions?.preventScroll, 'selection must bring an offscreen brief into view on mobile') + assert.equal(window.location.pathname, '/lab/explorations/observatory/portable-evaluations/') + assert.match(view.querySelector('[data-question-brief]').textContent, /What changes when a benchmark/) + assert.equal(view.querySelector('a[data-question="portable-evaluations"]').getAttribute('aria-current'), 'true') + assert.equal(document.activeElement, view.querySelector('#brief-title')) + assert.equal(view.querySelector('input[aria-label="Direct link to this brief"]').value, window.location.href) + await click(button(view, 'List')) + assert.equal(button(view, 'List').getAttribute('aria-pressed'), 'true') + assert.equal(view.querySelectorAll('a[data-question]').length, 4) + await act(async () => { + window.history.replaceState({}, '', '/lab/explorations/observatory/neural-measurements/') + window.dispatchEvent(new dom.window.PopStateEvent('popstate')) + }) + assert.match(view.querySelector('[data-question-brief]').textContent, /Which neural measurements/) + const { frontierQuestions } = loadFrontier() + for (const question of frontierQuestions) { + await click(view.querySelector(`a[data-question="${question.id}"]`)) + assert.match(view.querySelector('[data-question-brief]').textContent, new RegExp(question.question.replace(/[?]/g, '\\?'))) + } + assert.ok(view.querySelector('a[href="/lab/collaborate/"]')) + assert.equal(view.querySelectorAll('iframe').length, 0) +}) + +function loadFrontier() { return source('components/lab/explorations/lab-explorations.ts') } + +test('comparison connects each visitor intent to a distinct entrance and names its tradeoff', async t => { + const view = await mount(t, 'ExplorationComparison') + for (const href of ['/lab/', '/lab/explorations/arcade/', '/lab/explorations/observatory/']) { + assert.ok(view.querySelector(`a[href="${href}"]`), `missing entrance: ${href}`) + } + assert.equal(view.querySelectorAll('[data-entrance]').length, 3) + assert.match(view.textContent, /cold.start/i) + assert.match(view.textContent, /maintenance|curation/i) + await click(button(view, 'Make something')) + assert.match(view.querySelector('[role="status"]').textContent, /Science Arcade/) + await click(button(view, 'Find a useful question')) + assert.match(view.querySelector('[role="status"]').textContent, /Observatory/) + await click(button(view, 'Understand Open Lab')) + assert.match(view.querySelector('[role="status"]').textContent, /foundation/i) +}) + +test('Arcade range and row controls update the exported matrix; failed downloads stay honest', async t => { + const view = await mount(t, 'ScienceArcade') + const range = view.querySelector('input[name="rule"]') + await act(async () => { + Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set.call(range, '204') + range.dispatchEvent(new dom.window.Event('input', { bubbles: true })) + range.dispatchEvent(new dom.window.Event('change', { bubbles: true })) + }) + assert.match(view.querySelector('svg title').textContent, /204/) + const rows = view.querySelector('select[name="rows"]') + await act(async () => { rows.value = '120'; rows.dispatchEvent(new dom.window.Event('change', { bubbles: true })) }) + assert.equal(view.querySelector('svg[role="img"]').getAttribute('viewBox'), '0 0 121 120') + const { makeAutomatonPrint } = loadFrontier() + assert.equal(view.querySelector('[data-automaton-path]').getAttribute('d'), makeAutomatonPrint({ rule: 204, width: 121, rows: 120, seed: 'single', boundary: 'fixed' }).path) + const originalCreate = URL.createObjectURL + URL.createObjectURL = () => { throw new Error('test-only download failure') } + t.after(() => { URL.createObjectURL = originalCreate }) + await click(button(view, 'Download print')) + assert.match(view.querySelector('[role="status"]').textContent, /could not start/i) + assert.doesNotMatch(view.querySelector('[role="status"]').textContent, /saved|published successfully/i) +}) + +test('all entrance routes render anonymously; every brief has static params and unknown IDs 404', async t => { + const diagnostics = [] + const originalError = console.error + console.error = (...args) => diagnostics.push(args.join(' ')) + t.after(() => { console.error = originalError }) + const { renderToStaticMarkup } = await import('react-dom/server') + for (const path of ['page.tsx', 'arcade/page.tsx', 'observatory/page.tsx']) { + const route = source(`app/lab/explorations/${path}`) + assert.equal(route.metadata.robots.index, false) + if (path === 'arcade/page.tsx') { + assert.throws(() => route.default(), error => error.digest === 'NEXT_REDIRECT;replace;/lab/apps/;307;') + continue + } + const html = renderToStaticMarkup(React.createElement(route.default)) + const doc = new JSDOM(html).window.document + assert.equal(doc.querySelectorAll('h1').length, 1) + assert.equal(doc.querySelectorAll('main').length, 0, 'parent owns the main landmark') + for (const anchor of doc.querySelectorAll('a[href^="/"]')) { + assert.ok(new URL(anchor.getAttribute('href'), 'https://example.org').pathname.endsWith('/')) + } + } + const route = source('app/lab/explorations/observatory/[question]/page.tsx') + assert.deepEqual(route.generateStaticParams(), loadFrontier().frontierQuestions.map(question => ({ question: question.id }))) + for (const question of loadFrontier().frontierQuestions) { + const params = Promise.resolve({ question: question.id }) + const result = await route.default({ params }) + assert.equal(result.props.initialQuestion, question.id) + assert.equal((await route.generateMetadata({ params })).description, question.question) + } + await assert.rejects(route.default({ params: Promise.resolve({ question: '__proto__' }) }), /NEXT_HTTP_ERROR_FALLBACK;404/) + assert.deepEqual(diagnostics, [], 'server rendering must not emit React warnings') +}) diff --git a/scripts/lab-explorations.test.mjs b/scripts/lab-explorations.test.mjs new file mode 100644 index 00000000..e643fef8 --- /dev/null +++ b/scripts/lab-explorations.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import postcss from 'postcss' +import { source } from './velocity/test-source-loader.mjs' + +const helperPath = 'components/lab/explorations/lab-explorations.ts' +const load = () => { + assert.ok(existsSync(`src/${helperPath}`), 'explorations instrument is not implemented') + return source(helperPath) +} + +test('rule 90 evolves a single seed into the exact fixed-boundary lattice', () => { + const { evolveAutomaton } = load() + const config = { rule: 90, width: 7, rows: 4, seed: 'single', boundary: 'fixed' } + assert.deepEqual(evolveAutomaton(config).map(row => row.join('')), [ + '0001000', '0010100', '0100010', '1010101', + ]) + assert.deepEqual(evolveAutomaton(config), evolveAutomaton(config)) +}) + +test('instrument rejects invalid or unbounded configurations before allocation', () => { + const { evolveAutomaton } = load() + const config = { rule: 90, width: 7, rows: 4, seed: 'single', boundary: 'fixed' } + for (const invalid of [ + { rule: -1 }, { rule: 256 }, { rule: NaN }, { rule: 1.5 }, + { width: 2 }, { width: 242 }, { width: Infinity }, { rows: 0 }, { rows: 161 }, + { seed: 'random' }, { boundary: 'unknown' }, + ]) assert.throws(() => evolveAutomaton({ ...config, ...invalid }), /invalid automaton/i) +}) + +test('download artifacts contain reproducible configuration and the exact visible lattice', () => { + const { makeAutomatonPrint, evolveAutomaton } = load() + assert.equal(typeof makeAutomatonPrint, 'function', 'print exporter is missing') + const config = { rule: 90, width: 7, rows: 5, seed: 'pair', boundary: 'wrap' } + const print = makeAutomatonPrint(config) + const packet = JSON.parse(print.json) + assert.deepEqual(packet.config, config) + assert.deepEqual(packet.cells, evolveAutomaton(config)) + assert.equal(packet.schema, 'org.plrd.explorations.automaton.v1') + assert.match(packet.limit, /educational/i) + assert.match(print.svg, /]*viewBox="0 0 7 5"/) + assert.ok(print.svg.includes(`d="${print.path}"`)) + assert.match(print.svg, //) + assert.doesNotMatch(print.svg, / { + const { frontierQuestions, findFrontierQuestion, frontierHref } = load() + assert.ok(Array.isArray(frontierQuestions), 'editorial frontier brief collection is missing') + assert.equal(frontierQuestions.length, 4) + assert.equal(new Set(frontierQuestions.map(question => question.id)).size, 4) + for (const question of frontierQuestions) { + assert.equal(findFrontierQuestion(question.id), question) + assert.equal(frontierHref(question.id), `/lab/explorations/observatory/${question.id}/`) + assert.match(question.status, /editorial/i) + assert.ok(question.opening && question.exists && question.contribution && question.limit) + assert.ok(question.sources.every(source => source.url.startsWith('https://') || source.url.startsWith('/areas/'))) + } + for (const invalid of ['__proto__', '../../admin', 'javascript:alert(1)', 'missing', '', null]) { + assert.equal(findFrontierQuestion(invalid), undefined) + assert.throws(() => frontierHref(invalid), /unknown frontier question/i) + } +}) + +test('scoped stylesheet resolves component classes and keeps secondary controls at touch size', () => { + const base = 'src/components/lab/explorations/' + const css = readFileSync(`${base}lab-explorations.module.css`, 'utf8') + const root = postcss.parse(css) + const selectors = new Set() + root.walkRules(rule => { for (const match of rule.selector.matchAll(/\.([a-zA-Z][\w-]*)/g)) selectors.add(match[1]) }) + for (const file of ['ScienceArcade', 'Observatory', 'ExplorationComparison']) { + for (const match of readFileSync(`${base}${file}.tsx`, 'utf8').matchAll(/styles\.([a-zA-Z][\w]*)/g)) { + assert.ok(selectors.has(match[1]), `undefined scoped class ${match[1]}`) + } + } + for (const className of ['previewA', 'previewB', 'previewC']) assert.ok(selectors.has(className)) + for (const selector of ['.method summary', '.viewSwitch button', '.sourceLinks a']) { + let minHeight + root.walkRules(selector, rule => rule.walkDecls('min-height', declaration => { minHeight = declaration.value })) + assert.equal(minHeight, '44px', `${selector} needs an accessible touch target`) + } +}) + +test('field map sits in the compact app, not a second marketing site', () => { + const observatory = readFileSync('src/components/lab/explorations/Observatory.tsx', 'utf8') + assert.doesNotMatch(observatory, /routeBar/, 'Field map must not draw a second Open Lab wordmark inside the app shell') + assert.doesNotMatch(observatory, /The work-map entrance/, 'Field map must not keep the exploration-study eyebrow') + assert.doesNotMatch(observatory, /Compare entrances/, 'Field map must not advertise a second entrance study') + assert.doesNotMatch(observatory, /Science Arcade/, 'Tech tree must not keep a second Science Arcade marketing entrance') + assert.doesNotMatch(observatory, /Editorial field notes/, 'Tech tree must not overlay editorial field-notes chrome') + assert.doesNotMatch(observatory, /observatoryFooter/, 'Tech tree must not keep a marketing footer under the map') + const css = readFileSync('src/components/lab/explorations/lab-explorations.module.css', 'utf8') + const root = postcss.parse(css) + const header = {} + root.walkRules('.observatoryHeader h1', rule => rule.walkDecls(d => { header[d.prop] = d.value })) + const size = parseFloat(header['font-size'] || '0') + assert.ok(size > 0 && size <= 22, `Field-map heading must stay compact, got ${header['font-size']}`) + const center = {} + root.walkRules('.mapCenter > strong', rule => rule.walkDecls(d => { center[d.prop] = d.value })) + const centerSize = parseFloat(center['font-size'] || '0') + assert.ok(centerSize > 0 && centerSize <= 18, `Field-map center type must stay compact, got ${center['font-size']}`) + for (const rule of root.nodes.filter(n => n.type === 'atrule' && n.name === 'media')) { + rule.walkRules('.mapCenter > strong', r => r.walkDecls('font-size', d => { + assert.ok(parseFloat(d.value) <= 18, `Responsive field-map center type must stay compact, got ${d.value} in ${rule.params}`) + })) + } + const map = {} + root.walkRules('.frontierMap', rule => { + if (rule.parent && rule.parent.type === 'atrule') return + rule.walkDecls(d => { map[d.prop] = d.value }) + }) + const mapHeight = parseFloat(map.height || '0') + assert.ok(mapHeight > 0 && mapHeight <= 480, `Tech-tree map must sit in the app, not a tall marketing canvas, got ${map.height}`) +}) + +test('the observatory is a tech tree people choose a branch on', () => { + const observatory = readFileSync('src/components/lab/explorations/Observatory.tsx', 'utf8') + const shell = readFileSync('src/components/lab/LabShell.tsx', 'utf8') + assert.match(observatory, /Explore the tech tree/) + assert.match(observatory, /TECH TREE/) + assert.match(observatory, /Choose a branch/) + assert.doesNotMatch(observatory, /FIELD MAP/) + assert.doesNotMatch(observatory, /Explore the field { + assert.ok(existsSync('src/lib/lab-feed-model.ts'), 'Mixed feed model missing') + const m=source('lib/lab-feed-model.ts'), d=source('lib/lab-demo.ts'), f=source('lib/lab-following.ts') + const demo=d.emptyDemoState(), prefs=f.emptyFollowing('guest','demo') + const rows=m.buildFeedRows({isDemo:true,demo,drafts:[]}) + assert.equal(rows.length,8) + assert.equal(new Set(rows.map(r=>r.id)).size,8) + assert.ok(new Set(rows.map(r=>r.kind)).size>=4) + assert.ok(rows.filter(r=>r.origin==='demo').every(r=>d.DEMO_THREADS.some(t=>t.id===r.ideaId))) + const followed={...prefs,ideas:['split-boundary'],people:['sana'],disciplines:['cross-field'],filter:{feed:'following',disciplines:[]}} + const result=m.filterFeedRows([...rows,rows[0]],followed,'') + assert.equal(new Set(result.map(r=>r.id)).size,result.length) + assert.ok(result.some(r=>r.authorId==='sana')) + assert.ok(result.some(r=>r.ideaId==='split-boundary')) + assert.ok(result.some(r=>r.id==='artifact:marimo')) + assert.equal(m.filterFeedRows(rows,{...prefs,filter:{feed:'following',disciplines:[]}},'').length,0) + assert.equal(m.filterFeedRows(rows,prefs,'no possible match').length,0) + const live=m.buildFeedRows({isDemo:false,demo,drafts:[]}) + assert.ok(live.every(r=>r.origin!=='demo')) +}) + +test('provider replies and real local ideas enter their own mode; tags drive cross-field views', () => { + const m=source('lib/lab-feed-model.ts'), d=source('lib/lab-demo.ts'), f=source('lib/lab-following.ts') + const demo=d.reduceDemoState(d.emptyDemoState(), {type:'reply',threadId:'split-boundary',text:'A smaller independent rerun'}) + const drafts=[{slot:'note:abc',kind:'note',data:{text:'My duration question',field:'neurotech'}}] + const rows=m.buildFeedRows({isDemo:true,demo,drafts}) + assert.ok(rows.some(r=>r.text==='A smaller independent rerun' && r.origin==='demo')) + assert.ok(!rows.some(r=>r.draftSlot)) + const live=m.buildFeedRows({isDemo:false,demo,drafts}) + assert.ok(live.some(r=>r.ideaId==='draft:note:abc')) + assert.ok(!live.some(r=>r.text==='A smaller independent rerun')) + const prefs={...f.emptyFollowing('guest','live'),ideaTags:{'draft:note:abc':['digital-human-rights','neurotech']},filter:{feed:'discover',disciplines:['digital-human-rights','neurotech']}} + assert.equal(m.filterFeedRows(live,prefs,'duration').length,1) +}) + +test('returned-result revisions are exact-source, content-based, order-independent and preserve legacy receipts',()=>{ + const m=source('lib/lab-feed-model.ts'), d=source('lib/lab-demo.ts'), c=source('lib/lab-catchup.ts') + const args={isDemo:true,demo:d.emptyDemoState(),drafts:[]}, initial=m.buildFeedRows(args), row=initial[0] + const result={note:'Fixture observation',artifactUrl:'https://example.org/result',outcome:'uncertain'} + const task={id:'task-z',sourceId:row.ideaId,title:row.title,request:row.request,artifactUrl:'',result} + const legacy={...task,id:row.ideaId,sourceId:undefined}, unrelated={...task,id:'other-task',sourceId:row.ideaId+'-other'} + assert.deepEqual(m.buildFeedRows({...args,tasks:[legacy,unrelated,{...task,result:undefined}]}),initial,'No title/task-ID inference, prefix matching, or unfinished-task revision') + const oldRevision=JSON.stringify([row.ideaId,row.title,row.text,row.kind,row.origin,row.author,row.artifact,row.artifactUrl??'',row.request,row.stage,row.publicRecord?.cid??'']) + assert.equal(c.catchupReceipt(row).revision,oldRevision,'No-result receipt format stays byte-compatible') + const tasks=[task,{...task,id:'task-a',result:{...result,note:'Another observation'}},legacy,unrelated] + const snapshot=JSON.stringify(tasks), revised=m.buildFeedRows({...args,tasks}) + assert.equal(JSON.stringify(tasks),snapshot,'Feed projection never mutates task snapshots or order') + assert.deepEqual(m.buildFeedRows({...args,tasks:[...tasks].reverse()}),revised,'Storage order is not a revision') + const receipt=c.catchupReceipt(revised[0]) + assert.deepEqual(c.catchupReceipt(m.buildFeedRows({...args,tasks:JSON.parse(snapshot)})[0]),receipt,'Identical returned content does not replay') + for(const change of [{note:'Changed observation'},{artifactUrl:'https://example.org/revised'},{outcome:'did-not-work'}]) { + assert.notDeepEqual(c.catchupReceipt(m.buildFeedRows({...args,tasks:[{...task,result:{...result,...change}},...tasks.slice(1)]})[0]),receipt) + } + assert.deepEqual(revised.filter(r=>r.ideaId!==row.ideaId),initial.filter(r=>r.ideaId!==row.ideaId)) + const live={...args,isDemo:false}, editorial=m.buildFeedRows(live)[0] + assert.notDeepEqual(c.catchupReceipt(m.buildFeedRows({...live,tasks:[{...task,sourceId:editorial.ideaId}]})[0]),c.catchupReceipt(editorial),'Real-mode exact sources also carry returned evidence') +}) + +test('workshop feed centers buildable artifacts and bounded requests, with science taxonomy and historical persona disclosure',()=>{ + const m=source('lib/lab-feed-model.ts'),d=source('lib/lab-demo.ts'),f=source('lib/lab-following.ts') + assert.ok(f.DISCIPLINES?.some(v=>v.id==='physics'),'Scientific disciplines missing') + assert.equal(f.protocolFieldForDiscipline('physics'),'cross-field') + const rows=m.buildFeedRows({isDemo:true,demo:d.emptyDemoState(),drafts:[]}) + assert.ok(rows.every(r=>r.artifact && r.request && r.stage)) + assert.ok(rows.some(r=>r.action==='View app')) + assert.ok(rows.some(r=>r.action==='Take a test')) + const prefs={...f.emptyFollowing('guest','demo'),filter:{feed:'discover',disciplines:['physics']}} + assert.equal(m.filterFeedRows(rows,prefs,'').length,0) + assert.equal(d.DEMO_PEOPLE[0].name,'Ada Lovelace') + assert.match(d.DEMO_PERSONA_DISCLOSURE,/not their statements/i) +}) diff --git a/scripts/lab-feed-public.test.mjs b/scripts/lab-feed-public.test.mjs new file mode 100644 index 00000000..691ddf8d --- /dev/null +++ b/scripts/lab-feed-public.test.mjs @@ -0,0 +1,60 @@ +import { test, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; +process.env.__NEXT_TRAILING_SLASH='true'; +const require = createRequire(import.meta.url); +require.extensions['.css'] = m => { m.exports = new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)}); }; +const dom = new JSDOM('
', {url:'https://lab.example.org/lab/'}); +for (const key of ['window','document','HTMLElement','HTMLInputElement','HTMLTextAreaElement','Event','MouseEvent','KeyboardEvent','StorageEvent','localStorage']) globalThis[key]=dom.window[key]; +window.matchMedia=()=>({matches:true,addEventListener(){},removeEventListener(){}}); +globalThis.self=window; globalThis.IS_REACT_ACT_ENVIRONMENT=true; +window.HTMLDialogElement.prototype.showModal=function(){this.open=true;}; +window.HTMLDialogElement.prototype.close=function(){this.open=false;}; +const { createRoot } = await import('react-dom/client'); +const { PathnameContext }=require('next/dist/shared/lib/hooks-client-context.shared-runtime'); +const did='did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', other='did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +let root, identity; +const auth=source('lib/lab-identity.ts'); +const Shell=source('components/lab/LabShell.tsx').default; +const click=async text=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes(text)||b.getAttribute('aria-label')===text);assert.ok(b,text);await act(()=>b.click());}; +const mount=async (C,props={})=>act(async()=>{root.render(React.createElement(PathnameContext.Provider,{value:window.location.pathname},React.createElement(C,props)));await new Promise(r=>setTimeout(r,10));}); +const fill=async(selector,value)=>{const el=document.querySelector(selector);assert.ok(el,selector);await act(()=>{Object.getOwnPropertyDescriptor(el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(el,value);el.dispatchEvent(new Event('input',{bubbles:true}));});}; +beforeEach(()=>{localStorage.clear();window.history.replaceState(null,'','/lab/');identity={session:{did,handle:'real-person.example.org'},isAuthenticated:true,isLoading:false,oauthSession:null,logout:async()=>{},login:async()=>{}};mock.method(auth,'useLabIdentity',()=>identity);mock.method(globalThis,'fetch',async path=>{assert.ok(['/api/lab/feed/','/api/lab/capabilities/'].includes(path),'No ghost records API or writes');return Response.json(path.includes('feed')?{items:[],status:'empty'}:{canSignIn:false,canPublish:false,mode:'unconfigured'});});root=createRoot(document.getElementById('root'));}); +afterEach(async()=>{await act(()=>root.unmount());mock.restoreAll();}); + +test('feed reads the explicit DID through the real notebook adapter and isolates late accounts',async()=>{ + const protocol=source('lib/lab-protocol.ts');let finish;const reads=[]; + mock.method(protocol,'listLabRecords',async(owner,kind,options)=>{ + reads.push({owner,kind,options}); + if(owner===did) await new Promise(r=>{ if(kind==='note')finish=r; else r(); }); + const data={text:owner===did?'LATE OLD ACCOUNT':'Current account note',field:'neurotech',postType:'question'}; + const record={uri:`at://${owner}/org.plresearch.lab.note/one`,cid:'test-cid',kind:'note',authorDid:owner,data,pds:'https://pds.example.org',provenance:'pds-https-unverified-signature'}; + return {authorDid:owner,kind,records:kind==='note'?[record]:[],cursor:kind==='note'?'next':undefined}; + }); + const Feed=source('components/lab/feed/PublicFeedSources.tsx').default; + await mount(Feed);assert.equal(reads.length,5);assert.ok(reads.every(r=>r.owner===did)); + identity={...identity,session:{did:other}};await mount(Feed);await click('Your records'); + assert.match(document.body.textContent,/Current account note/); + assert.match(document.body.textContent,/up to 30 per collection.*more exist/); + assert.match(document.body.textContent,/Current PDS: https:\/\/pds.example.org/); + assert.ok(document.querySelector(`a[href="/lab/record/?uri=${encodeURIComponent(`at://${other}/org.plresearch.lab.note/one`)}"]`)); + await act(()=>finish());assert.doesNotMatch(document.body.textContent,/LATE OLD ACCOUNT/); +}); + + +test('relocated public-source search, kind filter and legacy source bookmarks survive a remount',async()=>{ + const Page=source('components/lab/feed/PublicFeedSources.tsx').default; + identity={...identity,isAuthenticated:false,session:null}; + await mount(Page);await fill('[aria-label="Search scientific work"]','marimo'); + assert.equal(document.querySelectorAll('.lab-feed-entry').length,1);await click('Promising'); + await act(()=>root.render(null));await mount(Page);assert.match(document.body.textContent,/Promising · saved/); + await fill('[aria-label="Search scientific work"]',''); + const select=document.querySelector('[aria-label="Source kind"]'); + await act(()=>{Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype,'value').set.call(select,'negative');select.dispatchEvent(new Event('change',{bubbles:true}))}); + assert.match(document.querySelector('.lab-empty').textContent,/No editorial starters/); + await act(()=>{Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype,'value').set.call(select,'all');select.dispatchEvent(new Event('change',{bubbles:true}))}); + assert.ok(document.querySelectorAll('.lab-feed-entry').length>1); +}); diff --git a/scripts/lab-feed-ui.test.mjs b/scripts/lab-feed-ui.test.mjs new file mode 100644 index 00000000..44d3a097 --- /dev/null +++ b/scripts/lab-feed-ui.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require=createRequire(import.meta.url) +require.extensions['.css']=m=>{m.exports=new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)})} +const dom=new JSDOM('
', {url:'http://localhost/lab/feed/'}) +for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','HTMLDialogElement','Event','KeyboardEvent','StorageEvent','localStorage']) Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +HTMLDialogElement.prototype.showModal=function(){this.open=true;this.querySelector('button')?.focus()} +HTMLDialogElement.prototype.close=function(){this.open=false} +globalThis.self=dom.window +globalThis.IS_REACT_ACT_ENVIRONMENT=true +const React=await import('react'), {createRoot}=await import('react-dom/client') +let identity={isLoading:false,isAuthenticated:false,session:null} +source('lib/lab-identity.ts').useLabIdentity=()=>identity +const D=source('components/lab/demo/DemoCommunityProvider.tsx') +function Mode(){const d=D.useDemoCommunity();return React.createElement('button',{onClick:()=>d.setMode(d.isDemo?'live':'demo')},'Test mode switch')} +const click=async label=>{const e=[...document.querySelectorAll('button,a,summary')].find(e=>e.getAttribute('aria-label')===label||e.textContent.trim()===label);assert.ok(e,'Missing action: '+label);await React.act(()=>{e.focus();e.click()});return e} +const fill=async(label,value)=>{const e=document.querySelector(`[aria-label="${label}"]`);assert.ok(e,'Missing input '+label);await React.act(()=>{Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,value);e.dispatchEvent(new Event('input',{bubbles:true}))})} +const rows=()=>[...document.querySelectorAll('[data-feed-row]')] +test('main compact feed: follow/filter, local reply, saved curator view, reload and mode isolation',async()=>{ + assert.ok(existsSync('src/components/lab/feed/MixedScienceFeed.tsx'),'Main mixed feed missing') + localStorage.clear() + const C=source('components/lab/feed/MixedScienceFeed.tsx').default + let root=createRoot(document.getElementById('root')) + const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,{storageScope:identity.session?.did||'browser'},React.createElement(Mode),React.createElement(C)))) + try{ + await render();assert.equal(rows().length,8) + await click('Following');assert.equal(rows().length,0);assert.match(document.body.textContent,/Reset filters/) + await click('Reset filters');assert.equal(rows().length,8) + await click('Follow idea: Split-before-fit inspector');await click('Following');assert.equal(rows().length,2) + await click('Discover');const person=await click('View Ada Lovelace’s profile');assert.ok(document.querySelector('dialog[open]'));await click('Follow in demo');await click('Close dialog');assert.equal(document.activeElement,person) + await click('Contribute: Split-before-fit inspector');await fill('Your contribution','Check fitted rows in a clean environment.');await click('Save demo contribution');assert.match(document.body.textContent,/Saved in this browser/);await click('Close dialog');assert.equal(rows().length,9) + await click('Refine the feed');await click('Branch: Neuroscience');await click('Branch: AI & machine learning');await fill('Name this view','Methods bridge');await click('Save view');assert.match(document.body.textContent,/Methods bridge/) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();assert.equal(document.querySelector('[aria-label="Branch: Neuroscience"]').getAttribute('aria-pressed'),'true');assert.match(document.body.textContent,/Methods bridge/) + await click('Reset filters');await click('Test mode switch');assert.equal(rows().length,3);assert.doesNotMatch(document.body.textContent,/Ada Lovelace|Check fitted rows/) + await click('Following');assert.equal(rows().length,0) + await click('Test mode switch');assert.ok(rows().length>=8) + }finally{await React.act(()=>root.unmount())} +}) + +test('top-level curation opens discipline follows in a focus-managed drawer',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/feed/') + const C=source('components/lab/FeedWorkbench.tsx').default,root=createRoot(document.getElementById('root')) + try { + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C)))) + assert.ok(Array.from(document.querySelectorAll('button')).some(x=>x.getAttribute('aria-label')==='Curate the feed'),'Curation must be reachable before scrolling the stream') + await click('Curate the feed');assert.ok(document.querySelector('dialog[open]')) + assert.match(document.querySelector('dialog[open]').textContent,/Your branches/) + await click('Follow branch: Mathematics');await click('Follow branch: Neuroscience') + await click('Close dialog');assert.equal(document.querySelector('dialog[open]'),null) + await click('Curate the feed');assert.equal(document.querySelector('dialog[open] [aria-label="Unfollow branch: Mathematics"]').getAttribute('aria-pressed'),'true') + assert.equal(document.querySelector('dialog[open] [aria-label="Unfollow branch: Neuroscience"]').getAttribute('aria-pressed'),'true') + } finally {await React.act(()=>root.unmount())} +}) +test('curation explicitly turns followed disciplines into a saved filter view',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/feed/') + const C=source('components/lab/FeedWorkbench.tsx').default;let root=createRoot(document.getElementById('root')) + const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C)))) + try { + await render();await click('Curate the feed');await click('Follow branch: Mathematics');await click('Follow branch: Neuroscience') + await click('Use followed branches as filters');await fill('Name this view','Minds and methods');await click('Save view') + assert.match(document.querySelector('dialog[open]').textContent,/View saved in this browser/) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();await click('Curate the feed') + const view=Array.from(document.querySelectorAll('dialog[open] button')).find(x=>x.textContent.includes('Minds and methods')) + assert.ok(view);assert.match(view.textContent,/2 branches/);await React.act(()=>view.click());await click('Close dialog') + assert.equal(document.querySelector('[aria-label="Branch: Mathematics"]').getAttribute('aria-pressed'),'true') + assert.equal(document.querySelector('[aria-label="Branch: Neuroscience"]').getAttribute('aria-pressed'),'true') + } finally {await React.act(()=>root.unmount())} +}) +test('feed refinement starts collapsed and opens through a keyboard-accessible native control',async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/feed/') + const C=source('components/lab/FeedWorkbench.tsx').default,root=createRoot(document.getElementById('root')) + try { + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C)))) + const summary=document.querySelector('summary[aria-label="Refine the feed"]');assert.ok(summary,'Search and discipline controls belong in one optional refinement panel') + assert.equal(summary.closest('details').open,false) + await React.act(()=>summary.click());assert.equal(summary.closest('details').open,true) + assert.ok(summary.closest('details').querySelector('[aria-label="Search the feed"]')) + assert.ok(summary.closest('details').querySelector('[aria-label="Branch: Neuroscience"]')) + await React.act(()=>summary.click());assert.equal(summary.closest('details').open,false) + } finally {await React.act(()=>root.unmount())} +}) + +test('the global search URL filters the actual default science feed after hydration', async()=>{ + localStorage.clear();window.history.replaceState(null,'','/lab/feed/?q=Split-before-fit') + const C=source('components/lab/feed/MixedScienceFeed.tsx').default,root=createRoot(document.getElementById('root')) + try {await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C))));assert.equal(document.querySelector('[aria-label="Search the feed"]').value,'Split-before-fit');assert.ok(rows().length>0);assert.ok(rows().every(row=>/split-before-fit/i.test(row.textContent)))}finally{await React.act(()=>root.unmount());window.history.replaceState(null,'','/lab/feed/')} +}) + +test('FeedWorkbench defaults to the mixed feed while preserving source and record workbenches',async()=>{ + const C=source('components/lab/FeedWorkbench.tsx').default + const root=createRoot(document.getElementById('root'));localStorage.clear() + const prior=globalThis.fetch;globalThis.fetch=async()=>({ok:true,json:async()=>({items:[],status:'empty',sourceLabel:'Test-only disconnected source'})}) + try {await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C))));assert.equal(rows().length,8);await click('Public sources & my records');assert.match(document.body.textContent,/From Bluesky/);assert.match(document.body.textContent,/Your records/)}finally{await React.act(()=>root.unmount());globalThis.fetch=prior} +}) + +test('onboarding appears only after verified auth, saves locally per DID and never reopens completed/skipped state',async()=>{ + assert.ok(existsSync('src/components/lab/social/LabOnboardingGate.tsx'),'Verified-auth onboarding gate missing') + const Gate=source('components/lab/social/LabOnboardingGate.tsx').default + const drafts=source('lib/lab-drafts.ts'), social=source('lib/lab-social.ts') + localStorage.clear();let calls=0;const prior=globalThis.fetch;globalThis.fetch=()=>{calls++;throw Error('No public calls from onboarding')} + let root=createRoot(document.getElementById('root'));const render=()=>React.act(()=>root.render(React.createElement(Gate))) + try{ + identity={isLoading:true,isAuthenticated:false,session:null};await render();assert.equal(document.querySelector('dialog'),null) + identity={isLoading:false,isAuthenticated:false,session:{did:'did:plc:unverified',displayName:'Not verified'}};await render();assert.equal(document.querySelector('dialog'),null) + drafts.saveDraft(localStorage,'profile','did:plc:alice',{workingOn:'Existing research',custom:'must remain'}) + identity={isLoading:false,isAuthenticated:true,session:{did:'did:plc:alice',handle:'alice.test',displayName:'Imported Alice'}};await render();assert.match(document.body.textContent,/Imported Alice/) + await fill('Display name','Alice local');await click('Interest: Neuroscience');await fill('GitHub URL','https://github.com/alice');await click('Save and continue') + assert.equal(document.querySelector('dialog'),null) + assert.equal(social.loadSocialState(localStorage,'did:plc:alice').meta.onboardingCompleted,true) + const profile=drafts.loadDraft(localStorage,'profile','did:plc:alice').data;assert.equal(profile.custom,'must remain');assert.equal(profile.workingOn,'Existing research');assert.equal(profile.githubUrl,'https://github.com/alice');assert.equal(profile.interests,'neurotech') + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();assert.equal(document.querySelector('dialog'),null) + identity={...identity,session:{did:'did:plc:bob',handle:'bob.test'}};await render();assert.ok(document.querySelector('dialog'));await click('Skip for now');await render();assert.equal(document.querySelector('dialog'),null) + assert.equal(social.loadSocialState(localStorage,'did:plc:bob').meta.onboardingSkipped,true) + assert.equal(calls,0) + }finally{await React.act(()=>root.unmount());globalThis.fetch=prior;identity={isLoading:false,isAuthenticated:false,session:null}} +}) + +test('take a bounded test to My bench, return a negative result, and retain it across reload',async()=>{ + assert.ok(existsSync('src/components/lab/feed/InventionBench.tsx'),'Invention bench UI missing') + localStorage.clear();const Feed=source('components/lab/feed/MixedScienceFeed.tsx').default,Bench=source('components/lab/feed/InventionBench.tsx').default + let root=createRoot(document.getElementById('root'));let page=Feed + const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(page)))) + try {await render();await click('Contribute: Split-before-fit inspector');await click('Save this test to My bench');assert.match(document.body.textContent,/personal bench item/);await click('Close dialog');page=Bench;await render();assert.match(document.body.textContent,/Compare two tiny synthetic workflows/) + await click('Return a result');await fill('Result note','Held-out inputs appeared in fit. Keep this failure.');await fill('Result artifact URL','https://example.org/failure-log');await click('Outcome: did-not-work');await click('Save result to My bench');assert.match(document.body.textContent,/did-not-work/) + await React.act(()=>root.unmount());root=createRoot(document.getElementById('root'));await render();assert.match(document.body.textContent,/Held-out inputs appeared/) + }finally{await React.act(()=>root.unmount())} +}) +test('structured composer persists text but makes media preview lifetime and publication deferral explicit',async()=>{ + assert.ok(existsSync('src/components/lab/feed/InventionComposer.tsx'),'Workshop composer missing') + localStorage.clear();const C=source('components/lab/feed/InventionComposer.tsx').default + const root=createRoot(document.getElementById('root')) + try {await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C,{onClose:()=>{}})))) + await fill('Build title','Overlap calculator');await fill('What it does','Separates subject-hours from elapsed hours.');await fill('Next useful request','Try two overlapping sessions.');await fill('Artifact URL','https://example.org/notebook');await click('Save build to My bench') + assert.match(document.body.textContent,/Text saved locally/);assert.match(document.body.textContent,/not saved|not stored/) + const state=source('lib/lab-inventions.ts').loadBench(localStorage,'guest','demo').state;assert.equal(state.updates[0].title,'Overlap calculator');assert.equal(state.updates[0].stage,'idea') + }finally{await React.act(()=>root.unmount())} +}) + +test('People opens the right historical persona with local follow; Profile shows imported identity and followed bench items',async()=>{ + assert.ok(existsSync('src/components/lab/PeopleWorkbench.tsx'),'People workbench missing') + const Module=require('node:module'),load=Module._load + Module._load=function(id,...args){if(id==='@/components/lab/social/BlueskyConnections')return {__esModule:true,default:()=>React.createElement('div',null,'Auth worker connection seam')};return load.call(this,id,...args)} + const People=source('components/lab/PeopleWorkbench.tsx').default + Module._load=load + localStorage.clear();const root=createRoot(document.getElementById('root')) + const wrap=Page=>React.createElement(D.DemoCommunityProvider,{storageScope:identity.session?.did||'browser'},React.createElement(Page)) + const prior=globalThis.fetch;globalThis.fetch=async()=>({ok:true,json:async()=>({status:'ready',records:[],profile:null,limit:50,hasMore:false})}) + try{await React.act(()=>root.render(wrap(People)));assert.match(document.body.textContent,/Auth worker connection seam/);await click('View Katherine Johnson’s profile');assert.match(document.querySelector('dialog').textContent,/Measurement-tool designer/);await click('Follow in demo');await click('Close dialog') + identity={isLoading:false,isAuthenticated:true,session:{did:'did:plc:alice',handle:'alice.test',displayName:'Actual Imported Alice',avatar:'https://example.org/avatar.png'}} + const Profile=source('components/lab/ProfileWorkbench.tsx').default + await React.act(()=>root.render(wrap(Profile)));assert.match(document.body.textContent,/Actual Imported Alice/);assert.match(document.body.textContent,/did:plc:alice/);assert.match(document.body.textContent,/My bench/);assert.match(document.body.textContent,/Followed ideas/);assert.match(document.body.textContent,/Followed people/);assert.match(document.body.textContent,/Builds & tests/) + assert.doesNotMatch(document.querySelector('[aria-label="Your local follows"]').textContent,/Katherine Johnson/) + }finally{await React.act(()=>root.unmount());globalThis.fetch=prior;identity={isLoading:false,isAuthenticated:false,session:null}} +}) + +test('a slow auth restore never blanks public workshop samples or exposes saved identity work',async()=>{ + localStorage.clear();identity={isLoading:true,isAuthenticated:false,session:null} + const root=createRoot(document.getElementById('root')),C=source('components/lab/feed/MixedScienceFeed.tsx').default + try{await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C))));assert.equal(rows().length,8);assert.equal(document.querySelector('[aria-label="Follow idea: Split-before-fit inspector"]').disabled,true)}finally{await React.act(()=>root.unmount());identity={isLoading:false,isAuthenticated:false,session:null}} +}) + +test('legacy public source bookmarks preserve malformed storage instead of silently replacing it',async()=>{ + const C=source('components/lab/feed/PublicFeedSources.tsx').default,key=source('lib/lab-drafts.ts').draftKey('promising','guest') + localStorage.clear();localStorage.setItem(key,'{broken');const root=createRoot(document.getElementById('root')),oldFetch=globalThis.fetch;globalThis.fetch=async()=>({ok:true,json:async()=>({items:[],status:'empty',sourceLabel:'Test source'})}) + try{await React.act(()=>root.render(React.createElement(C)));await click('✧ Promising');assert.equal(localStorage.getItem(key),'{broken');assert.match(document.body.textContent,/preserved|not overwritten/)}finally{await React.act(()=>root.unmount());globalThis.fetch=oldFetch} +}) diff --git a/scripts/lab-following.test.mjs b/scripts/lab-following.test.mjs new file mode 100644 index 00000000..cbea795b --- /dev/null +++ b/scripts/lab-following.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const store = () => { const m = new Map(); return { getItem: k => m.get(k) ?? null, setItem: (k,v) => m.set(k,v), m } } +test('following persists per identity and mode without writing any draft keys', () => { + assert.ok(existsSync('src/lib/lab-following.ts'), 'Local subscription model is missing') + const f = source('lib/lab-following.ts'), s = store() + assert.equal(f.updateFollowing(s, 'did:plc:alice', 'live', { type: 'toggle', kind: 'ideas', id: 'marimo' }).ok, true) + assert.deepEqual(f.loadFollowing(s, 'did:plc:alice', 'live').state.ideas, ['marimo']) + assert.deepEqual(f.loadFollowing(s, 'did:plc:bob', 'live').state.ideas, []) + assert.deepEqual(f.loadFollowing(s, 'did:plc:alice', 'demo').state.ideas, []) + assert.ok([...s.m.keys()].every(k => k.startsWith('open-lab:following:v1:'))) +}) + +test('curator views and multi-tag assignments survive reload; malformed state is never overwritten', () => { + const f = source('lib/lab-following.ts'), s = store(), owner = 'guest', mode = 'demo' + assert.equal(f.updateFollowing(s, owner, mode, { type: 'save-view', name: 'Methods across fields', disciplines: ['neurotech', 'ai-robotics'] }).ok, true) + assert.deepEqual(f.loadFollowing(s, owner, mode).state.views[0], { name: 'Methods across fields', disciplines: ['neurotech', 'ai-robotics'] }) + assert.equal(f.updateFollowing(s, owner, mode, { type: 'tag-idea', id: 'draft:note:test', disciplines: ['neurotech', 'cross-field'] }).ok, true) + assert.deepEqual(f.loadFollowing(s, owner, mode).state.ideaTags['draft:note:test'], ['neurotech', 'cross-field']) + assert.equal(f.updateFollowing(s, owner, mode, { type: 'filter', feed: 'following', disciplines: ['neurotech'] }).ok, true) + assert.equal(f.loadFollowing(s, owner, mode).state.filter.feed, 'following') + const key = f.followingKey(owner, mode) + for (const raw of ['{broken', JSON.stringify({...f.emptyFollowing(owner, mode), views: 'broken'}), JSON.stringify({...f.emptyFollowing(owner, mode), disciplines: ['invented']})]) { + s.setItem(key,raw) + assert.ok(f.loadFollowing(s, owner, mode).error) + assert.equal(f.updateFollowing(s, owner, mode, {type: 'toggle', kind: 'ideas', id: 'x'}).ok, false) + assert.equal(s.getItem(key),raw) + } +}) +test('invalid or dangerous curator entries fail without changing storage', () => { + const f=source('lib/lab-following.ts'), s=store() + for (const action of [{type:'save-view',name:'',disciplines:['neurotech']},{type:'save-view',name:'bad',disciplines:['bogus']},{type:'tag-idea',id:'__proto__',disciplines:['neurotech']}]) assert.equal(f.updateFollowing(s,'guest','live',action).ok,false) + assert.equal(s.m.size,0) +}) diff --git a/scripts/lab-integration-client.test.mjs b/scripts/lab-integration-client.test.mjs new file mode 100644 index 00000000..b993fb69 --- /dev/null +++ b/scripts/lab-integration-client.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { source } from './velocity/test-source-loader.mjs'; +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa'; +const origin = 'https://lab.example.org'; +test('client publication uses official Agent with injected SDK transport, never a CMS endpoint', async () => { + globalThis.window = { location: { origin } }; + let stored; const calls = []; + const session = { sub: did, did, getTokenInfo: async () => ({ sub: did, scope: 'atproto repo:org.plresearch.lab.note?action=create', aud: 'https://pds.example.org' }), fetchHandler: async (path, init) => { + calls.push(path); + if (path.includes('createRecord')) { const body = await new Response(init.body).json(); stored = { uri: `at://${did}/${body.collection}/${body.rkey}`, cid, value: body.record }; return Response.json({ uri: stored.uri, cid }); } + return Response.json(stored); + } }; + const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }); + const client = source('lib/lab-client.ts').createLabClient(async () => { throw Error('Ghost HTTP endpoint called'); }, { session, loadConfig: async () => config }); + const receipt = await client.publish('note', { text: 'Transport fixture only', postType: 'question', field: 'neurotech' }, { public: true, experimental: true, did, action: 'create' }); + assert.equal(receipt.cid, cid); assert.equal(receipt.verification, 'pds-readback'); + assert.equal(calls.length, 2); assert.match(calls[0], /createRecord/); assert.match(calls[1], /getRecord/); + await assert.rejects(() => source('lib/lab-client.ts').createLabClient().publish('note', {}, {}), /sign in/i); +}); + + +test('notebook reads explicit DID collection pages, exposes truncation, and never uses a server records API', async () => { + const calls = []; + const client = source('lib/lab-client.ts').createLabClient(async () => { throw Error('Ghost API'); }, { listRecords: async (owner, kind, options) => { + calls.push({ owner, kind, options }); + return { authorDid: owner, kind, records: [], cursor: kind === 'note' ? 'next-page' : undefined }; + } }); + const notebook = await client.records(did); + assert.equal(calls.length, 5); assert.ok(calls.every(c => c.owner === did && c.options.limit === 30)); + assert.equal(notebook.hasMore, true); assert.equal(notebook.limit, 30); assert.deepEqual(notebook.cursors, { note: 'next-page' }); + assert.equal(notebook.profileRecord, null); + await assert.rejects(() => client.records(), /DID|identity/i); +}); diff --git a/scripts/lab-integration-return.test.mjs b/scripts/lab-integration-return.test.mjs new file mode 100644 index 00000000..7a73e4c6 --- /dev/null +++ b/scripts/lab-integration-return.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { source } from './velocity/test-source-loader.mjs'; +const { safeLabReturnTo } = source('lib/lab-oauth-config.ts'); +const uri = 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/org.plresearch.lab.note/one'; +test('safe return preserves encoded exact record links and bounded editor context without URL normalization', () => { + for (const path of [ + `/lab/record/?uri=${encodeURIComponent(uri)}&response=${encodeURIComponent(uri)}`, + '/lab/feed/?q=open%20science&type=question&field=neurotech#draft', + '/lab/bottlenecks/?case=reproducibility', '/lab/onboarding/?field=cross-field', + '/lab/demo/?thread=demo-one&person=demo-person', '/lab/efforts/', + '/lab/explorations/', '/lab/explorations/arcade/', '/lab/explorations/observatory/', + ]) assert.equal(safeLabReturnTo(path), path); + for (const path of [ + '//evil.org/lab/', '/lab/../lab/feed/', '/lab/%66eed/', '/lab/feed%2f/', + '/lab/oauth/return/', '/lab/feed/?next=/lab/', '/lab/feed/?q=%zz', + '/lab/feed/?q=%0a', '/lab/feed/?q=%5c', '/lab/feed/?field=not-a-field', + '/lab/demo/?person=../admin', '/lab/record/?uri='+encodeURIComponent(uri+' '), + '/lab/record/?uri='+encodeURIComponent(uri.replace('one','..')), + '/lab/record/?uri='+encodeURIComponent(uri.replace('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa','alice.example.org')), + '/lab/record/?uri='+encodeURIComponent(uri.replace('one','%6fne')), + '/lab/record/?uri='+encodeURIComponent(uri)+'&uri='+encodeURIComponent(uri), + '/lab/record/?response='+encodeURIComponent(uri), '/lab/feed/?q='+('a'.repeat(201)), + ]) assert.equal(safeLabReturnTo(path), '/lab/', path); +}); diff --git a/scripts/lab-integration-ui.test.mjs b/scripts/lab-integration-ui.test.mjs new file mode 100644 index 00000000..8562ad1f --- /dev/null +++ b/scripts/lab-integration-ui.test.mjs @@ -0,0 +1,145 @@ +import { test, beforeEach, afterEach, mock } from 'node:test'; +import { createRequire } from 'node:module'; +// Composed shell imports scoped styles; Node probes behavior, browser QA owns geometry. +createRequire(import.meta.url).extensions['.css'] = m => { m.exports = {}; }; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; +const dom = new JSDOM('
', { url: 'https://lab.example.org/lab/feed/?field=neurotech' }); +for (const key of ['window','document','HTMLElement','HTMLInputElement','HTMLTextAreaElement','Event','MouseEvent','KeyboardEvent','localStorage']) globalThis[key] = dom.window[key]; +globalThis.self = window; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +window.HTMLDialogElement.prototype.showModal = function () { this.open = true; }; +window.HTMLDialogElement.prototype.close = function () { this.open = false; }; +const { createRoot } = await import('react-dom/client'); +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', other = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa'; +let root, identity, scope, calls, authorizations, config, stored, failRead, deleted; +const identityModule = source('lib/lab-identity.ts'); +const services = source('components/lab/LabShell.tsx'); +const Editor = source('components/lab/RecordEditor.tsx').default; +const button = text => [...document.querySelectorAll('button')].find(b => b.textContent.includes(text)); +const click = async text => { assert.ok(button(text), text); await act(async () => { button(text).click(); await new Promise(resolve => setTimeout(resolve, 20)); }); }; +const mount = async (C=Editor, props={ kind:'note', initial:{ text:'Injected transport fixture' }, onClose(){} }) => act(async () => { root.render(React.createElement(C, props)); await new Promise(resolve=>setTimeout(resolve,20)); }); +const change = async (selector, value) => { const el = document.querySelector(selector); await act(() => { Object.getOwnPropertyDescriptor(el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, 'value').set.call(el, value); el.dispatchEvent(new window.Event('input',{ bubbles:true })); }); }; +beforeEach(() => { + localStorage.clear(); calls=[]; authorizations=[]; scope='atproto'; failRead=false; stored=null; deleted=false; + config=source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL:window.location.origin, LAB_ENABLE_PUBLISH:'true' }); + const session={ sub:did, did, getTokenInfo:async()=>({ sub:did, scope, aud:'https://pds.example.org' }), fetchHandler:async(path,init)=>{ + calls.push([path,init]); + if (path.includes('createRecord') || path.includes('putRecord')) { const body=await new Response(init.body).json(); stored={ uri:`at://${did}/${body.collection}/${body.rkey}`, cid, value:body.record }; return Response.json({ uri:stored.uri,cid }); } + if (path.includes('deleteRecord')) { deleted=true; return Response.json({}); } + if (deleted) return Response.json({error:'RecordNotFound'},{status:400}); + if (failRead) return Response.json({error:'InternalServerError'},{status:500}); + return Response.json(stored); + } }; + identity={ session:{did,handle:did}, oauthSession:session,isAuthenticated:true,isLoading:false,error:null,authorizeWrite:async(...args)=>{authorizations.push(args);scope=`atproto repo:org.plresearch.lab.${args[0]}?action=${args[1]}`;}, logout:async()=>{}, login:async()=>{} }; + mock.method(identityModule,'useLabIdentity',()=>identity); + mock.method(services,'useLab',()=>({capabilities:config,openLogin(){}})); + mock.method(globalThis,'fetch',async path=>{assert.equal(path,'/api/lab/capabilities/');return Response.json(config);}); + root=createRoot(document.getElementById('root')); +}); +afterEach(async()=>{await act(()=>root.unmount());mock.restoreAll();}); +test('identity is the dedicated Lab hook, not a placeholder or legacy CMS context', () => { + identityModule.useLabIdentity.mock.restore(); + mock.method(source('lib/lab-auth.tsx'), 'useLabAuth', () => identity); + assert.equal(identityModule.useLabIdentity().oauthSession, identity.oauthSession); + assert.equal(identityModule.useLabIdentity().session.did, did); +}); +test('explicit granular authorization never publishes; a second review writes through SDK and shows usable URI/CID receipt',async()=>{ + await mount(); await click('Review draft'); + assert.match(document.querySelector('.lab-record-preview').textContent,/experimental/i); + await act(()=>document.querySelector('input[type="checkbox"]').click()); await click('Publish public record'); + assert.equal(calls.length,0); assert.ok(button('Authorize')); + await click('Authorize'); assert.equal(calls.length,0); assert.equal(authorizations.length,1); + assert.equal(authorizations[0][0],'note'); assert.equal(authorizations[0][1],'create'); + assert.match(authorizations[0][2],/field=neurotech/); + assert.equal(document.querySelector('input[type="checkbox"]')?.checked ?? false,false); + await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); await click('Publish public record'); + assert.equal(calls.length,2); assert.match(document.body.textContent,new RegExp(cid)); + assert.ok(document.querySelector(`a[href="/lab/record/?uri=${encodeURIComponent(stored.uri)}"]`)); + assert.match(document.body.textContent,/not.*peer reviewed/i); +}); + + +test('disabled/authenticating/guest states cannot write and value edits reset consent', async()=>{ + scope='atproto repo:org.plresearch.lab.note?action=create'; + await mount(); await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); + config={...config,canPublish:false}; await mount(); assert.equal(button('Publish public record').disabled,true); + config={...config,canPublish:true}; identity={...identity,isLoading:true}; await mount(); assert.equal(button('Publish public record').disabled,true); + identity={...identity,isLoading:false}; await mount(); await change('#entry-text','Changed'); assert.ok(button('Review draft')); assert.equal(calls.length,0); + identity={...identity,isAuthenticated:false,session:null,oauthSession:null}; await mount(); assert.ok(!button('Publish public record')); +}); +test('unknown write outcome survives editor remount, retains exact URI, and blocks duplicate retry', async()=>{ + scope='atproto repo:org.plresearch.lab.note?action=create'; failRead=true; + await mount(); await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); await click('Publish public record'); + assert.ok(document.querySelector(`a[href="/lab/record/?uri=${encodeURIComponent(stored.uri)}"]`)); + const count=calls.length; + await act(()=>root.render(null)); await mount(); await click('Review draft'); + assert.match(document.body.textContent,/Unknown write outcome/); assert.equal(button('Publish public record').disabled,true); assert.equal(calls.length,count); +}); +test('bench ignores stale account reads and edits only a displayed reviewed profile CID', async()=>{ + const Profile=source('components/lab/ProfileWorkbench.tsx').default; + const profileData={workingOn:'Existing public profile',lookingFor:'Collaborators',interests:[]}; + const view={uri:`at://${did}/org.plresearch.lab.profile/self`,cid,kind:'profile',authorDid:did,data:profileData,record:{$type:'org.plresearch.lab.profile',community:'https://www.plrd.org/lab/',createdAt:'2025-01-01T00:00:00.000Z',...profileData},pds:'https://pds.example.org',provenance:'pds-https-unverified-signature'}; + stored={uri:view.uri,cid,value:view.record}; + let finish; const real=source('lib/lab-client.ts').createLabClient; + mock.method(source('lib/lab-client.ts'),'createLabClient',(...args)=>({...real(...args),records:async owner=>{ + assert.equal(owner,identity.session.did); + if (owner===other) return new Promise(resolve=>{finish=resolve}); + return {profile:profileData,profileRecord:view,records:[view],limit:30,hasMore:true,cursors:{note:'next'}}; + }})); + await mount(Profile,{}); assert.match(document.body.textContent,/up to 30 per collection/i); assert.ok(!document.body.textContent.includes('@did:')); + await click('Edit profile draft'); await change('#entry-workingOn','Reviewed update'); await click('Review draft'); + assert.match(document.body.textContent,new RegExp(cid)); + scope='atproto repo:org.plresearch.lab.profile?action=update'; + await act(()=>document.querySelector('input[type="checkbox"]').click()); await click('Publish public record'); + const put=calls.find(([path])=>path.includes('putRecord')); assert.ok(put); assert.equal((await new Response(put[1].body).json()).swapRecord,cid); + await click('Cancel'); + identity={...identity,session:{did:other,handle:other}}; await mount(Profile,{}); + identity={...identity,session:{did,handle:did}}; await mount(Profile,{}); + await act(async()=>finish({profile:{...profileData,workingOn:'STALE ACCOUNT CONTENT'},profileRecord:null,records:[],limit:30,hasMore:false})); + assert.ok(!document.body.textContent.includes('STALE ACCOUNT CONTENT')); +}); + + +test('own deletion requires exact-record review, separate grant, and a second explicit confirm before SDK deletion',async()=>{ + const data={text:'Delete only this transport fixture',postType:'question',field:'neurotech'}; + const view={uri:`at://${did}/org.plresearch.lab.note/one`,cid,kind:'note',authorDid:did,data,record:{$type:'org.plresearch.lab.note',community:'https://www.plrd.org/lab/',createdAt:'2025-01-01T00:00:00.000Z',...data},pds:'https://pds.example.org',provenance:'pds-https-unverified-signature'}; + stored={uri:view.uri,cid,value:view.record}; + const real=source('lib/lab-client.ts').createLabClient; + mock.method(source('lib/lab-client.ts'),'createLabClient',(...args)=>({...real(...args),records:async()=>({profile:null,profileRecord:null,records:deleted?[]:[view],limit:30,hasMore:false,cursors:{}})})); + await mount(source('components/lab/ProfileWorkbench.tsx').default,{}); + await click('Delete this record'); assert.match(document.querySelector('dialog').textContent,new RegExp(cid)); assert.match(document.querySelector('dialog').textContent,/copies may persist/i); + assert.equal(button('Confirm exact deletion').disabled,true); + await act(()=>document.querySelector('dialog input[type="checkbox"]').click()); await click('Confirm exact deletion'); + assert.equal(calls.length,0); await click('Authorize deletion'); assert.equal(calls.length,0); + await click('Delete this record'); assert.equal(document.querySelector('dialog input[type="checkbox"]').checked,false); + await act(()=>document.querySelector('dialog input[type="checkbox"]').click()); await click('Confirm exact deletion'); + assert.equal(deleted,true); const request=calls.find(([p])=>p.includes('deleteRecord')); assert.equal((await new Response(request[1].body).json()).swapRecord,cid); + assert.match(document.body.textContent,/RecordNotFound/); assert.equal(calls.length,3); +}); +test('Lab layout mounts the isolated provider outside the shell',async()=>{ + const {createRequire}=await import('node:module'); const require=createRequire(import.meta.url); require.extensions['.css']=()=>{}; + const tree=source('app/lab/layout.tsx').default({children:'test'}); + assert.equal(tree.type,source('lib/lab-auth.tsx').LabAuthProvider); + assert.equal(tree.props.children.type,source('components/lab/LabShell.tsx').default); +}); + + +test('a profile opened before its read completes cannot silently adopt a newer version for publishing',async()=>{ + const props={kind:'profile',initial:{workingOn:'Local profile',lookingFor:'Tools'},profileReadReady:false,onClose(){}}; + await mount(Editor,props); + await mount(Editor,{...props,profileReadReady:true}); + await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); + assert.equal(button('Publish public record').disabled,true); + assert.equal(calls.length,0); +}); +test('editing a published draft clears only its old receipt, including after a remount',async()=>{ + scope='atproto repo:org.plresearch.lab.note?action=create'; + await mount(); await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); await click('Publish public record'); + await change('#entry-text','A deliberately different next draft'); await act(()=>root.render(null)); await mount(); + assert.equal(document.querySelector('#entry-text').value,'A deliberately different next draft'); + assert.ok(!document.body.textContent.includes('Record receipt')); + await click('Review draft'); await act(()=>document.querySelector('input[type="checkbox"]').click()); assert.equal(button('Publish public record').disabled,false); +}); diff --git a/scripts/lab-interest-taxonomy-ui.test.mjs b/scripts/lab-interest-taxonomy-ui.test.mjs new file mode 100644 index 00000000..454defa9 --- /dev/null +++ b/scripts/lab-interest-taxonomy-ui.test.mjs @@ -0,0 +1,252 @@ +import { test, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' + +const require = createRequire(import.meta.url) +require.extensions['.css'] = m => { m.exports = new Proxy({}, { get: (_, p) => p === '__esModule' ? false : String(p) }) } +const dom = new JSDOM('
', { url: 'https://lab.example.org/lab/onboarding/' }) +for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'localStorage']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) +globalThis.IS_REACT_ACT_ENVIRONMENT = true +// JSDOM lacks the native dialog methods; the dialog and both pickers are real React components. +dom.window.HTMLDialogElement.prototype.showModal = function () { this.open = true } +dom.window.HTMLDialogElement.prototype.close = function () { this.open = false } +const React = await import('react') +const { createRoot } = await import('react-dom/client') +const auth = source('lib/lab-identity.ts') +const social = source('lib/lab-social.ts') +const { DISCIPLINES } = source('lib/lab-following.ts') +const { saveDraft, draftKey } = source('lib/lab-drafts.ts') +const Gate = source('components/lab/social/LabOnboardingGate.tsx').default +const { InterestOnboarding } = source('components/lab/social/InterestOnboarding.tsx') +const ownerA = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +const ownerB = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +let root, identity, networkCalls +const signedIn = did => ({ isLoading: false, isAuthenticated: true, session: { did, handle: 'synthetic.example.org', displayName: 'Synthetic scientist' } }) +const button = text => { + const el = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === text || b.getAttribute('aria-label') === text) + assert.ok(el, `Missing real control: ${text}`) + return el +} +const click = text => React.act(() => button(text).click()) +const selected = label => button(label).getAttribute('aria-pressed') === 'true' +const render = (Component, ownerId = identity.session.did) => React.act(() => root.render(React.createElement(Component, { ownerId }))) +const remount = async (Component, ownerId) => { + await React.act(() => root.unmount()) + root = createRoot(document.getElementById('root')) + await render(Component, ownerId) +} +const savedInterests = (owner = identity.session.did) => social.profileInterests(social.loadSocialState(localStorage, owner).profile.interests) + +for (const [name, Component, saveLabel] of [['first-login', Gate, 'Save and continue'], ['ongoing', InterestOnboarding, 'Save starting choices']]) test(`${name}: local interests leave all feed follows untouched and explain the separate choice`, async () => { + const following = source('lib/lab-following.ts') + // Preserve populated stores for another owner and demo mode; never create a live follow for this owner. + assert.equal(following.updateFollowing(localStorage, ownerB, 'live', { type: 'toggle', kind: 'disciplines', id: 'physics' }).ok, true) + assert.equal(following.updateFollowing(localStorage, ownerA, 'demo', { type: 'toggle', kind: 'disciplines', id: 'biology' }).ok, true) + const original = Object.fromEntries(Object.entries(localStorage)) + await render(Component) + await click('Mathematics') + await click(saveLabel) + assert.deepEqual(savedInterests(), ['math'], 'The local value must not be collapsed to its public cross-field mapping') + for (const [key, value] of Object.entries(original)) assert.equal(localStorage.getItem(key), value) + assert.equal(localStorage.getItem(following.followingKey(ownerA, 'live')), null) + assert.deepEqual(Object.keys(localStorage).filter(k => !Object.hasOwn(original, k)).sort(), [draftKey('profile', ownerA), draftKey('social', ownerA)].sort()) + await remount(InterestOnboarding) + assert.match(document.body.textContent, /Feed follows are a separate choice/) + if (Component === Gate) { + assert.equal(social.saveSocialMeta(localStorage, ownerA, { onboardingCompleted: false }).ok, true) + await remount(Gate) + assert.match(document.body.textContent, /Feed follows are a separate choice/) + } +}) + +test('recommendation links from every discipline and legacy value open a populated real destination', async () => { + const Workbench = source('components/lab/BottleneckWorkbench.tsx').default + for (const interest of [...DISCIPLINES.map(d => d.id), 'old specialty']) { + assert.equal(social.saveSocialProfile(localStorage, ownerA, { interests: [interest] }).ok, true) + await remount(InterestOnboarding) + const links = [...document.querySelectorAll('section[aria-label="Recommended starting places"] a')].map(a => a.getAttribute('href')) + assert.ok(links.length > 0) + for (const href of links) { + assert.ok(href.startsWith('/lab/bottlenecks/'), 'The default contribution mode starts with a concrete case') + window.history.replaceState(null, '', href) + await remount(Workbench) + assert.ok(document.querySelector('[data-baseline]'), `${interest}: empty destination at ${href}`) + assert.doesNotMatch(document.body.textContent, /No editorial case matches|Unknown field/) + const { BOTTLENECK_CASES } = source('lib/lab-bottlenecks.ts') + assert.ok(BOTTLENECK_CASES.some(c => c.title === document.querySelector('[data-baseline] h2').textContent)) + } + } +}) + +beforeEach(() => { + localStorage.clear() + identity = signedIn(ownerA) + networkCalls = 0 + // Only the external identity boundary is supplied. Hooks, writer, storage reader, and controls are production code. + mock.method(auth, 'useLabIdentity', () => identity) + mock.method(globalThis, 'fetch', async () => { networkCalls++; throw Error('No network is permitted in local onboarding') }) + root = createRoot(document.getElementById('root')) +}) +afterEach(async () => { + await React.act(() => root.unmount()) + mock.restoreAll() + assert.equal(networkCalls, 0, 'Local interest editing must not publish or subscribe remotely') +}) + +for (const discipline of DISCIPLINES) test(`round trip: ${discipline.label} is selectable and removable after reload in both pickers`, async () => { + await render(Gate) + await click(discipline.label) + assert.equal(selected(discipline.label), true) + await click('Save and continue') + assert.deepEqual(savedInterests(), [discipline.id]) + assert.equal(social.loadSocialState(localStorage, ownerA).meta.onboardingCompleted, true) + await remount(InterestOnboarding) + for (const offered of DISCIPLINES) assert.ok(button(offered.label)) + assert.equal(selected(discipline.label), true) + await click(discipline.label) + await click('Save starting choices') + await remount(InterestOnboarding) + assert.equal(selected(discipline.label), false) + assert.deepEqual(savedInterests(), []) + await click(discipline.label) + await click('Save starting choices') + assert.deepEqual(savedInterests(), [discipline.id]) + assert.equal(social.saveSocialMeta(localStorage, ownerA, { onboardingCompleted: false }).ok, true) + await remount(Gate) + assert.equal(selected(discipline.label), true) + await click(discipline.label) + await click('Save and continue') + assert.deepEqual(savedInterests(), []) +}) + +for (const [name, Component, saveLabel] of [['first-login', Gate, 'Save and continue'], ['ongoing', InterestOnboarding, 'Save starting choices']]) test(`${name}: max8 is visible, blocks the ninth, and every maximum set saves without loss`, async () => { + for (const excluded of DISCIPLINES) { + localStorage.clear() + await remount(Component) + const chosen = DISCIPLINES.filter(d => d.id !== excluded.id) + for (const d of chosen) await click(d.label) + assert.equal(button(excluded.label).disabled, true, 'The unselected ninth must be visibly unavailable, not silently dropped at save') + assert.match(document.body.textContent, /8 of 8 interests selected/) + assert.match(document.body.textContent, /[Dd]eselect.*before adding/) + await click(excluded.label) + assert.equal(selected(excluded.label), false) + for (const d of chosen) assert.equal(button(d.label).disabled, false, 'Selected interests stay removable at the limit') + await click(saveLabel) + assert.deepEqual(savedInterests(), chosen.map(d => d.id)) + if (Component === Gate) assert.equal(social.loadSocialState(localStorage, ownerA).meta.onboardingCompleted, true) + await remount(InterestOnboarding) + for (const d of chosen) assert.equal(selected(d.label), true) + assert.equal(selected(excluded.label), false) + await click(chosen[0].label) + assert.equal(button(excluded.label).disabled, false) + await click(excluded.label) + await click('Save starting choices') + assert.deepEqual(savedInterests(), [...chosen.slice(1).map(d => d.id), excluded.id]) + } +}) + +const legacy = ['digital-human-rights', 'economies-governance', 'ai-robotics', 'neurotech', 'cross-field', 'archived-specialty', 'custom interest'] +for (const [name, Component, saveLabel] of [['first-login', Gate, 'Save and continue'], ['ongoing', InterestOnboarding, 'Save starting choices']]) { + test(`${name}: old IDs and custom interests survive and can be explicitly toggled off before saving`, async () => { + const profile = { interests: legacy, workingOn: 'Keep this work', lookingFor: 'A reviewer', githubUrl: 'https://github.com/example' } + assert.equal(saveDraft(localStorage, 'profile', ownerA, profile).ok, true) + await render(Component) + for (const id of legacy) assert.equal(selected(DISCIPLINES.find(d => d.id === id)?.label || id), true) + await click('Mathematics') + assert.match(document.body.textContent, /8 of 8 interests selected/) + await click(saveLabel) + assert.deepEqual(savedInterests(), [...legacy, 'math']) + assert.deepEqual(social.loadSocialState(localStorage, ownerA).profile, { ...profile, interests: [...legacy, 'math'].join(', ') }) + if (Component === Gate) assert.equal(social.saveSocialMeta(localStorage, ownerA, { onboardingCompleted: false }).ok, true) + await remount(Component) + await click('custom interest') + assert.equal(selected('custom interest'), false) + await click('custom interest') // Undo an explicit removal before saving. + assert.equal(selected('custom interest'), true) + await click('custom interest') + await click('archived-specialty') + await click(saveLabel) + await remount(InterestOnboarding) + assert.deepEqual(savedInterests(), [...legacy.slice(0, 5), 'math']) + assert.doesNotMatch(document.body.textContent, /custom interest|archived-specialty/) + }) + + test(`${name}: an inherited over-limit set stays intact until explicit removals make it saveable`, async () => { + const inherited = [...DISCIPLINES.map(d => d.id), 'old specialty'] + assert.equal(saveDraft(localStorage, 'profile', ownerA, { interests: inherited, workingOn: 'Keep over-limit original' }).ok, true) + const original = localStorage.getItem(draftKey('profile', ownerA)) + await render(Component) + assert.match(document.body.textContent, /10 of 8 interests selected/) + assert.equal(button(saveLabel).disabled, true) + for (const d of DISCIPLINES) assert.equal(button(d.label).disabled, false) + assert.equal(button('old specialty').disabled, false) + await click(saveLabel) + assert.equal(localStorage.getItem(draftKey('profile', ownerA)), original) + await click('old specialty') + assert.equal(button(saveLabel).disabled, true) + await click('Mathematics') + assert.equal(button(saveLabel).disabled, false) + await click(saveLabel) + assert.deepEqual(savedInterests(), DISCIPLINES.filter(d => d.id !== 'math').map(d => d.id)) + assert.equal(social.loadSocialState(localStorage, ownerA).profile.workingOn, 'Keep over-limit original') + }) +} + +for (const [name, Component, saveLabel] of [['first-login', Gate, 'Save and continue'], ['ongoing', InterestOnboarding, 'Save starting choices']]) { + test(`${name}: owner switching never carries unsaved interests or overwrites another identity`, async () => { + assert.equal(social.saveSocialProfile(localStorage, ownerA, { interests: ['math', 'A specialty'], workingOn: 'A work' }).ok, true) + assert.equal(social.saveSocialProfile(localStorage, ownerB, { interests: ['physics', 'B specialty'], workingOn: 'B work' }).ok, true) + const originalA = localStorage.getItem(draftKey('profile', ownerA)) + await render(Component) + await click('Mathematics') + await click('Biology') + identity = signedIn(ownerB) + await render(Component, ownerB) + assert.equal(selected('Physics'), true) + assert.equal(selected('Biology'), false) + assert.equal(selected('Mathematics'), false) + assert.doesNotMatch(document.body.textContent, /A specialty/) + await click('Materials science') + await click(saveLabel) + assert.deepEqual(savedInterests(ownerB), ['physics', 'B specialty', 'materials']) + assert.equal(localStorage.getItem(draftKey('profile', ownerA)), originalA) + identity = signedIn(ownerA) + await render(Component, ownerA) + assert.equal(selected('Mathematics'), true) + assert.equal(selected('Biology'), false) + assert.equal(selected('A specialty'), true) + }) + + for (const failure of ['throw', 'silent']) test(`${name}: ${failure} storage writes keep originals and unsaved choices available for retry`, async () => { + assert.equal(social.saveSocialProfile(localStorage, ownerA, { interests: ['math', 'custom interest'], workingOn: 'Preserved work' }).ok, true) + const original = localStorage.getItem(draftKey('profile', ownerA)) + await render(Component) + await click('Physics') + const blocked = mock.method(dom.window.Storage.prototype, 'setItem', () => { if (failure === 'throw') throw Error('Quota exceeded') }) + await click(saveLabel) + assert.match(document.querySelector('[role="alert"]').textContent, /Could not save/) + assert.equal(localStorage.getItem(draftKey('profile', ownerA)), original) + assert.equal(selected('Physics'), true) + assert.equal(selected('custom interest'), true) + assert.equal(social.loadSocialState(localStorage, ownerA).meta.onboardingCompleted, false) + blocked.mock.restore() + await click(saveLabel) + assert.deepEqual(savedInterests(), ['math', 'custom interest', 'physics']) + await remount(InterestOnboarding) + assert.equal(selected('Physics'), true) + assert.equal(selected('custom interest'), true) + }) + + for (const slot of ['profile', 'social']) test(`${name}: unreadable ${slot} prevents saving over any existing local data`, async () => { + assert.equal(social.saveSocialProfile(localStorage, ownerA, { interests: ['math', 'custom interest'], workingOn: 'Preserved original' }).ok, true) + localStorage.setItem(draftKey(slot, ownerA), '{broken') + const original = Object.fromEntries(Object.entries(localStorage)) + await render(Component) + await click('Physics') + await click(saveLabel) + assert.match(document.querySelector('[role="alert"]').textContent, /could not be read/) + assert.deepEqual(Object.fromEntries(Object.entries(localStorage)), original) + }) +} diff --git a/scripts/lab-interest-taxonomy.test.mjs b/scripts/lab-interest-taxonomy.test.mjs new file mode 100644 index 00000000..d33d8ecc --- /dev/null +++ b/scripts/lab-interest-taxonomy.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const social = source('lib/lab-social.ts') +const { DISCIPLINES } = source('lib/lab-following.ts') +function memoryStore() { + const map = new Map() + return { get length() { return map.size }, key: i => [...map.keys()][i] ?? null, getItem: k => map.get(k) ?? null, setItem: (k, v) => map.set(k, v) } +} + +for (const discipline of DISCIPLINES) test(`completed ${discipline.label} alone is a local starting area after writer reload`, () => { + const storage = memoryStore() + assert.equal(social.saveSocialProfile(storage, 'guest', { interests: [discipline.id], workingOn: 'A concrete question', lookingFor: 'An independent reviewer' }).ok, true) + assert.equal(social.saveSocialMeta(storage, 'guest', { onboardingCompleted: true }).ok, true) + const state = social.loadSocialState(storage, 'guest') + assert.equal(social.profileCompletion(state.profile).percent, 100) + assert.deepEqual(social.localNextActions(state), [], 'Completed valid local interests must not be contradicted by a missing-area nudge') +}) + +const { BOTTLENECK_CASES, BOTTLENECK_FIELDS } = source('lib/lab-bottlenecks.ts') + +test('the real writer rejects nine without trimming and keeps public field mapping separate', () => { + const store = memoryStore() + const { draftKey } = source('lib/lab-drafts.ts') + const ids = DISCIPLINES.map(d => d.id) + assert.equal(social.saveSocialProfile(store, 'guest', { interests: ids.slice(0, 8) }).ok, true) + const original = store.getItem(draftKey('profile', 'guest')) + const rejected = social.saveSocialProfile(store, 'guest', { interests: ids }) + assert.equal(rejected.ok, false) + assert.match(rejected.error, /up to 8/) + assert.equal(store.getItem(draftKey('profile', 'guest')), original) + const { protocolFieldForDiscipline } = source('lib/lab-following.ts') + const { validateLabData } = source('lib/lab-validation.ts') + for (const id of ['math', 'physics', 'biology', 'materials']) { + assert.equal(protocolFieldForDiscipline(id), 'cross-field') + assert.throws(() => validateLabData('note', { text: 'Test note', field: id, postType: 'question' })) + assert.equal(social.saveSocialProfile(store, 'guest', { interests: [id] }).ok, true) + assert.deepEqual(social.profileInterests(social.loadSocialState(store, 'guest').profile.interests), [id]) + } +}) +for (const interest of [...DISCIPLINES, { id: 'legacy specialty', label: 'legacy specialty' }]) test(`${interest.label}: recommendations reference existing routes and populated cases, not invented field IDs`, () => { + for (const mode of ['', 'evidence', 'tools', 'intervention']) { + const places = social.recommendStartingPlaces([interest.id], mode) + assert.ok(places.length > 0) + assert.equal(new Set(places.map(p => p.id)).size, places.length) + assert.equal(new Set(places.map(p => p.href)).size, places.length, 'Do not repeat the fallback case for evidence mode') + for (const place of places) { + const url = new URL(place.href, 'https://lab.example.org') + assert.equal(url.origin, 'https://lab.example.org') + assert.ok(url.pathname.endsWith('/')) + assert.ok(existsSync(`src/app${url.pathname}page.tsx`), place.href) + if (url.pathname === '/lab/bottlenecks/') { + const caseId = url.searchParams.get('case') + const field = url.searchParams.get('field') + const target = BOTTLENECK_CASES.find(c => c.id === caseId) + assert.ok(target, `Recommendation must name a real case: ${place.href}`) + assert.ok(!field || BOTTLENECK_FIELDS.some(([id]) => id === field), `Unknown field: ${field}`) + assert.ok(!field || target.focusArea === field || target.relatedFields.includes(field), `Empty field/case combination: ${place.href}`) + } + } + assert.ok(places.some(p => p.why.includes(interest.label)), 'Explain the actual local choice, including saved custom values') + if (!BOTTLENECK_CASES.some(c => c.focusArea === interest.id || c.relatedFields.includes(interest.id))) { + assert.match(places.map(p => p.why).join(' '), /no .*specific.*case|no .*case.*specific/i, 'Disclose cross-field fallback rather than imply discipline-specific content') + } + assert.deepEqual(places, social.recommendStartingPlaces([interest.id], mode)) + } +}) diff --git a/scripts/lab-invention-loop-model.test.mjs b/scripts/lab-invention-loop-model.test.mjs new file mode 100644 index 00000000..22de036d --- /dev/null +++ b/scripts/lab-invention-loop-model.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {source} from './velocity/test-source-loader.mjs' +const b=source('lib/lab-inventions.ts'),media=source('lib/lab-invention-media.ts') +const store=()=>{const m=new Map();return {getItem:k=>m.get(k)??null,setItem:(k,v)=>m.set(k,v),m}} +const update=(id='build-one')=>({id,kind:'prototype',stage:'idea',title:'Test jig',summary:'Measure two cases.',request:'Check one case.',artifactUrl:'https://example.org/jig',disciplines:['math']}) +const task=(id='invention:build-one')=>({id,title:'Test jig',request:'Check one case.',artifactUrl:'https://example.org/jig',artifact:'Jig specification',sourceId:'invention:build-one'}) + +test('unknown/corrupt storage and unknown media fields fail closed without rewriting the original',()=>{ + for(const raw of ['{broken',JSON.stringify({version:2,owner:'guest',mode:'demo',updates:[],drafts:[],tasks:[]}),JSON.stringify({version:1,owner:'other',mode:'demo',updates:[],tasks:[]}),JSON.stringify({version:1,owner:'guest',mode:'demo',updates:[{...update(),media:[{url:'blob:lost'}]}],tasks:[]}),JSON.stringify({version:1,owner:'guest',mode:'demo',updates:[],tasks:[],futureSchema:{data:'preserve'}})]){ + const s=store(),key=b.benchKey('guest','demo');s.setItem(key,raw) + assert.ok(b.loadBench(s,'guest','demo').error,'Must reject '+raw) + assert.equal(b.changeBench(s,'guest','demo',{type:'save-update',update:update()}).ok,false);assert.equal(s.getItem(key),raw) + } +}) + +test('same-ID edits preserve original timestamp/media, cannot create a missing edit target, and discard only that edit draft',async()=>{ + const s=store(),photo=new File([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3ioAAAAASUVORK5CYII=','base64')],'one.png',{type:'image/png'}),m=await media.storeInventionMedia(photo) + assert.equal(b.changeBench(s,'guest','demo',{type:'save-update',update:{...update(),media:[m]}}).ok,true) + const original=b.loadBench(s,'guest','demo').state.updates[0] + assert.equal(b.changeBench(s,'guest','demo',{type:'save-update',requireExisting:true,update:{...update(),title:'Revised',createdAt:'2000-01-01T00:00:00.000Z'}}).ok,true) + assert.equal(b.changeBench(s,'guest','demo',{type:'save-update',requireExisting:true,update:update('unknown')}).ok,false) + const saved=b.loadBench(s,'guest','demo').state.updates[0] + assert.equal(saved.createdAt,original.createdAt);assert.deepEqual(saved.media,[m]);assert.equal(saved.id,original.id) + for(const id of ['build-one','another-draft'])assert.equal(b.changeBench(s,'guest','demo',{type:'save-draft',draft:{...update(id),summary:''}}).ok,true) + assert.equal(b.changeBench(s,'guest','demo',{type:'discard-draft',id:'build-one'}).ok,true) + const state=b.loadBench(s,'guest','demo').state;assert.deepEqual(state.updates,[saved]);assert.deepEqual(state.drafts.map(d=>d.id),['another-draft']) +}) + +test('quota, unreadable and unconfirmed storage never report success',()=>{ + const s=store(),key=b.benchKey('guest','demo');b.changeBench(s,'guest','demo',{type:'save-update',update:update()});const original=s.getItem(key) + for(const bad of [{getItem:()=>{throw Error('read denied')},setItem:()=>assert.fail('must not write')},{getItem:s.getItem,setItem:()=>{throw Error('quota exceeded')}},{getItem:s.getItem,setItem:()=>{}}]){ + assert.equal(b.changeBench(bad,'guest','demo',{type:'save-update',update:{...update(),title:'Unsaved'}}).ok,false);assert.equal(s.getItem(key),original) + } +}) + +test('legacy task snapshots are never linked by title or silently remapped when revisited',()=>{ + const s=store(),legacy={id:'legacy',title:'Test jig',request:'Old exact request',artifactUrl:'https://example.org/old'} + assert.equal(b.changeBench(s,'guest','demo',{type:'take-task',task:legacy}).ok,true) + assert.equal(b.changeBench(s,'guest','demo',{type:'take-task',task:{...task(),id:'legacy'}}).ok,true) + assert.deepEqual(b.loadBench(s,'guest','demo').state.tasks,[legacy]) + assert.equal(b.changeBench(s,'guest','demo',{type:'return-result',id:'missing',result:{note:'No target',artifactUrl:'',outcome:'worked'}}).ok,false) + assert.equal(b.changeBench(s,'guest','demo',{type:'take-task',task:task()}).ok,true) + assert.equal(b.changeBench(s,'guest','demo',{type:'take-task',task:{...task(),sourceId:'different'}}).ok,false) + const state=b.loadBench(s,'guest','demo').state;assert.equal(state.tasks.length,2);assert.deepEqual(state.tasks[0],legacy) + assert.equal(b.loadBench(s,'someone-else','demo').state.tasks.length,0);assert.equal(b.loadBench(s,'guest','live').state.tasks.length,0) +}) + +test('local attachments reject object URLs, corrupt encodings, unsupported types and excessive media',async()=>{ + const s=store(),photo=new File([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3ioAAAAASUVORK5CYII=','base64')],'one.png',{type:'image/png'}),m=await media.storeInventionMedia(photo) + for(const bad of [{...m,dataUrl:'blob:temporary'},{...m,dataUrl:'data:image/png;base64,bm90LWEtcG5n'},{...m,type:'image/svg+xml'},{...m,caption:'x'.repeat(501)}])assert.equal(b.changeBench(s,'guest','demo',{type:'save-update',update:{...update(),media:[bad]}}).ok,false) + await assert.rejects(media.storeInventionMedia(new File([new Uint8Array(media.INVENTION_MEDIA_LIMIT+1)],'large.mp4',{type:'video/mp4'})),/2 MiB/) + assert.equal(media.validInventionMedia(Array.from({length:5},(_,i)=>({...m,id:String(i)}))),false) + assert.equal(b.loadBench(s,'guest','demo').state.updates.length,0) +}) diff --git a/scripts/lab-invention-loop-ui.test.mjs b/scripts/lab-invention-loop-ui.test.mjs new file mode 100644 index 00000000..39dcfdb6 --- /dev/null +++ b/scripts/lab-invention-loop-ui.test.mjs @@ -0,0 +1,188 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {createRequire} from 'node:module' +import {JSDOM} from 'jsdom' +import {source} from './velocity/test-source-loader.mjs' +const require=createRequire(import.meta.url) +require.extensions['.css']=m=>{m.exports=new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)})} +const dom=new JSDOM('
',{url:'http://localhost/lab/feed/'}) +for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','HTMLSelectElement','HTMLDialogElement','Event','KeyboardEvent','StorageEvent','localStorage'])Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +HTMLDialogElement.prototype.showModal=function(){this.open=true} +HTMLDialogElement.prototype.close=function(){this.open=false} +globalThis.self=window;globalThis.IS_REACT_ACT_ENVIRONMENT=true +const React=await import('react'),{createRoot}=await import('react-dom/client') +let identity={isLoading:false,isAuthenticated:false,session:null} +source('lib/lab-identity.ts').useLabIdentity=()=>identity +const D=source('components/lab/demo/DemoCommunityProvider.tsx'),b=source('lib/lab-inventions.ts') +const Composer=source('components/lab/feed/InventionComposer.tsx').default +const click=async label=>{const e=[...document.querySelectorAll('button,a')].find(e=>e.getAttribute('aria-label')===label||e.textContent.trim()===label);assert.ok(e,'Missing action: '+label);await React.act(()=>e.click());return e} +const fill=async(label,value)=>{const e=document.querySelector(`[aria-label="${label}"]`);assert.ok(e,'Missing input: '+label);await React.act(()=>{Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,value);e.dispatchEvent(new Event('input',{bubbles:true}))})} +const mount=async(C,props={})=>{const root=createRoot(document.getElementById('root'));const render=()=>React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,{storageScope:identity.session?.did||'browser'},React.createElement(C,props))));await render();return {root,render,unmount:()=>React.act(()=>root.unmount())}} +let network=0 +globalThis.fetch=()=>{network++;throw Error('External writes/reads forbidden in invention loop')} + +test('incomplete build survives close/reopen and saves exactly one stable draft ID',async()=>{ + localStorage.clear();let closed=false + let app=await mount(Composer,{onClose:()=>{closed=true}}) + try{await fill('Build title','Unfinished overlap jig');await click('Close dialog');assert.equal(closed,true)}finally{await app.unmount()} + app=await mount(Composer,{onClose:()=>{}}) + try{ + assert.equal(document.querySelector('[aria-label="Build title"]').value,'Unfinished overlap jig') + const draft=b.loadBench(localStorage,'guest','demo').state.drafts[0] + await fill('What it does','Separates elapsed and subject hours.');await fill('Next useful request','Test two overlapping recordings.');await click('Save build to My bench') + const state=b.loadBench(localStorage,'guest','demo').state + assert.equal(state.updates.length,1);assert.equal(state.updates[0].id,draft.id);assert.ok(state.updates[0].createdAt);assert.equal(state.drafts.length,0) + }finally{await app.unmount()} + assert.equal(network,0) +}) + +const choose=async file=>{const input=document.querySelector('input[type=file]');assert.ok(input);Object.defineProperty(input,'files',{value:[file],configurable:true});await React.act(async()=>{input.dispatchEvent(new Event('change',{bubbles:true}));await new Promise(r=>setTimeout(r,0))})} +const photo=()=>new File([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3ioAAAAASUVORK5CYII=','base64')],'jig.png',{type:'image/png'}) +test('saved build reopens by ID, revises without duplication, and preserves createdAt, media bytes and captions on reload',async()=>{ + localStorage.clear();let app=await mount(Composer,{onClose:()=>{}}) + try{ + await fill('Build title','Media jig');await fill('What it does','Inspect the prototype wiring.');await fill('Next useful request','Check the polarity.');await choose(photo());await fill('Caption for jig.png','First wiring');await fill('Alt text for jig.png','Prototype leads');await click('Save build to My bench') + }finally{await app.unmount()} + const original=b.loadBench(localStorage,'guest','demo').state.updates[0] + assert.equal(original.media?.length,1,'Selected bytes must persist with the saved build') + assert.equal(original.media[0].caption,'First wiring');assert.ok(!JSON.stringify(original).includes('blob:')) + app=await mount(source('components/lab/feed/InventionBench.tsx').default) + try{ + await click('Edit build: Media jig');assert.equal(document.querySelector('[aria-label="Build title"]').value,'Media jig');assert.ok(document.querySelector('img')) + await fill('Build title','Media jig revised');await fill('Caption for jig.png','Reversed leads');await click('Save build to My bench') + }finally{await app.unmount()} + const state=b.loadBench(localStorage,'guest','demo').state + assert.equal(state.updates.length,1);assert.equal(state.updates[0].id,original.id);assert.equal(state.updates[0].createdAt,original.createdAt);assert.equal(state.updates[0].media[0].dataUrl,original.media[0].dataUrl);assert.equal(state.updates[0].media[0].caption,'Reversed leads') + app=await mount(Composer,{updateId:original.id,onClose:()=>{}}) + try{assert.equal(document.querySelector('[aria-label="Build title"]').value,'Media jig revised');assert.equal(document.querySelector('[aria-label="Caption for jig.png"]').value,'Reversed leads');assert.equal(document.querySelector('img').alt,'Prototype leads')}finally{await app.unmount()} + assert.equal(network,0) +}) + +const model=source('lib/lab-feed-model.ts'),demoModel=source('lib/lab-demo.ts') +const Detail=source('components/lab/feed/FeedDetail.tsx').default +const feedRow=updates=>model.buildFeedRows({isDemo:true,demo:demoModel.emptyDemoState(),drafts:[],updates})[0] +test('fresh and existing Split-before-fit returns open the exact local task drawer, keep artifact context, and attach results at source',async()=>{ + localStorage.clear();const row=feedRow([]) + for(const outcome of ['did-not-work','worked']){ + const app=await mount(Detail,{row,onClose:()=>{}}) + try{ + const recipes=[...document.querySelectorAll('a')].find(e=>e.textContent==='Browse other task recipes →') + assert.ok(recipes,'No matching recipe must not promise an unrelated run packet') + assert.ok(!document.body.textContent.includes('Prepare a run packet')) + await click('Return a result →') + const editor=[...document.querySelectorAll('dialog')].find(d=>d.querySelector('[aria-label="Result note"]')) + assert.ok(editor,'Return must save/select this task and open its editor, not navigate to an empty bench') + assert.ok(editor.textContent.includes(row.title));assert.ok(editor.textContent.includes(row.request));assert.ok(editor.textContent.includes(row.artifact)) + await fill('Result note',`Exact split test: ${outcome}`);await click(`Outcome: ${outcome}`);await click('Save result to My bench') + const state=b.loadBench(localStorage,'guest','demo').state + assert.equal(state.tasks.length,1);assert.equal(state.tasks[0].id,row.ideaId);assert.equal(state.tasks[0].sourceId,row.ideaId);assert.equal(state.tasks[0].title,row.title);assert.equal(state.tasks[0].request,row.request);assert.equal(state.tasks[0].artifact,row.artifact) + assert.ok(document.querySelector('[aria-label="Results for this source"]').textContent.includes(`Exact split test: ${outcome}`)) + }finally{await app.unmount()} + } + const app=await mount(Detail,{row,onClose:()=>{}}) + try{assert.ok(document.querySelector('[aria-label="Results for this source"]').textContent.includes('Exact split test: worked'))}finally{await app.unmount()} + assert.equal(network,0) +}) + +test('discard confirmation removes only the selected unfinished draft, never another draft or its saved build',async()=>{ + localStorage.clear() + const seed=id=>({id,kind:'prototype',stage:'idea',title:id,summary:'',request:'',artifactUrl:'',disciplines:[]}) + for(const id of ['draft-one','draft-two'])assert.equal(b.changeBench(localStorage,'guest','demo',{type:'save-draft',draft:seed(id)}).ok,true) + const app=await mount(Composer,{draftId:'draft-one',onClose:()=>{}}) + try{ + await click('Discard this draft…');assert.equal(b.loadBench(localStorage,'guest','demo').state.drafts.length,2) + await click('Keep editing');assert.equal(document.querySelector('[aria-label="Build title"]').value,'draft-one') + await click('Discard this draft…');await click('Confirm discard this draft') + assert.deepEqual(b.loadBench(localStorage,'guest','demo').state.drafts.map(d=>d.id),['draft-two']) + }finally{await app.unmount()} +}) + +test('My bench exposes each incomplete draft for explicit resume, and rejects stale task query context',async()=>{ + localStorage.clear() + for(const id of ['first-draft','second-draft'])assert.equal(b.changeBench(localStorage,'guest','demo',{type:'save-draft',draft:{id,kind:'prototype',stage:'idea',title:id,summary:'',request:'',artifactUrl:'',disciplines:[]}}).ok,true) + window.history.replaceState(null,'','/lab/profile/?task=missing&scope=demo%3Asomeone-else') + const app=await mount(source('components/lab/feed/InventionBench.tsx').default) + try{ + assert.match(document.querySelector('[role="alert"]')?.textContent||'',/task.*link|context|query/i) + await click('Resume draft: first-draft');assert.equal(document.querySelector('[aria-label="Build title"]').value,'first-draft') + await fill('What it does','Recovered the first draft.');await fill('Next useful request','Try one test.');await click('Save build to My bench');assert.match(document.body.textContent,/Text saved locally/) + assert.equal(b.loadBench(localStorage,'guest','demo').state.updates[0].id,'first-draft');assert.deepEqual(b.loadBench(localStorage,'guest','demo').state.drafts.map(d=>d.id),['second-draft']) + }finally{await app.unmount();window.history.replaceState(null,'','/lab/feed/')} +}) + +// Real 16×16 blue H.264 clip, generated by ffmpeg; JSDOM does not decode media. +const video=()=>new File([Buffer.from("AAAAJGZ0eXBpc29tAAACAGlzb21pc282aXNvMmF2YzFtcDQxAAAC7G1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAA...AABj//wAAADRhdmNDAWQACv/hABdnZAAKrNlewEQAAAMABAAAAwAQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAEHN0dHMAAAAAAAAAAAAAABBzdHNjAAAAAAAAAAAAAAAUc3RzegAAAAAAAAAAAAAAAAAAABBzdGNvAAAAAAAAAAAAAAAobXZleAAAACB0cmV4AAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAYXVkdGEAAABZbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAsaWxzdAAAACSpdG9vAAAAHGRhdGEAAAABAAAAAExhdmY2MS43LjEwMwAAAHhtb29mAAAAEG1maGQAAAAAAAAAAQAAAGB0cmFmAAAAJHRmaGQAAAA5AAAAAQAAAAAAAAMQAAAgAAAAAssBAQAAAAAAFHRmZHQBAAAAAAAAAAAAAAAAAAAgdHJ1bgAAAgUAAAACAAAAgAIAAAAAAALLAAAADAAAAt9tZGF0AAACrQYF//+p3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTIgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD00MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTIzLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAAAWZYiEABT//uzafgU3DFb59dMhveABgQAAAAhBmiFsQS/+4AAAAENtZnJhAAAAK3RmcmEBAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAADEAEBAQAAABBtZnJvAAAAAAAAAEM=",'base64')],'test.mp4',{type:'video/mp4'}) + +test('complete workshop loop: unfinished build → resume → save → edit same card → negative return → reload source/detail and bench',async()=>{ + localStorage.clear();const Feed=source('components/lab/FeedWorkbench.tsx').default + let app=await mount(Feed) + try{ + await click('What are you making? Show a build →');await fill('Build title','Polarity tester');await choose(photo());await choose(video());await fill('Caption for test.mp4','First local test');await fill('Alt text for jig.png','Polarity jig') + await click('Close dialog');assert.equal(document.querySelector('dialog'),null) + await click('What are you making? Show a build →');assert.equal(document.querySelector('[aria-label="Build title"]').value,'Polarity tester');assert.ok(document.querySelector('video').controls);assert.equal(document.querySelector('video').autoplay,false) + await fill('What it does','Catch reversed leads.');await fill('Next useful request','Try the polarity reversal.');await fill('Artifact URL','https://example.org/polarity');await click('Save build to My bench');await click('Close dialog') + const initial=b.loadBench(localStorage,'guest','demo').state.updates[0] + await click('Open details: Polarity tester');await click('Edit build: Polarity tester');await fill('What it does','Catch reversed leads; note the false negatives.');await fill('Caption for test.mp4','Test conditions: swapped leads');await click('Save build to My bench');await click('Close dialog') + await click('Return a result →');await fill('Result note','Negative: swapped leads were missed. Keep this counterexample.');await fill('Result artifact URL','https://example.org/counterexample');await click('Outcome: did-not-work');await click('Save result to My bench') + assert.match(document.querySelector('[aria-label="Results for this source"]').textContent,/swapped leads were missed/) + await click('Close dialog');assert.equal(document.querySelectorAll(`[data-feed-row="invention:${initial.id}"]`).length,1) + const state=b.loadBench(localStorage,'guest','demo').state + assert.equal(state.updates.length,1);assert.equal(state.tasks.length,1);assert.equal(state.updates[0].createdAt,initial.createdAt);assert.equal(state.updates[0].id,initial.id);assert.equal(state.updates[0].media.length,2);assert.equal(state.drafts.length,0) + }finally{await app.unmount()} + app=await mount(Feed) + try{ + await click('Open details: Polarity tester');const result=document.querySelector('[aria-label="Results for this source"]');assert.match(result.textContent,/did-not-work/);assert.match(result.textContent,/swapped leads were missed/);assert.ok(document.querySelector('video').src.startsWith('data:video/mp4;base64,'));assert.match(document.querySelector('dialog').textContent,/Test conditions: swapped leads/) + await click('Return a result →');assert.match(document.querySelector('[aria-label="Result note"]').value,/swapped leads were missed/);await click('Close dialog') + assert.equal(b.loadBench(localStorage,'guest','demo').state.tasks.length,1) + }finally{await app.unmount()} + app=await mount(source('components/lab/feed/InventionBench.tsx').default) + try{const article=[...document.querySelectorAll('article')].find(a=>a.querySelector('[aria-label="Edit build: Polarity tester"]'));assert.match(article.querySelector('[aria-label="Results for this source"]').textContent,/swapped leads were missed/)}finally{await app.unmount()} + assert.equal(network,0) +}) + +test('identity change during composing/saving preserves A’s draft but never copies it or late media to B/live/guest',async()=>{ + localStorage.clear();identity={isLoading:false,isAuthenticated:true,session:{did:'did:plc:alice'}} + let app=await mount(Composer,{onClose:()=>{}}),release + try{ + await fill('Build title','Alice unfinished');await fill('What it does','Private local scope A');await fill('Next useful request','One local test') + const a=b.loadBench(localStorage,'did:plc:alice','demo').state.drafts[0],file=photo(),originalArray=file.arrayBuffer.bind(file) + file.arrayBuffer=()=>new Promise(r=>{release=async()=>r(await originalArray())}) + await choose(file);assert.ok(release);assert.equal([...document.querySelectorAll('button')].find(e=>e.textContent==='Save build to My bench').disabled,true) + identity={isLoading:false,isAuthenticated:true,session:{did:'did:plc:bob'}};await app.render() + assert.equal(document.querySelector('[aria-label="Build title"]').value,'') + await React.act(async()=>{await release();await new Promise(r=>setTimeout(r,0))}) + assert.equal(document.querySelector('img'),null) + await fill('Build title','Bob build');await fill('What it does','Scope B only');await fill('Next useful request','Bob test');await click('Save build to My bench') + assert.equal(b.loadBench(localStorage,'did:plc:alice','demo').state.drafts[0].id,a.id);assert.equal(b.loadBench(localStorage,'did:plc:alice','demo').state.updates.length,0) + assert.equal(b.loadBench(localStorage,'did:plc:bob','demo').state.updates[0].title,'Bob build');assert.equal(b.loadBench(localStorage,'did:plc:bob','demo').state.updates[0].media,undefined) + identity={isLoading:true,isAuthenticated:false,session:null};await app.render();assert.equal(document.querySelector('[aria-label="Build title"]'),null) + identity={isLoading:false,isAuthenticated:false,session:null};await app.render();assert.equal(document.querySelector('[aria-label="Build title"]').value,'') + assert.equal(b.loadBench(localStorage,'guest','demo').state.updates.length,0);assert.equal(b.loadBench(localStorage,'did:plc:alice','live').state.drafts.length,0) + }finally{await app.unmount();identity={isLoading:false,isAuthenticated:false,session:null}} +}) + +test('quota failure keeps current edits open and never reports saved; retry restores the same local draft',async()=>{ + localStorage.clear();let closed=false;const app=await mount(Composer,{onClose:()=>{closed=true}}),proto=window.Storage.prototype,original=proto.setItem + try{ + await fill('Build title','Safe old draft');const prior=localStorage.getItem(b.benchKey('guest','demo')) + proto.setItem=function(k,v){if(k.startsWith('open-lab:inventions:'))throw Error('Quota test');return original.call(this,k,v)} + await fill('What it does','Keep unsaved work visible');await fill('Next useful request','Retry storage');await click('Save build to My bench');await click('Close dialog') + assert.equal(closed,false);assert.equal(document.querySelector('[aria-label="What it does"]').value,'Keep unsaved work visible');assert.ok(!document.body.textContent.includes('Text saved locally'));assert.match(document.body.textContent,/Quota test/);assert.equal(localStorage.getItem(b.benchKey('guest','demo')),prior) + proto.setItem=original;await click('Save build to My bench');assert.match(document.body.textContent,/Text saved locally/);assert.equal(b.loadBench(localStorage,'guest','demo').state.updates.length,1) + }finally{proto.setItem=original;await app.unmount()} +}) + +test('unknown IDs and stale identity drawers error without creating or remapping a task; legacy results stay unassociated',async()=>{ + localStorage.clear();const Return=source('components/lab/feed/ReturnResult.tsx').default + for(const props of [{taskId:'unknown',scope:'demo:guest'},{taskId:'known',scope:'demo:someone-else'}]){ + const app=await mount(Return,{...props,onClose:()=>{}}) + try{assert.match(document.querySelector('[role="alert"]').textContent,/Unknown task|changed identity/);assert.equal(document.querySelector('[aria-label="Result note"]'),null)}finally{await app.unmount()} + } + let app=await mount(Composer,{updateId:'unknown',onClose:()=>{}}) + try{assert.match(document.querySelector('[role="alert"]').textContent,/Unknown build/);assert.equal(document.querySelector('[aria-label="Build title"]'),null)}finally{await app.unmount()} + const row=feedRow([]),legacy={id:row.ideaId,title:row.title,request:'Older saved request',artifactUrl:'https://example.org/older'} + b.changeBench(localStorage,'guest','demo',{type:'take-task',task:legacy}) + app=await mount(Detail,{row,onClose:()=>{}}) + try{await click('Return a result →');assert.match(document.querySelector('dialog').textContent,/Older saved request/);assert.match(document.querySelector('dialog').textContent,/no recorded source association/);await fill('Result note','Older task observation');await click('Save result to My bench');assert.equal(document.querySelector('[aria-label="Results for this source"]'),null);assert.equal(b.loadBench(localStorage,'guest','demo').state.tasks[0].sourceId,undefined)}finally{await app.unmount()} + assert.equal(network,0) +}) diff --git a/scripts/lab-inventions.test.mjs b/scripts/lab-inventions.test.mjs new file mode 100644 index 00000000..daa7dc32 --- /dev/null +++ b/scripts/lab-inventions.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const store=()=>{const m=new Map();return {getItem:k=>m.get(k)??null,setItem:(k,v)=>m.set(k,v),m}} +test('a bounded task reaches My bench, reloads, and accepts an evidence return without claiming external completion',()=>{ + assert.ok(existsSync('src/lib/lab-inventions.ts'),'Invention bench model missing') + const b=source('lib/lab-inventions.ts'),s=store() + const task={id:'split-check',title:'Inspect fitted row IDs',request:'Run two synthetic splits, log fit inputs; stop after two cases.',artifactUrl:'https://marimo.io/'} + assert.equal(b.changeBench(s,'did:plc:a','demo',{type:'take-task',task}).ok,true) + assert.equal(b.changeBench(s,'did:plc:a','demo',{type:'take-task',task}).ok,true) + let state=b.loadBench(s,'did:plc:a','demo').state;assert.equal(state.tasks.length,1);assert.equal(state.tasks[0].result,undefined) + assert.equal(b.changeBench(s,'did:plc:a','demo',{type:'return-result',id:task.id,result:{note:'Fit saw held-out rows; counterexample kept.',artifactUrl:'https://example.org/log',outcome:'did-not-work'}}).ok,true) + state=b.loadBench(s,'did:plc:a','demo').state;assert.equal(state.tasks[0].result.outcome,'did-not-work') + assert.equal(b.loadBench(s,'did:plc:b','demo').state.tasks.length,0);assert.equal(b.loadBench(s,'did:plc:a','live').state.tasks.length,0) + const key=b.benchKey('did:plc:a','demo');s.setItem(key,'{broken');assert.equal(b.changeBench(s,'did:plc:a','demo',{type:'take-task',task}).ok,false);assert.equal(s.getItem(key),'{broken') +}) + +test('structured invention updates save an honest stage, artifact and next request; no public validation status can be invented',()=>{ + const b=source('lib/lab-inventions.ts'),s=store() + const update={id:'build-1',kind:'prototype',stage:'idea',title:'A duration comparison app',summary:'Keep elapsed and subject-hours separate.',artifactUrl:'',request:'Help implement an overlap check.',disciplines:['neurotech','math']} + assert.equal(b.changeBench(s,'guest','live',{type:'save-update',update}).ok,true) + assert.equal(b.loadBench(s,'guest','live').state.updates[0].stage,'idea') + assert.equal(b.changeBench(s,'guest','live',{type:'save-update',update:{...update,id:'bad',stage:'externally-validated'}}).ok,false) + assert.equal(b.changeBench(s,'guest','live',{type:'save-update',update:{...update,id:'bad',artifactUrl:'javascript:alert(1)'}}).ok,false) + assert.equal(b.loadBench(s,'guest','demo').state.updates.length,0) +}) diff --git a/scripts/lab-launch-transition.test.mjs b/scripts/lab-launch-transition.test.mjs new file mode 100644 index 00000000..d8ca0bef --- /dev/null +++ b/scripts/lab-launch-transition.test.mjs @@ -0,0 +1,358 @@ +import { test, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import React, { act } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' + +const require = createRequire(import.meta.url) +// Only CSS module naming is shimmed; TSX, React, events and DOM are real. +require.extensions['.css'] = module => { module.exports = { cover: 'launch-cover' } } +const componentPath = 'src/components/OpenLabLaunchLink.tsx' +const loadLink = () => { + assert.ok(existsSync(componentPath), 'the progressively enhanced launch link must exist') + return source('components/OpenLabLaunchLink.tsx').default +} + +const dom = new JSDOM('
', { + url: 'https://plrd.org/', +}) +for (const key of ['window', 'document', 'HTMLElement', 'MouseEvent', 'Event']) globalThis[key] = dom.window[key] +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const { createRoot } = await import('react-dom/client') +// jsdom cannot load a new document. Spy ONLY at its final navigation boundary: +// the real Location.assign still parses and resolves the component's URL. +const { implForWrapper } = require('jsdom/lib/generated/idl/utils.js') +const { serializeURL } = createRequire(require.resolve('jsdom'))('whatwg-url') +let root, animations, navigations, reducedMotion, motionQuery +beforeEach(() => { + animations = [] + navigations = [] + reducedMotion = false + motionQuery = new window.EventTarget() + Object.defineProperty(motionQuery, 'matches', { get: () => reducedMotion }) + window.matchMedia = () => motionQuery + window.CSS = { supports: () => true } + mock.method(implForWrapper(window.location), '_locationObjectNavigate', url => navigations.push(serializeURL(url))) + // WAAPI/layout are browser-only. Keep the real component/DOM around these seams. + window.HTMLElement.prototype.animate = function (frames, options) { + let resolve, reject + const finished = new Promise((yes, no) => { resolve = yes; reject = no }) + const animation = { element: this, frames, options, finished, resolve, reject, cancelled: 0, + cancel() { this.cancelled++; reject(new Error('Animation cancelled')) }, + } + animations.push(animation) + return animation + } + mock.timers.enable({ apis: ['setTimeout'] }) + root = createRoot(document.getElementById('root')) +}) +afterEach(async () => { + await act(() => root.unmount()) + mock.restoreAll() + mock.timers.reset() + document.querySelectorAll('.launch-cover, base').forEach(node => node.remove()) +}) +const mount = async (props = {}) => { + await act(() => root.render(React.createElement(loadLink(), { href: '/lab/', children: 'Explore the lab →', ...props }))) + const anchor = document.querySelector('#root a') + anchor.getBoundingClientRect = () => ({ left: 100, top: 200, width: 400, height: 80 }) + return anchor +} +const activate = async (anchor, init = {}, type = 'click') => { + let intercepted + // Observe the real React handler, then stop jsdom's unimplemented native load. + document.addEventListener(type, event => { intercepted = event.defaultPrevented; event.preventDefault() }, { once: true }) + const event = new window.MouseEvent(type, { + bubbles: true, cancelable: true, button: 0, detail: 1, clientX: 150, clientY: 220, ...init, + }) + await act(() => anchor.dispatchEvent(event)) + return intercepted +} + +test('a primary click covers from its viewport point then navigates to the real href and removes the cover', async () => { + const anchor = await mount() + assert.equal(await activate(anchor), true, 'enhancement should own a plain click') + assert.equal(animations.length, 1) + const animation = animations[0] + assert.equal(animation.element.parentElement, document.body) + assert.equal(animation.element.getAttribute('aria-hidden'), 'true') + assert.equal(animation.frames[0].clipPath, 'circle(0px at 150px 220px)') + const radius = Math.ceil(Math.hypot(Math.max(150, window.innerWidth - 150), Math.max(220, window.innerHeight - 220))) + assert.equal(animation.frames[1].clipPath, `circle(${radius}px at 150px 220px)`) + assert.equal(animation.options.duration, 220) + assert.deepEqual(navigations, []) + await act(async () => animation.resolve()) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animation.element.isConnected, false) + assert.equal(animation.cancelled, 1) + const css = require('postcss').parse(readFileSync('src/components/OpenLabLaunchLink.module.css', 'utf8')) + const rule = css.nodes.find(node => node.selector === '.cover') + const declarations = Object.fromEntries(rule.nodes.map(node => [node.prop, node.value])) + assert.equal(declarations.position, 'fixed') + assert.equal(declarations.inset, '0') + assert.equal(declarations['pointer-events'], 'none') + assert.equal(declarations['clip-path'], 'circle(0px at 0 0)') +}) + +test('only ordinary current-tab document activation is enhanced', async () => { + const cases = [ + [{}, { ctrlKey: true }], [{}, { metaKey: true }], [{}, { shiftKey: true }], [{}, { altKey: true }], + [{}, { button: 1 }], [{}, { button: 1 }, 'auxclick'], [{}, { button: 2 }, 'auxclick'], + [{ target: '_blank' }, {}], [{ target: 'workbench' }, {}], [{ target: '_parent' }, {}], + [{ target: '_top' }, {}], [{ download: '' }, {}], + [{ href: 'mailto:hello@example.org' }, {}], [{ href: '#focus-areas' }, {}], + [{ onClick: event => event.preventDefault() }, {}, 'click', true], + ] + for (const [props, event, type = 'click', cancelled = false] of cases) { + const anchor = await mount(props) + assert.equal(await activate(anchor, event, type), cancelled, JSON.stringify({ props, event, type })) + assert.equal(animations.length, 0) + assert.equal(document.querySelector('.launch-cover'), null) + assert.deepEqual(navigations, []) + } + const base = document.createElement('base') + base.target = '_blank' + document.head.appendChild(base) + assert.equal(await activate(await mount()), false, 'honor the document base target too') + assert.equal(animations.length, 0) +}) + +test('keyboard and assistive activation originates at the CTA center without stealing focus', async () => { + const anchor = await mount({ target: '_SELF' }) + anchor.focus() + assert.equal(await activate(anchor, { detail: 0, clientX: 0, clientY: 0 }), true) + assert.equal(animations[0].frames[0].clipPath, 'circle(0px at 300px 240px)') + assert.equal(document.activeElement, anchor) + await act(async () => animations[0].resolve()) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) +}) + +test('reduced motion leaves native navigation immediate and has a CSS safety net', async () => { + reducedMotion = true + assert.equal(await activate(await mount()), false) + assert.equal(animations.length, 0) + assert.equal(document.querySelector('.launch-cover'), null) + assert.deepEqual(navigations, []) + const css = require('postcss').parse(readFileSync('src/components/OpenLabLaunchLink.module.css', 'utf8')) + const media = css.nodes.find(node => node.name === 'media' && node.params === '(prefers-reduced-motion: reduce)') + assert.ok(media) + assert.equal(media.nodes[0].selector, '.cover') + assert.ok(media.nodes[0].nodes.some(node => node.prop === 'display' && node.value === 'none')) +}) + +test('unsupported motion or clip-path APIs never intercept native navigation', async () => { + const supportedMedia = window.matchMedia + const supportedCSS = window.CSS + const supportedAnimate = window.HTMLElement.prototype.animate + for (const unsupported of [ + () => { window.CSS = { supports: () => false } }, + () => { window.CSS = undefined }, + () => { window.CSS = {} }, + () => { window.CSS = { supports: () => { throw new Error('Unavailable API') } } }, + () => { window.matchMedia = undefined }, + () => { window.HTMLElement.prototype.animate = undefined }, + ]) { + unsupported() + assert.equal(await activate(await mount()), false) + assert.equal(animations.length, 0) + assert.equal(document.querySelector('.launch-cover'), null) + assert.deepEqual(navigations, []) + window.matchMedia = supportedMedia + window.CSS = supportedCSS + window.HTMLElement.prototype.animate = supportedAnimate + } +}) + +test('a stalled animation is bounded by 320 ms and a late finish cannot navigate twice', async () => { + await activate(await mount()) + await act(() => mock.timers.tick(319)) + assert.deepEqual(navigations, []) + assert.ok(animations[0].element.isConnected) + await act(() => mock.timers.tick(1)) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animations[0].element.isConnected, false) + assert.equal(animations[0].cancelled, 1) + await act(async () => { animations[0].resolve(); mock.timers.tick(1000) }) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) +}) + +test('repeated plain clicks share one departure while modified clicks remain native', async () => { + const anchor = await mount() + await activate(anchor) + assert.equal(await activate(anchor), true) + assert.equal(await activate(anchor, { metaKey: true }), false) + assert.equal(animations.length, 1) + await act(async () => animations[0].resolve()) + assert.equal(await activate(anchor), true, 'do not issue a second load while the first document load is pending') + await act(() => mock.timers.tick(1000)) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animations.length, 1) +}) + +test('unmount cancels the pending departure and cannot navigate later', async () => { + await activate(await mount()) + await act(() => root.render(null)) + assert.equal(animations[0].element.isConnected, false) + assert.equal(animations[0].cancelled, 1) + await act(async () => { animations[0].resolve(); mock.timers.tick(1000) }) + assert.deepEqual(navigations, []) +}) + +test('page lifecycle and history exits cancel the cover and restored pages can launch again', async () => { + const anchor = await mount() + for (const type of ['pagehide', 'popstate', 'pageshow']) { + await activate(anchor) + const animation = animations.at(-1) + await act(() => window.dispatchEvent(new window.Event(type))) + assert.equal(animation.element.isConnected, false, type) + await act(async () => { animation.resolve(); mock.timers.tick(1000) }) + assert.deepEqual(navigations, []) + } + assert.equal(animations.length, 3) + await activate(anchor) + await act(async () => animations.at(-1).resolve()) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) +}) + +test('animation setup failure leaves the original click native with no stranded cover', async () => { + const supportedAnimate = window.HTMLElement.prototype.animate + const errors = [] + const onError = event => { errors.push(event.error); event.preventDefault() } + window.addEventListener('error', onError) + try { + for (const fail of [ + () => { throw new Error('Fixture setup failure') }, + () => ({ cancel() {} }), // partial implementation without Animation.finished + ]) { + window.HTMLElement.prototype.animate = fail + assert.equal(await activate(await mount()), false) + assert.equal(document.querySelector('.launch-cover'), null) + await act(() => mock.timers.tick(1000)) + assert.deepEqual(navigations, []) + } + assert.deepEqual(errors, []) + } finally { + window.HTMLElement.prototype.animate = supportedAnimate + window.removeEventListener('error', onError) + } + await activate(await mount()) + await act(async () => animations[0].resolve()) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) +}) + +test('a partially broken animation cancel API cannot strand navigation', async () => { + await activate(await mount()) + animations[0].cancel = () => { throw new Error('Fixture cancellation failure') } + await act(async () => animations[0].resolve()) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animations[0].element.isConnected, false) + await act(() => mock.timers.tick(1000)) + assert.equal(navigations.length, 1) +}) + +test('enabling reduced motion during the cover immediately completes the departure', async () => { + const removeListener = mock.method(motionQuery, 'removeEventListener') + await activate(await mount()) + reducedMotion = true + await act(() => motionQuery.dispatchEvent(new window.Event('change'))) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animations[0].element.isConnected, false) + assert.ok(removeListener.mock.calls.some(call => call.arguments[0] === 'change')) + await act(() => mock.timers.tick(1000)) + assert.equal(navigations.length, 1) +}) + +test('the exact cross-origin destination uses normal document navigation without View Transitions', async () => { + assert.equal(document.startViewTransition, undefined) + const href = 'https://open-lab.example/lab/?entry=public%2Fhome#workbench' + const anchor = await mount({ href }) + assert.equal(anchor.getAttribute('href'), href) + assert.equal(await activate(anchor), true) + await act(async () => animations[0].resolve()) + assert.deepEqual(navigations, [href]) + await act(() => window.dispatchEvent(new window.Event('pageshow'))) + window.CSS.supports = () => false + assert.equal(await activate(anchor), false, 'unsupported cross-origin case stays native too') + assert.equal(animations.length, 1) +}) + +test('a rejected animation completes navigation once instead of leaving a cover', async () => { + await activate(await mount()) + await act(async () => animations[0].reject(new Error('Fixture asynchronous animation failure'))) + assert.deepEqual(navigations, ['https://plrd.org/lab/']) + assert.equal(animations[0].element.isConnected, false) + await act(() => mock.timers.tick(1000)) + assert.equal(navigations.length, 1) +}) + +test('launching has no theme, storage, scroll-lock or automatic mount effects', async () => { + const html = document.documentElement + const previousClass = html.className + const previousBodyStyle = document.body.getAttribute('style') + html.classList.add('dark') + document.body.style.paddingRight = '13px' + window.localStorage.setItem('theme', 'dark') + window.sessionStorage.setItem('fixture', 'unchanged') + const htmlBefore = html.getAttribute('style') + const bodyBefore = document.body.getAttribute('style') + try { + const anchor = await mount() + await act(() => mock.timers.tick(1000)) + assert.equal(animations.length, 0) + assert.deepEqual(navigations, []) + await activate(anchor) + assert.equal(html.getAttribute('style'), htmlBefore) + assert.equal(document.body.getAttribute('style'), bodyBefore) + await act(async () => animations[0].resolve()) + assert.ok(html.classList.contains('dark')) + assert.equal(html.getAttribute('style'), htmlBefore) + assert.equal(document.body.getAttribute('style'), bodyBefore) + assert.equal(window.localStorage.getItem('theme'), 'dark') + assert.equal(window.localStorage.length, 1) + assert.equal(window.sessionStorage.getItem('fixture'), 'unchanged') + assert.equal(window.sessionStorage.length, 1) + const implementation = readFileSync(componentPath, 'utf8') + assert.doesNotMatch(implementation, /next\/(?:navigation|link)|localStorage|sessionStorage|startViewTransition|lab-auth|lab-identity|scrollTo/) + const css = require('postcss').parse(readFileSync('src/components/OpenLabLaunchLink.module.css', 'utf8')) + css.walkRules(rule => assert.equal(rule.selector, '.cover', 'all style rules belong only to the transient cover')) + } finally { + html.className = previousClass + if (previousBodyStyle === null) document.body.removeAttribute('style') + else document.body.setAttribute('style', previousBodyStyle) + window.localStorage.clear() + window.sessionStorage.clear() + } +}) + +test('a refused document load clears the cover and leaves the next attempt native', async () => { + mock.method(implForWrapper(window.location), '_locationObjectNavigate', () => { throw new Error('Fixture refused navigation') }) + const anchor = await mount() + await activate(anchor) + await act(async () => animations[0].resolve()) + assert.equal(animations[0].element.isConnected, false) + assert.equal(await activate(anchor), false, 'a failed load must not leave a permanently inert invitation') + assert.equal(animations.length, 1) +}) + +test('server markup is a named native /lab/ anchor without JavaScript or an overlay', () => { + const Link = loadLink() + const markup = renderToStaticMarkup(React.createElement(Link, { + href: '/lab/', className: 'invitation-styles', + children: React.createElement('span', null, 'Made something that makes science easier?'), + })) + const doc = new JSDOM(markup).window.document + assert.equal(doc.body.children.length, 1) + const link = doc.querySelector('a') + assert.ok(link) + assert.equal(link.getAttribute('href'), '/lab/') + assert.equal(link.textContent, 'Made something that makes science easier?') + assert.equal(link.className, 'invitation-styles') + assert.equal(link.getAttribute('role'), null) + assert.equal(link.getAttribute('tabindex'), null) + assert.equal(doc.querySelector('[aria-hidden]'), null) + const home = readFileSync('src/app/page.tsx', 'utf8') + assert.match(home, /{m.exports={}} +const dom=new JSDOM('
',{url:'http://localhost/'}) +for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','Event'])Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +globalThis.IS_REACT_ACT_ENVIRONMENT=true +const React=await import('react'),{createRoot}=await import('react-dom/client') +test('photo/video selection previews locally, edits alt/caption, removes, and cleans object URLs on scope change',async()=>{ + assert.ok(existsSync('src/components/lab/feed/MediaPicker.tsx'),'Media picker missing') + const C=source('components/lab/feed/MediaPicker.tsx').default,revoked=[];let seq=0 + const originalCreate=URL.createObjectURL,originalRevoke=URL.revokeObjectURL;URL.createObjectURL=()=>`blob:test-${++seq}`;URL.revokeObjectURL=u=>revoked.push(u) + const root=createRoot(document.getElementById('root'));let calls=0;const oldFetch=globalThis.fetch;globalThis.fetch=()=>{calls++;throw Error('No media upload')} + const render=scope=>React.act(()=>root.render(React.createElement(C,{scope}))) + const choose=async file=>{const input=document.querySelector('input[type=file]');Object.defineProperty(input,'files',{value:[file],configurable:true});await React.act(async()=>{input.dispatchEvent(new Event('change',{bubbles:true}));await new Promise(r=>setTimeout(r,0))})} + try{await render('demo:alice');await choose(new File([new Uint8Array([137,80,78,71,13,10,26,10,0])],'photo.png',{type:'image/png'}));assert.ok(document.querySelector('img[src^="blob:"]')) + const alt=document.querySelector('[aria-label="Alt text for photo.png"]');await React.act(()=>{Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(alt,'Wiring of the prototype');alt.dispatchEvent(new Event('input',{bubbles:true}))});assert.equal(document.querySelector('img').alt,'Wiring of the prototype') + await choose(new File([new Uint8Array([0,0,0,16,102,116,121,112,109,112,52,50,0,0,0,0])],'clip.mp4',{type:'video/mp4'}));const v=document.querySelector('video');assert.ok(v);assert.equal(v.autoplay,false);assert.equal(v.controls,true) + assert.ok(document.querySelector('a[download="photo.png"]'));await React.act(()=>document.querySelector('[aria-label="Remove photo.png"]').click());assert.ok(revoked.includes('blob:test-1')) + await render('live:bob');assert.equal(document.querySelector('video'),null);assert.ok(revoked.includes('blob:test-2'));assert.equal(calls,0) + }finally{await React.act(()=>root.unmount());URL.createObjectURL=originalCreate;URL.revokeObjectURL=originalRevoke;globalThis.fetch=oldFetch} +}) diff --git a/scripts/lab-media.test.mjs b/scripts/lab-media.test.mjs new file mode 100644 index 00000000..776c7373 --- /dev/null +++ b/scripts/lab-media.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const png=new Uint8Array([137,80,78,71,13,10,26,10,0]) +test('media session enforces MIME/signature/size limits, never uploads, and revokes on remove/dispose and pending close',async()=>{ + assert.ok(existsSync('src/lib/lab-media.ts'),'Local media preview missing') + const m=source('lib/lab-media.ts'),created=[],revoked=[],url={createObjectURL:f=>{const u='blob:local/'+created.length;created.push(f);return u},revokeObjectURL:u=>revoked.push(u)} + const s=m.createMediaSession('demo:alice',url) + await assert.rejects(()=>s.add(new File([''],'bad.svg',{type:'image/svg+xml'})),/PNG|JPEG|WebP/) + await assert.rejects(()=>s.add(new File([''],'fake.png',{type:'image/png'})),/signature/) + await assert.rejects(()=>s.add(new File([new Uint8Array(m.IMAGE_LIMIT+1)],'huge.png',{type:'image/png'})),/limit/) + const item=await s.add(new File([png],'test.png',{type:'image/png'}));assert.equal(item.scope,'demo:alice');assert.equal(item.kind,'image');assert.equal(created.length,1) + s.remove(item.id);assert.deepEqual(revoked,['blob:local/0']) + const item2=await s.add(new File([png],'again.png',{type:'image/png'}));s.dispose();assert.ok(revoked.includes(item2.url)) + await assert.rejects(()=>s.add(new File([png],'late.png',{type:'image/png'})),/closed/) + const next=m.createMediaSession('live:bob',url);assert.equal(next.items.length,0);next.dispose() +}) diff --git a/scripts/lab-oauth-connections.test.mjs b/scripts/lab-oauth-connections.test.mjs new file mode 100644 index 00000000..b5bc54bb --- /dev/null +++ b/scripts/lab-oauth-connections.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const configLib = source('lib/lab-oauth-config.ts') +const origin = 'https://lab.example.org', did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' + +test('native connection capability is separate from custom publication and declares only follow create/delete', async () => { + const config = configLib.getLabOAuthConfig({ LAB_PUBLIC_URL: origin }) + assert.equal(config.canConnect, true) + assert.equal(config.canPublish, false) + assert.ok(config.metadata.scope.split(' ').includes('repo:app.bsky.graph.follow?action=create&action=delete')) + assert.doesNotMatch(config.metadata.scope, /transition:|rpc:|repo:\*/) + assert.equal(configLib.getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_CONNECT: 'false' }).canConnect, false) + assert.equal(configLib.getLabOAuthConfig({ LAB_PUBLIC_URL: '' }).canConnect, false) + for (const flag of ['', 'TRUE', 'typo']) assert.equal(configLib.getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_CONNECT: flag }).canConnect, false) + const calls = [], redirects = [] + let allow = true, deleted, resolve + const runtime = source('lib/lab-auth.tsx').createLabAuthRuntime({ + loadConfig: async () => configLib.getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_CONNECT: allow ? 'true' : 'false' }), + loadClient: async (_, cb) => { deleted = cb; return { init: async () => ({ session: { sub: did, did, signOut: async () => {} } }), authorize: async (actor, opts) => { calls.push([actor, opts]); return resolve ? new Promise(r => { resolve = r }) : new URL('https://auth.example.org/consent') } } }, + location: () => ({ origin, pathname: '/lab/profile/' }), replace: x => redirects.push(x), + }) + await runtime.initialize() + for (const action of ['create', 'delete']) { + await runtime.authorizeConnection(action, '/lab/profile/#connections') + assert.deepEqual(calls.at(-1), [did, { scope: `atproto repo:app.bsky.graph.follow?action=${action}`, state: '/lab/profile/#connections', prompt: 'consent' }]) + } + await assert.rejects(() => runtime.authorizeConnection('update'), /permission/i) + allow = false; await assert.rejects(() => runtime.authorizeConnection('create'), /disabled/i) + allow = true; resolve = true + const pending = runtime.authorizeConnection('create', '/lab/profile/#connections') + await new Promise(r => setImmediate(r)); deleted(did) + resolve(new URL('https://auth.example.org/consent')); await pending + assert.equal(redirects.length, 2, 'invalidated authorization does not navigate') +}) diff --git a/scripts/lab-oauth-preview.test.mjs b/scripts/lab-oauth-preview.test.mjs new file mode 100644 index 00000000..bd76c8eb --- /dev/null +++ b/scripts/lab-oauth-preview.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const { getLabOAuthConfig, configForBrowser } = source('lib/lab-oauth-config.ts') +const alias = 'plrdorg-git-feat-open-lab-protocol.vercel.app' +const env = { VERCEL: '1', VERCEL_ENV: 'preview', VERCEL_URL: 'example-unique-team.vercel.app', VERCEL_BRANCH_URL: alias } + +test('connection return route is allowed without opening external or generic redirects', () => { + const { safeLabReturnTo } = source('lib/lab-oauth-config.ts') + assert.equal(safeLabReturnTo('/lab/people/#bluesky-connections'), '/lab/people/#bluesky-connections') + assert.equal(safeLabReturnTo('/lab/people/?next=https://attacker.example.org'), '/lab/') +}) + +test('trusted deployment branch alias is the stable exact preview OAuth identity', async () => { + const config = getLabOAuthConfig(env) + assert.equal(config.origin, `https://${alias}`) + assert.equal(configForBrowser(config, `https://${alias}`).canSignIn, true) + assert.equal(config.oauthVerified, false) + assert.equal(configForBrowser(config, `https://${env.VERCEL_URL}`).canSignIn, false) + assert.equal(configForBrowser(config, 'https://another-git-branch-team.vercel.app').canSignIn, false) + const previous = { ...process.env } + try { + delete process.env.LAB_PUBLIC_URL + Object.assign(process.env, env) + const response = source('app/api/lab/oauth/client-metadata.json/route.ts').GET(new Request(config.clientId, { headers: { host: 'attacker.example.org', forwarded: 'host=attacker.example.org', 'x-forwarded-host': 'attacker.example.org' } })) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), config.metadata) + const caps = source('app/api/lab/capabilities/route.ts').GET(new Request(`https://${alias}/api/lab/capabilities/`)) + assert.equal((await caps.json()).canSignIn, true) + } finally { for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key]; Object.assign(process.env, previous) } +}) + +test('explicit empty disables; inherited production and malformed branch identities fail closed', () => { + assert.equal(getLabOAuthConfig({ ...env, LAB_PUBLIC_URL: '' }).canSignIn, false) + assert.equal(getLabOAuthConfig({ ...env, LAB_PUBLIC_URL: 'https://www.example.org' }).canSignIn, false) + assert.equal(getLabOAuthConfig({ ...env, LAB_PUBLIC_URL: `https://${alias}` }).canSignIn, true) + for (const bad of ['', 'evil.example.org', 'https://branch.vercel.app', '-bad.vercel.app', 'bad-.vercel.app', 'a'.repeat(64)+'.vercel.app', 'branch.vercel.app/path', 'branch.vercel.app:443', 'branch.vercel.app\n']) { + assert.equal(getLabOAuthConfig({ ...env, VERCEL_BRANCH_URL: bad }).canSignIn, false, JSON.stringify(bad)) + } + assert.equal(getLabOAuthConfig({ ...env, VERCEL: undefined }).canSignIn, false) + assert.equal(getLabOAuthConfig({ ...env, VERCEL_ENV: 'production' }).origin, `https://${env.VERCEL_URL}`) + assert.equal(getLabOAuthConfig({ ...env, VERCEL_BRANCH_URL: undefined }).origin, `https://${env.VERCEL_URL}`) +}) diff --git a/scripts/lab-origin-proxy.test.mjs b/scripts/lab-origin-proxy.test.mjs new file mode 100644 index 00000000..15559587 --- /dev/null +++ b/scripts/lab-origin-proxy.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' + +// Actual Next dev Requests use http://localhost:3390 even for a caller +// on the explicitly configured 127.0.0.1:3390 authority. Headers may only +// match the preconfigured identity, never create a different identity. +test('capabilities recognize a preconfigured authority behind an internal listener', async () => { + const before={...process.env} + try { + delete process.env.VERCEL + process.env.NODE_ENV='development' + process.env.LAB_PUBLIC_URL='http://127.0.0.1:3390' + const route=source('app/api/lab/capabilities/route.ts') + const response=route.GET(new Request('http://localhost:3390/api/lab/capabilities/',{headers:{host:'127.0.0.1:3390','x-forwarded-proto':'http'}})) + const config=await response.json() + assert.equal(config.canSignIn,true) + assert.equal(config.origin,'http://127.0.0.1:3390') + assert.equal(config.canPublish,false) + assert.equal(config.oauthVerified,false) + const {configForBrowser}=source('lib/lab-oauth-config.ts') + assert.equal(configForBrowser(config,'http://127.0.0.1:3390').canSignIn,true) + assert.equal(configForBrowser(config,'http://localhost:3390').canSignIn,false) + } finally {process.env=before} +}) + +test('internal HTTPS metadata routing never trusts an alternate host or forwarded host', async () => { + const before={...process.env} + try { + delete process.env.VERCEL + process.env.LAB_PUBLIC_URL='https://lab-review.example.org' + const caps=source('app/api/lab/capabilities/route.ts'),meta=source('app/api/lab/oauth/client-metadata.json/route.ts') + const request=(url,headers)=>new Request(url,{headers}) + const valid={host:'lab-review.example.org','x-forwarded-proto':'https','x-forwarded-host':'ignored.example.org'} + const result=meta.GET(request('http://localhost:3000/api/lab/oauth/client-metadata.json',valid)) + assert.equal(result.status,200) + const data=await result.json() + assert.equal(data.client_id,'https://lab-review.example.org/api/lab/oauth/client-metadata.json') + assert.deepEqual(data.redirect_uris,['https://lab-review.example.org/lab/oauth/return/']) + for(const [url,headers] of [ + ['http://localhost:3000',{...valid,host:'other.example.org'}], + ['http://localhost:3000',{...valid,host:'lab-review.example.org:444'}], + ['http://localhost:3000',{...valid,'x-forwarded-proto':'http'}], + ['http://localhost:3000',{'x-forwarded-host':'lab-review.example.org','x-forwarded-proto':'https'}], + ['https://attacker.example.org',valid], + ['http://localhost:3000',{...valid,'x-forwarded-proto':'https,http'}], + ]) { + const req=request(url+'/api/lab/capabilities/',headers) + assert.equal((await caps.GET(req).json()).canSignIn,false,JSON.stringify([url,headers])) + assert.equal(meta.GET(req).status,404) + } + } finally {process.env=before} +}) diff --git a/scripts/lab-protocol-auth.test.mjs b/scripts/lab-protocol-auth.test.mjs new file mode 100644 index 00000000..745fc3ea --- /dev/null +++ b/scripts/lab-protocol-auth.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +const origin = 'https://lab.example.org' +function harness({ configPatch = {}, result, initError } = {}) { + const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }) + const calls = []; const redirects = []; const listeners = new Map() + const oauthSession = { sub: did, did, signOut: async () => { calls.push(['signOut']) } } + const client = { init: async () => { calls.push(['init']); if (initError) throw initError; return result === 'callback' ? { session: oauthSession, state: '/lab/profile/#draft' } : result === 'restore' ? { session: oauthSession } : undefined }, authorize: async (handle, options) => { calls.push(['authorize', handle, options]); return new URL('https://auth.example.org/authorize') }, addEventListener: (name, cb) => listeners.set(name, cb) } + const runtime = source('lib/lab-auth.tsx').createLabAuthRuntime({ loadConfig: async () => ({ ...config, ...configPatch }), loadClient: async (c, onDeleted) => { calls.push(['load', c.clientId]); listeners.set('deleted', onDeleted); return client }, location: () => ({ origin, pathname: '/lab/oauth/return/' }), replace: path => redirects.push(path) }) + return { runtime, calls, redirects, listeners, configPatch } +} + +test('OAuth return route renders accessible progress without claiming a publication', async () => { + const React = await import('react') + const { renderToStaticMarkup } = await import('react-dom/server') + const Page = source('app/lab/oauth/return/page.tsx').default + const html = renderToStaticMarkup(React.createElement(Page)) + assert.match(html, /role="status"/) + assert.match(html, /Nothing is published automatically/) + assert.match(html, /href="\/lab\/"/) +}) + +test('handle-only sign-in requests identity, permission escalation requests one exact action and never auto-publishes', async () => { + const h = harness({ result: 'restore' }) + await h.runtime.initialize() + for (const input of ['https://pds.example.org', 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', '@science.bsky.social', 'science.bsky.social ', 'bad']) await assert.rejects(() => h.runtime.login(input)) + assert.equal(h.calls.filter(c => c[0] === 'authorize').length, 0) + await h.runtime.login('science.bsky.social', '//evil.example.org/') + assert.deepEqual(h.calls.at(-1), ['authorize', 'science.bsky.social', { scope: 'atproto', state: '/lab/' }]) + await h.runtime.authorizeWrite('note', 'create', '/lab/feed/#draft') + assert.deepEqual(h.calls.at(-1), ['authorize', did, { scope: 'atproto repo:org.plresearch.lab.note?action=create', state: '/lab/feed/#draft', prompt: 'consent' }]) + h.configPatch.canPublish = false + await assert.rejects(() => h.runtime.authorizeWrite('note', 'create'), /disabled/) +}) + +test('unconfigured origins, SDK callback errors and invalidated sessions never produce a fake identity', async () => { + const unavailable = harness({ configPatch: { origin: 'https://wrong.example.org' } }) + await unavailable.runtime.initialize() + assert.equal(unavailable.calls.length, 0) + assert.equal(unavailable.runtime.getSnapshot().isAuthenticated, false) + await assert.rejects(() => unavailable.runtime.login('science.bsky.social')) + const failed = harness({ initError: new Error('sensitive OAuth state') }) + await failed.runtime.initialize() + assert.equal(failed.runtime.getSnapshot().session, null) + assert.equal(failed.runtime.getSnapshot().error.includes('sensitive'), false) + const restored = harness({ result: 'restore' }) + await restored.runtime.initialize() + assert.equal(restored.redirects.length, 0) + restored.listeners.get('deleted')(did) + assert.equal(restored.runtime.getSnapshot().isAuthenticated, false) + assert.match(restored.runtime.getSnapshot().error, /session ended/) +}) + +test('browser auth initializes only once, restores identity, and carries safe callback state without publishing', async () => { + const { runtime, calls, redirects } = harness({ result: 'callback' }) + await Promise.all([runtime.initialize(), runtime.initialize()]) + assert.equal(calls.filter(c => c[0] === 'init').length, 1) + assert.equal(runtime.getSnapshot().session.did, did) + assert.equal(runtime.getSnapshot().isAuthenticated, true) + assert.equal(runtime.getSnapshot().isLoading, false) + assert.deepEqual(redirects, ['/lab/profile/#draft']) + await runtime.logout() + assert.equal(runtime.getSnapshot().session, null) + assert.equal(calls.at(-1)[0], 'signOut') +}) diff --git a/scripts/lab-protocol-config.test.mjs b/scripts/lab-protocol-config.test.mjs new file mode 100644 index 00000000..5f71fbb1 --- /dev/null +++ b/scripts/lab-protocol-config.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) + +test('metadata and capabilities routes share a public identity without request-header authority', async () => { + const before = process.env.LAB_PUBLIC_URL + process.env.LAB_PUBLIC_URL = 'https://lab-preview.example.org' + try { + const caps = await source('app/api/lab/capabilities/route.ts').GET() + assert.match(caps.headers.get('cache-control'), /no-store/) + const c = await caps.json() + const req = new Request(c.clientId, { headers: { 'x-forwarded-host': 'attacker.example.org' } }) + const res = await source('app/api/lab/oauth/client-metadata.json/route.ts').GET(req) + assert.equal(res.status, 200) + assert.equal(res.headers.get('location'), null) + assert.deepEqual(await res.json(), c.metadata) + const wrong = await source('app/api/lab/oauth/client-metadata.json/route.ts').GET(new Request('https://wrong.example.org/api/lab/oauth/client-metadata.json')) + assert.equal(wrong.status, 404) + const wrongCaps = await source('app/api/lab/capabilities/route.ts').GET(new Request('https://wrong.example.org/api/lab/capabilities/')) + assert.equal((await wrongCaps.json()).canSignIn, false) + // Official SDK metadata parser from the installed browser SDK dependency. + const sdkRequire = createRequire(require.resolve('@atproto/oauth-client-browser')) + const { clientMetadataSchema } = sdkRequire('@atproto/oauth-client') + assert.equal(clientMetadataSchema.parse(c.metadata).token_endpoint_auth_method, 'none') + } finally { if (before === undefined) delete process.env.LAB_PUBLIC_URL; else process.env.LAB_PUBLIC_URL = before } +}) + +test('explicit HTTPS public identity declares native follow plus five exact Lab collection scopes', () => { + const { getLabOAuthConfig } = source('lib/lab-oauth-config.ts') + const result = getLabOAuthConfig({ LAB_PUBLIC_URL: 'https://lab-preview.example.org', LAB_ENABLE_PUBLISH: 'true' }) + assert.equal(result.mode, 'ready') + assert.equal(result.oauthVerified, false) + assert.equal(result.canPublish, true) + const m = result.metadata + assert.equal(m.client_id, 'https://lab-preview.example.org/api/lab/oauth/client-metadata.json') + assert.deepEqual(m.redirect_uris, ['https://lab-preview.example.org/lab/oauth/return/']) + assert.equal(m.token_endpoint_auth_method, 'none') + assert.equal(m.dpop_bound_access_tokens, true) + assert.equal(m.application_type, 'web') + assert.deepEqual(m.scope.split(' '), ['atproto', 'repo:app.bsky.graph.follow?action=create&action=delete', ...['profile','note','app','contribution','participation'].map(k => `repo:org.plresearch.lab.${k}?action=create${k === 'profile' ? '&action=update' : ''}&action=delete`)]) + assert.equal(JSON.stringify(result).includes('secret'), false) +}) + +test('configuration fails closed, uses trusted Vercel origin, and refuses wrong browser alias', () => { + const { getLabOAuthConfig, configForBrowser } = source('lib/lab-oauth-config.ts') + for (const origin of [undefined, '', 'https://www.plrd.org/path', 'http://example.org', 'https://a:b@example.org', 'https://example.org:444', 'https://127.0.0.1', 'https://example.org?x=1']) { + assert.equal(getLabOAuthConfig({ LAB_PUBLIC_URL: origin }).canSignIn, false, String(origin)) + } + const c = getLabOAuthConfig({ VERCEL: '1', VERCEL_URL: 'plrd-branch.vercel.app' }) + assert.equal(c.origin, 'https://plrd-branch.vercel.app') + assert.equal(c.canPublish, false) + assert.equal(configForBrowser(c, 'https://plrd-branch.vercel.app').canSignIn, true) + assert.equal(configForBrowser(c, 'https://www.plrd.org').canSignIn, false) + assert.equal(getLabOAuthConfig({ VERCEL_URL: 'evil.test' }).canSignIn, false) +}) + +test('arbitrary Vercel previews refuse an inherited production origin; stale browser metadata fails closed', () => { + const { getLabOAuthConfig, configForBrowser, labActionScope } = source('lib/lab-oauth-config.ts') + assert.equal(getLabOAuthConfig({ VERCEL: '1', VERCEL_ENV: 'preview', VERCEL_URL: 'branch.vercel.app', LAB_PUBLIC_URL: 'https://www.plrd.org' }).canSignIn, false) + const c = getLabOAuthConfig({ LAB_PUBLIC_URL: 'https://lab.example.org' }) + for (const patch of [{ clientId: 'https://old.example.org/metadata' }, { redirectUri: 'https://old.example.org/callback' }, { metadata: { ...c.metadata, scope: 'atproto transition:generic' } }]) assert.equal(configForBrowser({ ...c, ...patch }, c.origin).canSignIn, false) + assert.equal(labActionScope('note', 'delete'), 'repo:org.plresearch.lab.note?action=delete') + assert.throws(() => labActionScope('note', 'update')) + assert.throws(() => labActionScope('unknown', 'create')) +}) + +test('loopback config delegates special metadata to the official load helper', () => { + const { getLabOAuthConfig } = source('lib/lab-oauth-config.ts') + const c = getLabOAuthConfig({ LAB_PUBLIC_URL: 'http://127.0.0.1:3382', NODE_ENV: 'development' }) + assert.equal(c.canSignIn, true) + const u = new URL(c.clientId) + assert.equal(u.origin, 'http://localhost') + assert.equal(u.searchParams.get('redirect_uri'), 'http://127.0.0.1:3382/lab/oauth/return/') + assert.equal(c.metadata, undefined) + assert.equal(getLabOAuthConfig({ LAB_PUBLIC_URL: 'http://127.0.0.1:3382', VERCEL: '1' }).canSignIn, false) + assert.equal(getLabOAuthConfig({ LAB_PUBLIC_URL: 'http://localhost:3382' }).canSignIn, false) +}) + +test('return state permits only same-origin Lab UI destinations', () => { + const { safeLabReturnTo } = source('lib/lab-oauth-config.ts') + assert.equal(safeLabReturnTo('/lab/feed/?type=question#draft'), '/lab/feed/?type=question#draft') + for (const path of ['//evil.test/lab/', 'https://evil.test/lab/', '/api/login/', '/lab/oauth/return/', '/lab/../admin/', '/lab/%2e%2e/admin/', '/lab/%252f%252fevil.test/', '/lab/feed/?next=https://evil.test']) { + assert.equal(safeLabReturnTo(path), '/lab/', path) + } +}) diff --git a/scripts/lab-protocol-public.test.mjs b/scripts/lab-protocol-public.test.mjs new file mode 100644 index 00000000..75c33013 --- /dev/null +++ b/scripts/lab-protocol-public.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +const uri = `at://${did}/org.plresearch.lab.note/draft-1` +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const value = { $type: 'org.plresearch.lab.note', community: 'https://www.plrd.org/lab/', createdAt: '2026-09-10T00:00:00.000Z', text: 'Public synthetic fixture', postType: 'question', field: 'neurotech' } +const pds = 'https://morel.us-east.host.bsky.network' +const doc = { id: did, service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: pds }] } + +test('second visitor inspects an exact public AT URI with the official resolver and Agent, without login or local drafts', async () => { + globalThis.window = {} + const calls = [] + const fetcher = async (input, init) => { + calls.push([String(input), init]) + return Response.json(String(input).startsWith('https://plc.directory/') ? doc : { uri, cid, value }) + } + const reader = source('lib/lab-protocol.ts').createLabPublicReader(fetcher) + const view = await reader.readRecord(uri) + assert.equal(view.uri, uri) + assert.equal(view.cid, cid) + assert.equal(view.authorDid, did) + assert.equal(view.kind, 'note') + assert.equal(view.data.text, value.text) + assert.equal(view.provenance, 'pds-https-unverified-signature') + assert.equal(calls.length, 2) + assert.match(calls[1][0], /com.atproto.repo.getRecord/) + for (const [, init] of calls) { assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); assert.equal(new Headers(init.headers).has('authorization'), false) } + delete globalThis.window +}) + +test('untrusted PDS payloads are size bounded before SDK JSON decoding', async () => { + globalThis.window = {} + const reader = source('lib/lab-protocol.ts').createLabPublicReader(async () => new Response(' '.repeat(1_048_577), { headers: { 'content-type': 'application/json' } })) + await assert.rejects(() => reader.readRecord(uri), /exceeds/) + delete globalThis.window +}) + +test('malformed identifiers, private endpoints, wrong DIDs and unexpected record fields fail closed', async () => { + const { createLabPublicReader } = source('lib/lab-protocol.ts') + globalThis.window = {} + let calls = 0 + let body = doc + const reader = createLabPublicReader(async input => { calls++; return Response.json(String(input).startsWith('https://plc.directory/') ? body : { uri, cid, value: { ...value, reviewed: true } }) }) + for (const bad of [uri + '?x=y', uri.replace(did, 'someone.bsky.social'), uri.replace('note', 'unknown'), uri.replace('draft-1', '..'), uri.replace(did, 'did:web:127.0.0.1'), uri.replace(did, 'did:web:example.org%3A443')]) await assert.rejects(() => reader.readRecord(bad)) + assert.equal(calls, 0) + for (const endpoint of ['http://example.org', 'https://127.0.0.1', 'https://[::1]', 'https://example.local', 'https://example.org/proxy', 'https://u:p@example.org']) { + body = { ...doc, service: [{ ...doc.service[0], serviceEndpoint: endpoint }] } + await assert.rejects(() => reader.readRecord(uri)) + } + body = { ...doc, id: 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' } + await assert.rejects(() => reader.readRecord(uri), /mismatch|does not match/) + body = doc + await assert.rejects(() => reader.readRecord(uri), /Unexpected/) + delete globalThis.window + await assert.rejects(() => reader.readRecord(uri), /browser/) +}) + +test('public notebook lists one bounded collection page with explicit cursor, no indexer', async () => { + globalThis.window = {} + const calls = [] + const reader = source('lib/lab-protocol.ts').createLabPublicReader(async (input) => { + calls.push(String(input)) + return Response.json(String(input).startsWith('https://plc.directory/') ? doc : { records: [{ uri, cid, value }], cursor: 'next-page' }) + }) + const page = await reader.listRecords(did, 'note', { limit: 10, cursor: 'first-page' }) + assert.equal(page.records[0].uri, uri) + assert.equal(page.cursor, 'next-page') + assert.equal(page.authorDid, did) + assert.equal(page.kind, 'note') + const request = new URL(calls[1]) + assert.equal(request.pathname, '/xrpc/com.atproto.repo.listRecords') + assert.equal(request.searchParams.get('cursor'), 'first-page') + assert.equal(request.searchParams.get('limit'), '10') + await assert.rejects(() => reader.listRecords(did, 'note', { limit: 101 })) + assert.equal(calls.length, 2) + delete globalThis.window +}) diff --git a/scripts/lab-protocol-validation.test.mjs b/scripts/lab-protocol-validation.test.mjs new file mode 100644 index 00000000..2653aabb --- /dev/null +++ b/scripts/lab-protocol-validation.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +test('runtime business rules reject authority fields, unknown keys, malformed records, and unsafe URLs', () => { + const { validateLabData, validateLabRecord, LAB_COMMUNITY } = source('lib/lab-validation.ts') + for (const [kind, good] of Object.entries(fixtures)) { + for (const injected of [{ authorDid: 'did:plc:attacker' }, { accepted: true }, { reviewed: true }, { access_token: 'never' }, { $type: 'app.bsky.feed.post' }]) { + assert.throws(() => validateLabData(kind, { ...good, ...injected }), undefined, kind) + } + } + for (const value of [null, [], 'text', { ...fixtures.note, text: '' }, { ...fixtures.note, text: 'x'.repeat(2001) }, { ...fixtures.note, field: 'all' }, { ...fixtures.note, text: 'bad\u0000control' }]) assert.throws(() => validateLabData('note', value)) + for (const url of ['javascript:alert(1)', 'data:text/html,x', 'http://example.org', 'https://u:p@example.org', 'https://127.0.0.1/', 'https://[::1]/', 'https://0x7f000001/', 'https://example.org:444/', 'https://example.org/?access_token=secret']) assert.throws(() => validateLabData('app', { ...fixtures.app, url }), undefined, url) + for (const patch of [{ community: 'https://evil.example.org/' }, { createdAt: 'invalid' }, { accepted: true }, { $type: 'org.plresearch.lab.profile' }]) assert.throws(() => validateLabRecord('note', { $type: 'org.plresearch.lab.note', community: LAB_COMMUNITY, createdAt: '2026-09-10T00:00:00.000Z', ...fixtures.note, ...patch })) + assert.throws(() => validateLabData('profile', { ...fixtures.profile, interests: Array(9).fill('x') })) + assert.throws(() => validateLabData('profile', { ...fixtures.profile, githubUrl: 'https://github.com/science/repo' })) + assert.throws(() => validateLabData('profile', { ...fixtures.profile, scholarUrl: 'https://example.org/scholar' })) +}) + +const fixtures = { + profile: { workingOn: 'Open benchmarks', interests: ['neurotech'], lookingFor: 'Reproduction help', githubUrl: 'https://github.com/science', scholarUrl: 'https://scholar.google.com/citations?user=sample' }, + note: { text: 'Can this result be reproduced?', postType: 'question', field: 'neurotech', evidenceUrl: 'https://example.org/paper' }, + app: { title: 'Public simulator', url: 'https://example.org/app', description: 'An educational model', field: 'ai-robotics', githubUrl: 'https://github.com/science/model' }, + contribution: { targetUrl: 'https://www.plneuro.xyz/', observation: 'A source to inspect', evidenceUrl: 'https://example.org/paper', field: 'neurotech' }, + participation: { campaignId: 'reference-audit', taskId: 'source-1', role: 'review', note: 'I can inspect this source', evidenceUrl: 'https://example.org/result' }, +} + +test('all five portable kinds use the same formal schemas and runtime validator on read and write', () => { + const { validateLabData, validateLabRecord, LAB_COMMUNITY, LAB_SCHEMAS } = source('lib/lab-validation.ts') + assert.equal(LAB_SCHEMAS.length, 5) + for (const [kind, data] of Object.entries(fixtures)) { + assert.deepEqual(validateLabData(kind, data), data) + const record = { $type: `org.plresearch.lab.${kind}`, community: LAB_COMMUNITY, createdAt: '2026-09-10T00:00:00.000Z', ...data } + assert.deepEqual(validateLabRecord(kind, record), record) + } +}) diff --git a/scripts/lab-protocol-writes.test.mjs b/scripts/lab-protocol-writes.test.mjs new file mode 100644 index 00000000..31f37cc2 --- /dev/null +++ b/scripts/lab-protocol-writes.test.mjs @@ -0,0 +1,102 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const origin = 'https://lab.example.org' +const note = { text: 'Synthetic test, not a live publication.', postType: 'question', field: 'neurotech' } +const consent = { public: true, experimental: true, did, action: 'create' } +function harness({ scope = 'atproto repo:org.plresearch.lab.note?action=create', readPatch = {}, configPatch = {}, initial, deleteMode = 'ok' } = {}) { + globalThis.window = { location: { origin } } + const calls = [] + let stored = initial + let deleted = false + const session = { sub: did, did, getTokenInfo: async () => ({ sub: did, scope, aud: 'https://pds.example.org' }), fetchHandler: async (path, init) => { + calls.push([path, init]) + if (path.includes('createRecord') || path.includes('putRecord')) { + const body = await new Response(init.body).json() + stored = { uri: `at://${did}/${body.collection}/${body.rkey}`, cid, value: body.record } + return Response.json({ uri: stored.uri, cid }) + } + if (path.includes('deleteRecord')) { deleted = true; return Response.json({}) } + if (deleted && deleteMode === 'ok') return Response.json({ error: 'RecordNotFound', message: 'Record not found' }, { status: 400 }) + if (deleted && deleteMode === 'outage') return Response.json({ error: 'InternalServerError' }, { status: 500 }) + return Response.json({ ...stored, ...readPatch }) + } } + const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }) + const writer = source('lib/lab-records.ts').createLabRecordWriter(session, async () => ({ ...config, ...configPatch })) + return { writer, calls, session } +} + +test('own deletion requires exact DID/CID consent and confirms RecordNotFound, not an outage', async () => { + const initial = { uri: `at://${did}/org.plresearch.lab.note/test`, cid, value: { ...note, $type: 'org.plresearch.lab.note', community: 'https://www.plrd.org/lab/', createdAt: '2026-01-01T00:00:00.000Z' } } + const scope = 'atproto repo:org.plresearch.lab.note?action=delete' + const deleteConsent = { ...consent, action: 'delete', expectedCid: cid } + const { writer, calls } = harness({ scope, initial }) + await assert.rejects(() => writer.delete(initial.uri.replace(did, 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'), deleteConsent), /own/) + assert.equal(calls.length, 0) + await assert.rejects(() => writer.delete(initial.uri, { ...deleteConsent, expectedCid: 'stale' }), /changed/) + assert.equal(calls.length, 1) + const receipt = await writer.delete(initial.uri, deleteConsent) + assert.equal(receipt.deleted, true) + assert.equal(receipt.uri, initial.uri) + assert.equal(receipt.verified, true) + const request = calls.find(([path]) => path.includes('deleteRecord')) + assert.equal((await new Response(request[1].body).json()).swapRecord, cid) + for (const deleteMode of ['outage', 'still-present']) { + const h = harness({ scope, initial, deleteMode }) + await assert.rejects(() => h.writer.delete(initial.uri, deleteConsent), error => error.name === 'LabWriteVerificationError') + } +}) + +test('profile update preserves creation date, checks editor CID and uses an atomic swap', async () => { + const profile = { workingOn: 'Test', lookingFor: 'Test', interests: [] } + const initial = { uri: `at://${did}/org.plresearch.lab.profile/self`, cid, value: { ...profile, $type: 'org.plresearch.lab.profile', community: 'https://www.plrd.org/lab/', createdAt: '2025-01-01T00:00:00.000Z' } } + const { writer, calls } = harness({ scope: 'atproto repo:org.plresearch.lab.profile?action=update', initial }) + const receipt = await writer.publish('profile', { ...profile, workingOn: 'Updated' }, { ...consent, action: 'update', expectedCid: cid }) + assert.equal(receipt.record.workingOn, 'Updated') + assert.equal(receipt.record.createdAt, initial.value.createdAt) + assert.equal(receipt.uri, initial.uri) + assert.equal(calls.length, 3) + assert.match(calls[1][0], /putRecord/) + assert.equal((await new Response(calls[1][1].body).json()).swapRecord, cid) +}) + +test('malformed input, absent consent, stale origin, disabled publication and absent grant make no PDS writes', async () => { + for (const bad of [undefined, {}, { ...consent, did: 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' }, { ...consent, public: false }, { ...consent, experimental: false }]) { + const { writer, calls } = harness() + await assert.rejects(() => writer.publish('note', note, bad), /consent/) + assert.equal(calls.length, 0) + } + for (const opts of [{ scope: 'atproto' }, { scope: 'atproto repo:org.plresearch.lab.app?action=create' }, { configPatch: { canPublish: false } }, { configPatch: { origin: 'https://other.example.org' } }]) { + const { writer, calls } = harness(opts) + await assert.rejects(() => writer.publish('note', note, consent)) + assert.equal(calls.length, 0) + } + const { writer, calls } = harness() + await assert.rejects(() => writer.publish('note', { ...note, authorDid: did }, consent), /Unexpected/) + assert.equal(calls.length, 0) +}) + +test('URI, CID and record-content readback mismatches never return a success receipt', async () => { + for (const readPatch of [{ cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, { uri: `at://${did}/org.plresearch.lab.note/wrong` }, { value: { ...note, text: 'Different', $type: 'org.plresearch.lab.note', community: 'https://www.plrd.org/lab/', createdAt: '2026-01-01T00:00:00.000Z' } }]) { + const { writer } = harness({ readPatch }) + await assert.rejects(() => writer.publish('note', note, consent), error => error.name === 'LabWriteVerificationError' && error.uri.startsWith(`at://${did}/`)) + } +}) + +test('explicit public create returns exact current PDS readback, not just a successful write response', async () => { + const { writer, calls } = harness() + const receipt = await writer.publish('note', note, consent) + assert.equal(receipt.verified, true) + assert.equal(receipt.verification, 'pds-readback') + assert.equal(receipt.record.text, note.text) + assert.equal(receipt.cid, cid) + assert.equal(calls.length, 2) + assert.match(calls[1][0], /getRecord/) + assert.equal(new URL(calls[1][0], origin).searchParams.has('cid'), false) + const body = await new Response(calls[0][1].body).json() + assert.equal(body.repo, did) + assert.equal(body.validate, false) // locally validated experimental lexicons + assert.equal(body.record.$type, 'org.plresearch.lab.note') +}) diff --git a/scripts/lab-public-feed.test.mjs b/scripts/lab-public-feed.test.mjs new file mode 100644 index 00000000..c58d995b --- /dev/null +++ b/scripts/lab-public-feed.test.mjs @@ -0,0 +1,112 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const did = 'did:plc:jfhpnnst6flqway4eaeqzj2a' +const fixture = (overrides = {}) => ({ post: { + uri: `at://${did}/app.bsky.feed.post/3testrecord`, cid: 'bafyreitest', + author: { did, handle: 'example.bsky.social', displayName: 'Example source author' }, + record: { $type: 'app.bsky.feed.post', text: ' A source-linked finding.', createdAt: '2026-09-01T12:00:00Z' }, + ...overrides, +} }) + +test('Open Lab reads a bounded attributed science feed, never calling it a member feed', async (t) => { + assert.ok(existsSync('src/lib/lab-public-feed.ts'), 'public science feed adapter is missing') + const adapter = source('lib/lab-public-feed.ts') + const calls = [] + const fetcher = async (url, options) => { + calls.push({ url: String(url), options }) + return Response.json({ feed: [fixture()] }) + } + const data = await adapter.loadLabPublicFeed({ fetcher, now: () => new Date('2026-09-10T12:00:00Z') }) + assert.equal(calls.length, 1) + const target = new URL(calls[0].url) + assert.equal(target.origin, 'https://public.api.bsky.app') + assert.equal(target.pathname, '/xrpc/app.bsky.feed.getFeed') + assert.equal(target.searchParams.get('feed'), 'at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science') + assert.equal(calls[0].options.redirect, 'error') + assert.equal(calls[0].options.cache, 'no-store') + assert.ok(calls[0].options.signal instanceof AbortSignal) + assert.equal(data.status, 'live') + assert.equal(data.fetchedAt, '2026-09-10T12:00:00.000Z') + assert.match(data.sourceLabel, /Science feed on Bluesky/) + assert.match(data.message, /not Open Lab members/i) + assert.equal(data.items.length, 1) + assert.equal(data.items[0].uri, fixture().post.uri) + assert.equal(data.items[0].text, fixture().post.record.text) + assert.equal(data.items[0].author.did, did) + assert.equal(data.items[0].url, `https://bsky.app/profile/${did}/post/3testrecord`) + assert.equal(data.items[0].createdAt, fixture().post.record.createdAt) +}) + +test('malformed, labeled, deleted, mismatched and duplicate posts never render as valid science items', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + const entries = [ + fixture(), fixture(), + { post: { ...fixture().post, uri: 'javascript:alert(1)' } }, + fixture({ author: { did: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', handle: 'other.bsky.social' } }), + fixture({ labels: [{ val: 'porn' }] }), + fixture({ notFound: true }), + fixture({ record: { text: 'x'.repeat(3001), createdAt: '2026-09-01T12:00:00Z' } }), + fixture({ record: { text: 'wrong type', createdAt: 'never' } }), + null, {}, + ] + const data = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: entries }) }) + assert.equal(data.status, 'live') + assert.equal(data.items.length, 1) + assert.deepEqual(Object.keys(data.items[0].author).sort(), ['did', 'displayName', 'handle']) + const allInvalid = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture({ labels: [{ val: 'sexual' }] })] }) }) + assert.equal(allInvalid.status, 'empty') + assert.match(allInvalid.message, /filtered|eligible/i) + const badAvatar = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture({ author: { ...fixture().post.author, avatar: 'https://evil.example/track', extra: 'private' } })] }) }) + assert.equal(badAvatar.items[0].author.avatar, undefined) + assert.equal(badAvatar.items[0].author.extra, undefined) +}) + +test('oversized provider bodies fail closed before parsing and batches are bounded', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + const oversized = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture()], padding: 'x'.repeat(600000) }) }) + assert.equal(oversized.status, 'unavailable') + const items = Array.from({ length: 70 }, (_, i) => fixture({ uri: `at://${did}/app.bsky.feed.post/item${i}` })) + const bounded = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: items }) }) + assert.equal(bounded.items.length, 30) +}) + +test('public feed route remains read-only and never caches an outage as a healthy response', async (t) => { + assert.ok(existsSync('src/app/api/lab/feed/route.ts'), 'public feed route missing') + const route = source('app/api/lab/feed/route.ts') + const mock = t.mock.method(globalThis, 'fetch', async () => Response.json({ feed: [fixture()] })) + const response = await route.GET(new Request('https://example.org/api/lab/feed/?url=http://127.0.0.1/secret')) + assert.equal(response.status, 200) + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + assert.match(response.headers.get('cache-control'), /s-maxage=60/) + assert.equal((await response.json()).items.length, 1) + assert.ok(String(mock.mock.calls[0].arguments[0]).startsWith('https://public.api.bsky.app/')) + assert.equal(route.POST, undefined) + mock.mock.mockImplementation(async () => new Response(null, { status: 503 })) + const failed = await route.GET(new Request('https://example.org/api/lab/feed/')) + assert.equal(failed.status, 503) + assert.equal(failed.headers.get('cache-control'), 'no-store') + assert.equal((await failed.json()).status, 'unavailable') +}) + +test('outages and malformed upstream responses are unavailable, never successful empty feeds', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + for (const fetcher of [ + async () => { throw new Error('sensitive-provider-message') }, + async () => Response.json({ feed: [] }, { status: 503 }), + async () => Response.json({ error: 'upstream auth denied' }), + async () => new Response('login', { headers: { 'Content-Type': 'text/html' } }), + async () => Response.json({ feed: null }), + ]) { + const data = await loadLabPublicFeed({ fetcher }) + assert.equal(data.status, 'unavailable') + assert.equal(data.fetchedAt, null) + assert.deepEqual(data.items, []) + assert.doesNotMatch(JSON.stringify(data), /sensitive-provider-message|upstream auth denied|/) + } + const empty = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [] }) }) + assert.equal(empty.status, 'empty') + assert.ok(empty.fetchedAt) +}) diff --git a/scripts/lab-real-following-ui.test.mjs b/scripts/lab-real-following-ui.test.mjs new file mode 100644 index 00000000..a3ee34ea --- /dev/null +++ b/scripts/lab-real-following-ui.test.mjs @@ -0,0 +1,288 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = m => { m.exports = new Proxy({}, { get: (_, p) => p === '__esModule' ? false : String(p) }) } +const dom = new JSDOM('
', { url: 'http://localhost/lab/feed/' }) +for (const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLDialogElement','Event','StorageEvent','localStorage']) Object.defineProperty(globalThis, k, { value: dom.window[k], configurable: true, writable: true }) +globalThis.self = window +HTMLDialogElement.prototype.showModal = function() { this.open = true } +HTMLDialogElement.prototype.close = function() { this.open = false } +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const React = await import('react'), { createRoot } = await import('react-dom/client') +const A = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', B = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' +const owner = 'did:plc:cccccccccccccccccccccccc' +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const pds = 'https://pds.example.org' +const note = (did, text) => ({ uri: `at://${did}/org.plresearch.lab.note/example`, cid, value: { $type: 'org.plresearch.lab.note', community: 'https://www.plrd.org/lab/', createdAt: '2026-09-10T00:00:00.000Z', text, field: 'neurotech', postType: 'question' } }) +let identity, calls, pages, boundary +const failNative = () => { throw Error('Native follow permission or authenticated write must never run') } +function reset() { + localStorage.clear(); calls = []; pages = new Map([[A, [note(A, 'Synthetic A research question')]], [B, [note(B, 'Synthetic B research question')]]]); boundary = null + identity = { isLoading: false, isAuthenticated: true, session: { did: owner, handle: 'owner.example.org' }, oauthSession: null, capabilities: { canConnect: false, canSignIn: true }, authorizeConnection: failNative, isSessionCurrent: () => true } +} +reset() +// Only the fetch boundary is synthetic; profile validation, DID resolution, PDS +// reader, local stores, hooks and both rendered consumers are real. +globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.origin) + calls.push({ url, init }) + assert.equal(init?.method || 'GET', 'GET', 'No write request') + assert.equal(new Headers(init?.headers).has('authorization'), false) + if (boundary) { const result = await boundary(url, init); if (result) return result } + if (url.pathname.endsWith('resolveHandle')) return Response.json({ did: url.searchParams.get('handle').startsWith('a.') ? A : B }) + if (url.pathname.endsWith('getProfile')) { const did = url.searchParams.get('actor'); return Response.json({ did, handle: did === A ? 'a.example.org' : 'b.example.org', displayName: did === A ? 'Synthetic Person A' : 'Synthetic Person B' }) } + if (url.hostname === 'plc.directory') { const did = decodeURIComponent(url.pathname.slice(1)); return Response.json({ id: did, service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: pds }] }) } + if (url.pathname.endsWith('listRecords')) return Response.json({ records: (pages.get(url.searchParams.get('repo')) || []).filter(r => r.value.$type === url.searchParams.get('collection')) }) + if (url.pathname.endsWith('getRecord')) { + const uri = `at://${url.searchParams.get('repo')}/${url.searchParams.get('collection')}/${url.searchParams.get('rkey')}` + const record = [...pages.values()].flat().find(r => r.uri === uri) + return record ? Response.json(record) : Response.json({ error: 'RecordNotFound' }, { status: 404 }) + } + throw Error('Unexpected public read: ' + url) +} +source('lib/lab-identity.ts').useLabIdentity = () => identity +const realConnectionFactory = source('lib/lab-connections.ts').createLabConnectionClient +source('lib/lab-connections.ts').createLabConnectionClient = failNative +const D = source('components/lab/demo/DemoCommunityProvider.tsx') +const Connections = source('components/lab/social/BlueskyConnections.tsx').default +const Feed = source('components/lab/FeedWorkbench.tsx').default +const F = source('lib/lab-following.ts') +function Mode() { const demo = D.useDemoCommunity(); return React.createElement('button', { onClick: () => demo.setMode(demo.isDemo ? 'live' : 'demo') }, 'Test mode switch') } +const rows = () => [...document.querySelectorAll('[data-feed-row]')] +const publicRows = () => rows().filter(r => r.getAttribute('data-feed-row').startsWith('at://')) +const button = label => [...document.querySelectorAll('button')].find(b => b.getAttribute('aria-label') === label || b.textContent.trim() === label) +const click = async label => { const b = button(label); assert.ok(b, 'Missing action: ' + label); assert.equal(b.disabled, false, 'Disabled action: ' + label); await React.act(async () => b.click()) } +const fill = async (label, value) => { const e = document.querySelector(`[aria-label="${label}"]`); assert.ok(e); await React.act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(e, value); e.dispatchEvent(new Event('input', { bubbles: true })) }) } +async function mount() { + let root = createRoot(document.getElementById('root')) + const render = () => React.act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(D.DemoCommunityProvider, { initialMode: 'live', storageScope: identity.session?.did || 'browser' }, React.createElement(Mode), React.createElement(Connections), React.createElement(Feed))))) + await render() + return { render, reload: async () => { await React.act(async () => root.unmount()); root = createRoot(document.getElementById('root')); await render() }, close: () => React.act(async () => root.unmount()) } +} +async function findA() { await fill('Bluesky handle to find', 'a.example.org'); await click('Find person'); assert.match(document.querySelector('#bluesky-connections').textContent, /Synthetic Person A/) } + +test('real lookup → local follow A → default and Following show only A public records; unfollow survives reload without native permission', async () => { + reset(); const h = await mount() + try { + assert.equal(publicRows().length, 0) + await findA(); await click('Follow in Open Lab') + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, [A]) + assert.equal(publicRows().length, 1, 'A must enter the default science feed, not a separate source tab') + assert.match(publicRows()[0].textContent, /Synthetic A research question/) + await click('Following'); assert.equal(rows().length, 1); assert.doesNotMatch(document.querySelector('[aria-label="Mixed science feed"]').textContent, /Synthetic B research question/) + await h.reload(); assert.equal(publicRows().length, 1) + await findA(); await click('Unfollow in Open Lab'); assert.equal(rows().length, 0) + await h.reload(); assert.equal(rows().length, 0) + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, []) + assert.ok(calls.every(c => !c.url.pathname.includes('app.bsky.graph.follow'))) + assert.ok(calls.filter(c => c.url.origin === pds).every(c => c.init.credentials === 'omit' && c.init.redirect === 'error')) + } finally { await h.close() } +}) + +test('supported public activity keeps exact provenance and fields; following an exact idea survives person unfollow', async () => { + reset() + const record = (kind, data) => ({ uri: `at://${A}/org.plresearch.lab.${kind}/sample`, cid, value: { $type: `org.plresearch.lab.${kind}`, community: 'https://www.plrd.org/lab/', createdAt: '2026-09-09T00:00:00.000Z', ...data } }) + const app = record('app', { title: 'Synthetic A instrument', description: 'A described public tool.', url: 'https://example.org/instrument', field: 'cross-field' }) + const contribution = record('contribution', { targetUrl: 'https://example.org/target', observation: 'Synthetic A negative observation', evidenceUrl: 'https://example.org/evidence', field: 'neurotech' }) + const participation = record('participation', { campaignId: 'synthetic-campaign', taskId: 'synthetic-task', role: 'review', note: 'Synthetic A self-reported participation' }) + pages.get(A).push(app, contribution, participation) + const h = await mount() + try { + await findA(); await click('Follow in Open Lab'); await click('Following') + assert.equal(publicRows().length, 4, 'Supported activity kinds must not disappear into the source tab') + for (const expected of pages.get(A)) { + const row = publicRows().find(r => r.getAttribute('data-feed-row') === expected.uri); assert.ok(row) + for (const exact of [A, expected.uri, expected.cid, pds, expected.value.createdAt, 'pds-https-unverified-signature']) assert.ok(row.textContent.includes(exact), 'Missing exact provenance ' + exact) + assert.ok(row.querySelector(`a[href="${source('lib/lab-notebook.ts').labInspectorHref(expected.uri)}"]`)) + assert.doesNotMatch(row.textContent, /Editorial|Synthetic Person B|Ada Lovelace|peer reviewed|verified release/i) + } + const untagged = publicRows().find(r => r.getAttribute('data-feed-row') === participation.uri) + assert.doesNotMatch(untagged.textContent, /Field:|Neuroscience|Mathematics|cross-field|Cross-disciplinary/) + assert.match(publicRows().find(r => r.getAttribute('data-feed-row') === app.uri).textContent, /Field: cross-field/) + await click('Follow idea: ' + app.uri) + await click('Unfollow in Open Lab'); assert.equal(publicRows().length, 1); assert.equal(publicRows()[0].getAttribute('data-feed-row'), app.uri) + await h.reload(); assert.equal(publicRows().length, 1) + await click('Unfollow idea: ' + app.uri); await h.reload(); assert.equal(publicRows().length, 0) + } finally { await h.close() } +}) + +async function setPrefs(state) { + await React.act(async () => { localStorage.setItem(F.followingKey(owner, 'live'), JSON.stringify(state)); window.dispatchEvent(new StorageEvent('storage', { key: F.followingKey(owner, 'live') })) }) +} +test('bounded public scan stops after two pages and five people and names truncation instead of claiming completeness', async () => { + reset(); const h = await mount() + try { + boundary = (url) => { + if (!url.pathname.endsWith('listRecords') || !url.searchParams.get('collection').endsWith('.note')) return + const did = url.searchParams.get('repo'), second = url.searchParams.get('cursor') === 'page2' + const r = note(did, 'Bounded synthetic note ' + (second ? 'two' : 'one')); r.uri += second ? '-two' : '-one' + return Response.json({ records: [r], cursor: second ? 'page3' : 'page2' }) + } + const people = [A, B, owner, 'did:plc:dddddddddddddddddddddddd', 'did:plc:eeeeeeeeeeeeeeeeeeeeeeee', 'did:plc:ffffffffffffffffffffffff'] + await setPrefs({ ...F.emptyFollowing(owner, 'live'), people, filter: { feed: 'following', disciplines: [] } }) + assert.equal(publicRows().length, 10, 'Exactly two pages for each of five people') + const requests = calls.filter(c => c.url.pathname.endsWith('listRecords')) + assert.deepEqual([...new Set(requests.map(c => c.url.searchParams.get('repo')))].sort(), people.slice(0, 5).sort()) + assert.ok(requests.every(c => c.url.searchParams.get('limit') === '10' && c.url.searchParams.get('cursor') !== 'page3')) + assert.equal(requests.filter(c => c.url.searchParams.get('collection').endsWith('.note')).length, 10) + const status = document.querySelector('[data-public-feed-status]'); assert.ok(status) + assert.match(status.textContent, /truncated/i); assert.match(status.textContent, /1 person.*not read/i); assert.match(status.textContent, /2 pages/) + } finally { await h.close() } +}) + +test('unreadable/unsupported public sources are isolated, named and never replaced with activity or rewritten storage', async () => { + reset(); const h = await mount() + try { + const invalid = note(A, 'Rejected synthetic body'); invalid.value.schemaVersion = 999 + pages.set(A, [invalid]); const rawSource = JSON.stringify(invalid) + const state = { ...F.emptyFollowing(owner, 'live'), people: [A, B, 'https://127.0.0.1'], filter: { feed: 'following', disciplines: [] } } + await setPrefs(state) + assert.equal(publicRows().length, 1, 'A rejected source must not hide valid B') + assert.match(publicRows()[0].textContent, /Synthetic B research question/) + const status = document.querySelector('[data-public-feed-status]'); assert.ok(status) + assert.match(status.textContent, /unavailable|invalid|unsupported/i); assert.ok(status.textContent.includes(A)); assert.ok(status.textContent.includes('https://127.0.0.1')) + assert.doesNotMatch(document.querySelector('[aria-label="Mixed science feed"]').textContent, /Rejected synthetic body/) + assert.equal(localStorage.getItem(F.followingKey(owner, 'live')), JSON.stringify(state)); assert.equal(JSON.stringify(invalid), rawSource) + assert.ok(calls.every(c => !['127.0.0.1', 'localhost'].includes(c.url.hostname))) + } finally { await h.close() } +}) + +test('no supported records is explicit even when editorial rows remain; retry changes only the public read', async () => { + reset(); pages.set(A, []); const h = await mount() + try { + await findA(); await click('Follow in Open Lab') + assert.equal(publicRows().length, 0); assert.equal(rows().length, 3) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /No supported public records/) + const raw = localStorage.getItem(F.followingKey(owner, 'live')) + pages.set(A, [note(A, 'Synthetic A after retry')]); await click('Refresh public subscriptions') + assert.equal(publicRows().length, 1); assert.equal(localStorage.getItem(F.followingKey(owner, 'live')), raw) + } finally { await h.close() } +}) + +for (const change of ['account', 'mode', 'restoring', 'unfollow']) test(`slow public response is aborted and cannot leak after ${change} changes`, async () => { + reset(); const h = await mount(); let resolve, pendingSignal + try { + boundary = (url, init) => { + if (url.pathname.endsWith('listRecords') && url.searchParams.get('repo') === A && url.searchParams.get('collection').endsWith('.note')) { + pendingSignal = init.signal + return new Promise(r => { resolve = r }) + } + } + await findA(); await click('Follow in Open Lab'); assert.ok(resolve, 'Deferred real reader request reached fetch boundary') + assert.equal(publicRows().length, 0) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /Reading public subscriptions/) + if (change === 'mode') await click('Test mode switch') + else if (change === 'unfollow') await click('Unfollow in Open Lab') + else { identity = change === 'account' ? { ...identity, session: { did: B, handle: 'b.example.org' } } : { ...identity, isLoading: true }; await h.render() } + assert.equal(pendingSignal.aborted, true, 'Old public read must be canceled, not just visually hidden') + const callsAfterSwitch = calls.length + await React.act(async () => resolve(Response.json({ records: [note(A, 'Late A must never leak')], cursor: 'next-page' }))) + assert.equal(publicRows().length, 0); assert.doesNotMatch(document.body.textContent, /Late A must never leak/) + assert.equal(calls.length, callsAfterSwitch, 'No old-account pagination or later collections after cancellation') + if (change === 'mode') assert.equal(button('Follow in Open Lab'), undefined) + } finally { await h.close(); if (resolve) await React.act(async () => resolve(Response.json({ records: [] }))) } +}) + +for (const scenario of ['malformed JSON', 'unknown version', 'storage unavailable']) test(`local follow preserves ${scenario} and all unrelated originals`, async () => { + reset(); const key = F.followingKey(owner, 'live') + const raw = scenario === 'unknown version' ? JSON.stringify({ ...F.emptyFollowing(owner, 'live'), version: 999 }) : '{broken original' + localStorage.setItem(key, raw); localStorage.setItem('untouched-private-draft', 'local original') + const get = window.Storage.prototype.getItem + if (scenario === 'storage unavailable') window.Storage.prototype.getItem = function(k) { if (k === key) throw Error('read blocked'); return get.call(this, k) } + let h + try { + h = await mount(); await findA() + assert.equal(button('Follow in Open Lab').disabled, true) + assert.match(document.body.textContent, /original is preserved|storage is unavailable/i) + assert.equal(publicRows().length, 0) + assert.equal(get.call(localStorage, key), raw); assert.equal(get.call(localStorage, 'untouched-private-draft'), 'local original') + assert.ok(calls.every(c => !c.url.pathname.endsWith('listRecords'))) + } finally { if (h) await h.close(); window.Storage.prototype.getItem = get } +}) + +for (const scenario of ['unsupported type', 'wrong author', 'bad CID', 'oversized page', 'network error']) test(`mounted science feed reports ${scenario} honestly`, async () => { + reset(); const h = await mount() + try { + boundary = url => { + if (!url.pathname.endsWith('listRecords') || !url.searchParams.get('collection').endsWith('.note')) return + const r = note(A, 'Rejected public content') + if (scenario === 'unsupported type') r.value.$type = 'app.bsky.feed.post' + if (scenario === 'wrong author') r.uri = note(B, '').uri + if (scenario === 'bad CID') r.cid = 'invented-version' + if (scenario === 'network error') return Response.json({ error: 'Unavailable' }, { status: 503 }) + return Response.json({ records: scenario === 'oversized page' ? Array.from({ length: 11 }, (_, i) => ({ ...r, uri: r.uri + i })) : [r] }) + } + await findA(); await click('Follow in Open Lab'); await click('Following') + assert.equal(publicRows().length, 0); assert.equal(rows().length, 0) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /source errors.*Source unavailable, invalid, or unsupported/s) + assert.doesNotMatch(document.body.textContent, /Rejected public content/) + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, [A]) + } finally { await h.close() } +}) + +test('repeated cursor or cross-page record identity is an error, not a second version or inflated activity count', async () => { + reset(); const h = await mount() + try { + boundary = url => url.pathname.endsWith('listRecords') && url.searchParams.get('collection').endsWith('.note') ? Response.json({ records: [note(A, 'Repeated page must not claim activity')], cursor: 'same-cursor' }) : undefined + await findA(); await click('Follow in Open Lab'); await click('Following') + assert.equal(publicRows().length, 0) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /source errors/) + } finally { await h.close() } +}) + +test('a followed person with no supported records can be unfollowed locally from feed curation without looking up again', async () => { + reset(); pages.set(A, []); const h = await mount() + try { + await findA(); await click('Follow in Open Lab'); await click('Following') + await h.reload(); await click('Curate the feed') + await click('Unfollow person: ' + A) + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, []) + await h.reload(); assert.equal(publicRows().length, 0); assert.equal(rows().length, 0) + } finally { await h.close() } +}) + +test('complete A results are identity scoped; B and guest do not inherit them or demo personas', async () => { + reset(); const h = await mount() + try { + await findA(); await click('Follow in Open Lab'); await click('Following'); assert.equal(publicRows().length, 1) + identity = { ...identity, session: { did: B, handle: 'b.example.org' } }; await h.render(); assert.equal(publicRows().length, 0) + await fill('Bluesky handle to find', 'b.example.org'); await click('Find person'); await click('Follow in Open Lab'); await click('Following') + assert.equal(publicRows().length, 1); assert.match(publicRows()[0].textContent, /Synthetic B research question/); assert.doesNotMatch(document.body.textContent, /Synthetic A research question|Ada Lovelace/) + identity = { ...identity, isAuthenticated: false, session: null }; await h.render(); assert.equal(publicRows().length, 0) + await findA(); await click('Follow in Open Lab'); await click('Following'); assert.equal(publicRows().length, 1) + assert.deepEqual(F.loadFollowing(localStorage, 'guest', 'live').state.people, [A]) + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, [A]); assert.deepEqual(F.loadFollowing(localStorage, B, 'live').state.people, [B]) + } finally { await h.close() } +}) + +test('exact-idea reads are capped at ten; malformed and unsupported stored URIs remain untouched and cannot fetch arbitrary URLs', async () => { + reset(); const h = await mount() + try { + const notes = Array.from({ length: 11 }, (_, i) => { const r = note(A, 'Synthetic bounded idea ' + i); r.uri += '-' + i; return r }); pages.set(A, notes) + const state = { ...F.emptyFollowing(owner, 'live'), ideas: notes.map(r => r.uri), filter: { feed: 'following', disciplines: [] } } + await setPrefs(state); assert.equal(publicRows().length, 10) + assert.equal(calls.filter(c => c.url.pathname.endsWith('getRecord')).length, 10) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /1 idea subscriptions not read/) + const invalid = { ...state, ideas: [`at://${A}/org.plresearch.lab.profile/self`, 'at://did:web:127.0.0.1/org.plresearch.lab.note/no', 'at://a.example.org/org.plresearch.lab.note/no', `at://${A}/app.bsky.feed.post/no`] } + const before = calls.length; await setPrefs(invalid); assert.equal(publicRows().length, 0); assert.equal(calls.length, before) + assert.equal(localStorage.getItem(F.followingKey(owner, 'live')), JSON.stringify(invalid)) + assert.match(document.querySelector('[data-public-feed-status]').textContent, /Idea unavailable, invalid, or unsupported/) + } finally { await h.close() } +}) + +test('an identity-only SDK session can subscribe without token permission inspection or authenticated transport', async () => { + reset() + identity.oauthSession = { sub: owner, did: owner, getTokenInfo: failNative, fetchHandler: failNative } + source('lib/lab-connections.ts').createLabConnectionClient = realConnectionFactory + let h + try { + h = await mount(); await findA(); await click('Follow in Open Lab'); await click('Following') + assert.equal(publicRows().length, 1) + assert.deepEqual(F.loadFollowing(localStorage, owner, 'live').state.people, [A]) + assert.equal(button('Authorize follow permission'), undefined) + assert.equal(button('Confirm public follow'), undefined) + } finally { if (h) await h.close(); source('lib/lab-connections.ts').createLabConnectionClient = failNative } +}) diff --git a/scripts/lab-record-inspector-ui.test.mjs b/scripts/lab-record-inspector-ui.test.mjs new file mode 100644 index 00000000..5bc8ea1f --- /dev/null +++ b/scripts/lab-record-inspector-ui.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = () => {} +const uri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.plresearch.lab.note/abc' + +test('a signed-out visitor reads a linked record, sees safe text and can prepare evidence without publishing', async () => { + assert.ok(existsSync('src/components/lab/LabPublicInspector.tsx'), 'missing unsigned public record reader') + const dom = new JSDOM('
', { url: 'http://localhost/lab/record/?uri='+encodeURIComponent(uri) }) + const saved = {} + for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Event']) { saved[key] = Object.getOwnPropertyDescriptor(globalThis,key); Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true,writable:true}) } + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const React = await import('react') + const { createRoot } = await import('react-dom/client') + const root = createRoot(document.getElementById('root')) + let proposed = null + const loaded = [] + const Component = source('components/lab/LabPublicInspector.tsx').default + try { + await React.act(async () => root.render(React.createElement(Component,{loadRecord:async u => { loaded.push(u); return {uri:u,cid:'test-cid',did:'did:plc:abcdefghijklmnopqrstuvwx',kind:'note',data:{text:'',postType:'negative',field:'neurotech'},createdAt:'2026-09-01T00:00:00Z'} },onPropose:r=>{proposed=r}}))) + assert.deepEqual(loaded,[uri]) + assert.match(document.body.textContent, /Negative result/) + assert.match(document.body.textContent, //) + assert.equal(document.querySelectorAll('img').length,0) + assert.match(document.body.textContent, /not scientific verification/i) + const button = [...document.querySelectorAll('button')].find(b=>b.textContent==='Add evidence to this work') + await React.act(()=>button.click()) + assert.equal(proposed.uri,uri) + assert.deepEqual(loaded,[uri]) + } finally { await React.act(()=>root.unmount()); dom.window.close(); for(const [k,d] of Object.entries(saved)){ if(d)Object.defineProperty(globalThis,k,d);else delete globalThis[k] } delete globalThis.IS_REACT_ACT_ENVIRONMENT } +}) diff --git a/scripts/lab-record-inspector.test.mjs b/scripts/lab-record-inspector.test.mjs new file mode 100644 index 00000000..48538caf --- /dev/null +++ b/scripts/lab-record-inspector.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const uri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.plresearch.lab.note/abc' +test('record share URLs preserve exact AT URI and only accept explicit Open Lab records', () => { + assert.ok(existsSync('src/lib/lab-record-display.ts'), 'missing public record view helpers') + const { recordPermalink, readRecordLocation } = source('lib/lab-record-display.ts') + const link = recordPermalink('https://preview.vercel.app', uri) + assert.equal(new URL(link).searchParams.get('uri'), uri) + assert.deepEqual(readRecordLocation(new URL(link).search), { uri, response: null }) + for (const bad of ['at://bob.bsky.social/org.plresearch.lab.note/x', 'at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.post/x', uri+'?token=x', 'https://example.org/']) { + assert.throws(() => recordPermalink('https://preview.vercel.app', bad)) + assert.throws(() => readRecordLocation('?uri='+encodeURIComponent(bad))) + } + assert.throws(() => recordPermalink('https://evil.test/private', uri)) + assert.throws(() => readRecordLocation('?uri='+encodeURIComponent(uri)+'&uri='+encodeURIComponent(uri))) +}) + +test('the public record viewer has a registered route entrypoint', () => { + assert.ok(existsSync('src/app/lab/record/page.tsx'),'public record route exists') +}) + +test('PDS read adapter preserves response identity and content without calling it verified', () => { + const m=source('lib/lab-record-display.ts') + assert.equal(typeof m.presentPdsRecord,'function') + const raw={uri,cid:'bafyreia',authorDid:'did:plc:abcdefghijklmnopqrstuvwx',kind:'note',data:{title:'Evidence',body:'Read the source'},pds:'https://public.example',provenance:'pds-https-unverified-signature'} + assert.deepEqual(m.presentPdsRecord(raw),{uri,cid:'bafyreia',did:raw.authorDid,kind:'note',data:raw.data}) + assert.throws(()=>m.presentPdsRecord({...raw,authorDid:'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'}),/author/i) +}) + +test('participation records remain inspectable with their actual return text', () => { + const m=source('lib/lab-record-display.ts') + const record=m.presentPdsRecord({uri:uri.replace('.note/','.participation/'),cid:'bafyrei',authorDid:'did:plc:abcdefghijklmnopqrstuvwx',kind:'participation',data:{campaignId:'pilot',taskId:'source-trace',role:'review',note:'Negative result: could not corroborate.'}}) + assert.match(m.displayRecord(record).rows.map(x=>x.value).join(' '),/could not corroborate/) +}) + +test('untrusted public record presentation exposes only named fields, never html or control metadata', () => { + const { displayRecord } = source('lib/lab-record-display.ts') + const value = displayRecord({ uri, cid: 'test-cid', did: 'did:plc:abcdefghijklmnopqrstuvwx', kind:'note', data: { text: '', postType: 'negative', field:'neurotech', oauthSession:'SECRET', evidenceUrl:'javascript:alert(1)' }, createdAt:'2026-09-01T00:00:00Z' }) + assert.equal(value.title, 'Negative result') + assert.equal(value.rows[0].value, '') + assert.ok(!JSON.stringify(value).includes('SECRET')) + assert.ok(!JSON.stringify(value).includes('javascript:')) +}) diff --git a/scripts/lab-recovery-alert.test.mjs b/scripts/lab-recovery-alert.test.mjs new file mode 100644 index 00000000..93ecf8be --- /dev/null +++ b/scripts/lab-recovery-alert.test.mjs @@ -0,0 +1,135 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; + +const require = createRequire(import.meta.url); +require.extensions['.css'] = module => { module.exports = {}; }; +const origin = 'https://lab.example.org'; +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }); +const { createLabAuthRuntime } = source('lib/lab-auth.tsx'); +const identity = source('lib/lab-identity.ts'); +const client = source('lib/lab-client.ts'); +const realClient = client.createLabClient; +// LabShell creates its real client at import time. Defer only its fetch boundary +// so each test's scoped network fixture is used rather than Node's native fetch. +const nativeFetch = globalThis.fetch; +globalThis.fetch = (...args) => globalThis.fetch(...args); +let Shell, Profile; +try { + Shell = source('components/lab/LabShell.tsx').default; + Profile = source('components/lab/ProfileWorkbench.tsx').default; +} finally { + globalThis.fetch = nativeFetch; +} +const { PathnameContext } = require('next/dist/shared/lib/hooks-client-context.shared-runtime'); + +// Adapted from the independent security probe: only SDK/network boundaries are +// synthetic. Actual auth runtime subscriptions drive the actual keyed Bench UI. +for (const fails of [true, false, 'unconfigured']) { + const unavailable = fails === 'unconfigured'; + test(unavailable ? 'default-off availability guidance stays in the sign-in flow, not a page-wide failure' : fails ? 'deferred SDK cleanup rejection remains visible after the owner Bench unmounts' : 'clean SDK sign-out leaves no recovery alert', async t => { + const localConfig = unavailable ? source('lib/lab-oauth-config.ts').getLabOAuthConfig({}) : config; + const dom = new JSDOM('
', { url: origin + '/lab/profile/' }); + const globals = Object.fromEntries(['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent', 'KeyboardEvent', 'StorageEvent', 'localStorage'].map(key => [key, dom.window[key]])); + Object.assign(globals, { self: dom.window, IS_REACT_ACT_ENVIRONMENT: true }); + const previous = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + Object.assign(globalThis, globals); + let root; + t.after(async () => { + if (root) await act(() => root.unmount()); + dom.window.close(); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + window.HTMLDialogElement.prototype.showModal = function () { this.open = true; }; + window.HTMLDialogElement.prototype.close = function () { this.open = false; }; + t.mock.method(globalThis, 'fetch', async url => { + assert.equal(url, '/api/lab/capabilities/', 'No live network or mutations'); + return Response.json(localConfig); + }); + + let resolveCleanup, rejectCleanup, cleanupCalls = 0; + const sdkSession = { sub: did, did, signOut: () => { + cleanupCalls++; + return new Promise((resolve, reject) => { resolveCleanup = resolve; rejectCleanup = reject; }); + } }; + const runtime = createLabAuthRuntime({ + loadConfig: async () => localConfig, + loadClient: async () => ({ init: async () => ({ session: sdkSession }), authorize: async () => { throw Error('Unexpected authorization'); } }), + location: () => ({ origin, pathname: '/lab/profile/' }), + replace: () => { throw Error('Unexpected redirect'); }, + }); + await runtime.initialize(); + t.mock.method(identity, 'useLabIdentity', () => ({ + ...React.useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getServerSnapshot), + logout: runtime.logout, login: runtime.login, authorizeWrite: runtime.authorizeWrite, + })); + t.mock.method(client, 'createLabClient', (...args) => ({ + ...realClient(...args), + records: async () => ({ profile: null, profileRecord: null, records: [], limit: 30, hasMore: false, cursors: {} }), + })); + const { createRoot } = require('react-dom/client'); + root = createRoot(document.getElementById('root')); + await act(async () => { + root.render(React.createElement(PathnameContext.Provider, { value: '/lab/profile/' }, React.createElement(Shell, null, React.createElement(Profile)))); + }); + if (unavailable) { + assert.equal(runtime.getSnapshot().isAuthenticated, false); + assert.equal(runtime.getSnapshot().error, localConfig.message); + assert.ok(!document.querySelector('[role="alert"]'), 'Expected default-off configuration is not a page-wide failure'); + await act(()=>document.querySelector('[aria-label="Sign in to Open Lab"]').click()); + assert.match(document.querySelector('dialog').textContent, /Sign-in is unavailable on this preview/); + assert.ok(document.querySelector('dialog button[type="submit"]').disabled); + return; + } + const oldBench = document.querySelector('.lab-workbench'); + const shell = document.querySelector('.open-lab'); + const signOut = [...document.querySelectorAll('button')].find(button => button.textContent.trim() === 'Sign out'); + assert.ok(oldBench); + assert.ok(signOut); + assert.equal(runtime.getSnapshot().isAuthenticated, true); + assert.equal(document.querySelector('[role="alert"]'), null); + + await act(() => signOut.click()); + assert.equal(cleanupCalls, 1); + assert.equal(oldBench.isConnected, false, 'Owner-keyed Bench unmounts before SDK cleanup settles'); + const guestBench = document.querySelector('.lab-workbench'); + assert.ok(guestBench); + assert.notEqual(guestBench, oldBench); + assert.equal(signOut.isConnected, false); + assert.equal(runtime.getSnapshot().error, null); + assert.equal(runtime.getSnapshot().isAuthenticated, false); + assert.equal(runtime.getSnapshot().session, null); + assert.equal(runtime.getSnapshot().oauthSession, null); + + await act(async () => { + if (fails) rejectCleanup(Error('Synthetic persistent store failure')); + else resolveCleanup(); + await new Promise(resolve => setImmediate(resolve)); + }); + assert.equal(runtime.getSnapshot().isAuthenticated, false); + assert.equal(runtime.getSnapshot().session, null); + assert.equal(runtime.getSnapshot().oauthSession, null); + assert.equal(runtime.getSnapshot().isLoading, false); + assert.ok(document.querySelector('.open-lab'),'shell remains available after owner-scoped provider remount'); + assert.equal(document.querySelector('.lab-workbench'), guestBench); + if (fails) { + const message = runtime.getSnapshot().error; + assert.match(message, /Signed out of this page.*local session cleanup could not be confirmed/); + assert.match(message, /Clear this site’s browser storage and revoke Open Lab access in your account settings/); + const alert = document.querySelector('[role="alert"]'); + assert.ok(alert, 'Runtime cleanup recovery guidance must remain visible after the old Bench unmounts'); + assert.equal(alert.textContent, message, 'Display exact runtime guidance, not a generic failure or guaranteed revocation claim'); + assert.equal(alert.closest('.lab-workbench'), null, 'Recovery alert belongs outside the owner-keyed subtree'); + } else { + assert.equal(runtime.getSnapshot().error, null); + assert.equal(document.querySelector('[role="alert"]'), null); + } + }); +} diff --git a/scripts/lab-science-tree-refresh.test.mjs b/scripts/lab-science-tree-refresh.test.mjs new file mode 100644 index 00000000..c571bf0c --- /dev/null +++ b/scripts/lab-science-tree-refresh.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' + +const snapshot = JSON.parse(readFileSync('src/data/lab-science-tree.json', 'utf8')) +const byId = new Map(snapshot.nodes.map(node => [node.id, node])) +const columns = ['topic_id', 'topic_name', 'subfield_id', 'subfield_name', 'field_id', 'field_name', 'domain_id', 'domain_name', 'keywords', 'summary', 'wikipedia_url'] +// Reconstructed CSV is a deterministic local fixture, not a claimed source download. +const rows = snapshot.nodes.filter(node => node.kind === 'topic').map(topic => { + const row = { keywords: topic.keywords, summary: topic.description, wikipedia_url: '' } + for (let node = topic; node; node = byId.get(node.parent)) { + row[`${node.kind}_id`] = node.sourceId + row[`${node.kind}_name`] = node.label + } + return row +}) +const csv = data => [columns.join(','), ...data.map(row => columns.map(key => `"${String(row[key]).replaceAll('"', '""')}"`).join(','))].join('\n') + '\n' +function run(t, data) { + const dir = mkdtempSync(join(tmpdir(), 'lab-science-refresh-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const input = join(dir, 'input.csv'), output = join(dir, 'output.json') + writeFileSync(input, data) + writeFileSync(output, 'preserve previous snapshot') + const result = spawnSync('python3', ['scripts/refresh-lab-science-tree.py', '--input', input, '--checked-on', '2026-09-11', '--output', output], { encoding: 'utf8', timeout: 15000 }) + assert.equal(result.error, undefined) + return { result, output: readFileSync(output, 'utf8') } +} + +test('offline refresh preserves all source nodes, descriptions and IDs and stamps the actual input hash', t => { + const data = csv(rows) + const { result, output } = run(t, data) + assert.equal(result.status, 0, result.stderr) + const refreshed = JSON.parse(output) + assert.deepEqual(refreshed.nodes, snapshot.nodes) + assert.deepEqual(refreshed.counts, snapshot.counts) + assert.equal(refreshed.provenance.sourceSha256, createHash('sha256').update(data).digest('hex')) +}) + +for (const [name, mutate, message] of [ + ['malformed IDs without normalization', data => { data[0].topic_id = 'T10001' }, /Invalid source topic ID/], + ['duplicate topics', data => { data.push(data[0]) }, /Duplicate topic/], + ['conflicting ancestor labels', data => { data[1].domain_name = 'conflicting name' }, /Conflicting source hierarchy/], + ['incomplete universes', data => { data.pop() }, /Source universe changed or is incomplete/], + ['missing descriptions', data => { data[0].summary = '' }, /Missing description/], +]) test(`offline refresh rejects ${name} without replacing the previous file`, t => { + const data = structuredClone(rows); mutate(data) + const { result, output } = run(t, csv(data)) + assert.notEqual(result.status, 0) + assert.match(result.stderr, message) + assert.equal(output, 'preserve previous snapshot') +}) diff --git a/scripts/lab-science-tree-ui.test.mjs b/scripts/lab-science-tree-ui.test.mjs new file mode 100644 index 00000000..08b6f1a8 --- /dev/null +++ b/scripts/lab-science-tree-ui.test.mjs @@ -0,0 +1,202 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = module => { module.exports = { __esModule: true, default: new Proxy({}, { get: (_, key) => String(key) }) } } +const dom = new JSDOM('', { url: 'https://example.org/lab/explorations/observatory/' }) +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }) +const React = await import('react') +const { createRoot } = await import('react-dom/client') +const { act } = React +async function mount(t, url = '/lab/explorations/observatory/', props = {}) { + window.history.replaceState({}, '', url) + const Component = source('components/lab/explorations/Observatory.tsx').default + const container = document.createElement('div'); document.body.append(container) + const root = createRoot(container) + await act(async () => root.render(React.createElement(Component, props))) + t.after(async () => { await act(async () => root.unmount()); container.remove() }) + return container +} +const click = async el => { assert.ok(el, 'expected control'); await act(async () => el.click()) } +const button = (view, text) => [...view.querySelectorAll('button')].find(el => el.textContent === text || el.getAttribute('aria-label') === text) +const input = async (el, value) => { assert.ok(el); await act(async () => { + Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set.call(el, value) + el.dispatchEvent(new dom.window.Event('input', { bubbles: true })) +}) } + +test('search and layout survive a shared URL and returning from a result; Escape clears without losing focus', async t => { + const view = await mount(t, '/lab/explorations/observatory/?node=field%3A19&q=Veterinary&view=list&overlay=plrd') + const search = view.querySelector('input[type="search"]') + assert.equal(search.value, 'Veterinary', 'shared URL must restore whole-snapshot search') + assert.equal(view.querySelector('input[type="checkbox"]').checked, true) + assert.equal(button(view, 'List').getAttribute('aria-pressed'), 'true') + const before = window.location.href + await click(view.querySelector('[data-science-node="field:34"]')) + assert.equal(search.value, '') + assert.equal(new URLSearchParams(window.location.search).get('view'), 'list', 'branch navigation must retain layout in its URL') + assert.equal(new URLSearchParams(window.location.search).get('overlay'), 'plrd') + await act(async () => { + window.history.replaceState({}, '', before) + window.dispatchEvent(new dom.window.PopStateEvent('popstate')) + }) + assert.equal(search.value, 'Veterinary') + assert.ok(view.querySelector('[data-science-node="field:34"]')) + await act(async () => { + search.focus() + search.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + assert.equal(search.value, '') + assert.equal(document.activeElement.tagName, 'INPUT') + assert.equal(new URLSearchParams(window.location.search).has('q'), false) + await input(search, 'Oceanography') + assert.equal(new URLSearchParams(window.location.search).get('q'), 'Oceanography') +}) + +test('a legacy deep link lands keyboard focus on its contextual brief rather than burying it below the atlas', async t => { + const view = await mount(t, '/lab/explorations/observatory/neural-measurements/', { initialQuestion: 'neural-measurements' }) + assert.equal(document.activeElement.id, 'brief-title', 'legacy route must focus its contextual brief on arrival') + assert.match(view.querySelector('#brief-title').textContent, /Which neural measurements/) +}) + +test('route metadata describes the broad atlas, and every scoped style resolves with readable surfaces', async () => { + const route = source('app/lab/explorations/observatory/page.tsx') + assert.match(route.metadata.description, /OpenAlex/, 'route metadata still advertises the narrow computing frontier') + assert.equal(route.metadata.robots.index, false) + const { readFileSync } = await import('node:fs') + const postcss = (await import('postcss')).default + const css = postcss.parse(readFileSync('src/components/lab/explorations/science-tree.module.css', 'utf8')) + const selectors = new Set() + css.walkRules(rule => { for (const match of rule.selector.matchAll(/\.([a-zA-Z][\w-]*)/g)) selectors.add(match[1]) }) + for (const file of ['Observatory', 'ScienceBranchGraph']) for (const match of readFileSync(`src/components/lab/explorations/${file}.tsx`, 'utf8').matchAll(/(? { const declarations = {}; css.walkRules(selector, rule => rule.walkDecls(d => { declarations[d.prop] = d.value })); return declarations } + assert.equal(rules('.scienceTree')['color-scheme'], 'light') + assert.equal(rules(':global(.dark) .scienceTree')['color-scheme'], 'dark') + assert.equal(rules('.scienceTree h1')['font-size'], '22px') + assert.equal(rules('.scienceTree h2')['font-size'], '17px') + assert.equal(rules('.scienceTree button')['min-height'], '44px') + assert.equal(rules('.graphViewport').overflow, 'auto') + assert.ok(css.nodes.some(n => n.type === 'atrule' && n.params.includes('prefers-reduced-motion'))) +}) + +test('a selected branch has an explicit Up control and search accurately announces its list layout', async t => { + const view = await mount(t, '/lab/explorations/observatory/?node=field%3A19') + const up = view.querySelector('a[aria-label="Up one level"]') + assert.ok(up, 'explicit back-to-parent control is missing') + await click(up) + assert.equal(new URLSearchParams(window.location.search).get('node'), 'domain:3') + await click(button(view, 'Map')) + await input(view.querySelector('input[type="search"]'), 'Oceanography') + assert.equal(button(view, 'List').getAttribute('aria-pressed'), 'true') + assert.equal(button(view, 'Map').getAttribute('aria-pressed'), 'false') + assert.equal(button(view, 'Map').disabled, true) + await click(button(view, 'Clear search')) + assert.equal(button(view, 'Map').disabled, false) +}) + +test('PL R&D highlights are an optional overlay; legacy briefs retain exact source-linked routes', async t => { + const view = await mount(t) + const ids = () => [...view.querySelectorAll('[data-science-node]')].map(n => n.dataset.scienceNode) + const before = ids() + assert.equal(view.querySelectorAll('[data-pl-overlay]').length, 0) + await click(view.querySelector('input[type="checkbox"]')) + assert.ok(view.querySelectorAll('[data-pl-overlay]').length > 0, 'overlay must highlight related branches, not only open a text disclaimer') + assert.deepEqual(ids(), before, 'overlay never removes non-PL science') + await click(view.querySelector('[data-question="neural-measurements"]')) + assert.equal(window.location.pathname, '/lab/explorations/observatory/neural-measurements/') + assert.equal(new URLSearchParams(window.location.search).has('node'), false) + assert.equal(view.querySelector('#science-branch-title').textContent, 'Neuroscience') + assert.match(view.querySelector('[data-question-brief]').textContent, /Which neural measurements are actually comparable/) + assert.equal(document.activeElement.id, 'brief-title') + assert.ok(view.querySelector('[data-question-brief] a[href="https://github.com/lksbrssr/neuro-atlas"]')) + assert.equal(view.querySelector('input[aria-label="Direct link to this brief"]').value, window.location.href) +}) + +test('invalid deep links fail visibly without rewriting any storage; modifier links stay native', async t => { + window.localStorage.setItem('openlab.synthetic.unrelated', '{corrupt') + const before = [...Array(window.localStorage.length)].map((_, i) => [window.localStorage.key(i), window.localStorage.getItem(window.localStorage.key(i))]) + const view = await mount(t, '/lab/explorations/observatory/?node=__proto__') + assert.match(view.textContent, /This branch is not in this snapshot/) + assert.equal(view.querySelector('#science-branch-title').textContent, 'All sciences') + const anchor = view.querySelector('[data-science-node="domain:3"]') + const event = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }) + await act(async () => anchor.dispatchEvent(event)) + assert.equal(event.defaultPrevented, false) + assert.equal(window.location.search, '?node=__proto__') + await click(anchor) + assert.doesNotMatch(view.textContent, /This branch is not in this snapshot/) + const after = [...Array(window.localStorage.length)].map((_, i) => [window.localStorage.key(i), window.localStorage.getItem(window.localStorage.key(i))]) + assert.deepEqual(after, before) +}) + +test('map navigation has real bounded zoom, pan, reset, keyboard controls and a mobile list alternative', async t => { + const view = await mount(t) + await click(button(view, 'Map')) + const viewport = view.querySelector('[aria-label="Science branch map"]') + assert.ok(viewport, 'an interactive branch map is missing') + assert.equal(viewport.getAttribute('tabindex'), '0') + assert.equal(view.querySelectorAll('[data-containment-edge]').length, 4) + assert.ok(view.querySelectorAll('[data-science-node]').length <= 8) + const scene = view.querySelector('[data-science-scene]') + const original = scene.style.transform + await click(button(view, 'Zoom in')) + assert.notEqual(scene.style.transform, original) + await click(button(view, 'Pan right')) + assert.ok(viewport.scrollLeft > 0) + await act(async () => viewport.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))) + assert.ok(viewport.scrollTop > 0) + await click(button(view, 'Reset view')) + assert.equal(scene.style.transform, original) + assert.equal(viewport.scrollLeft, 0) + assert.equal(viewport.scrollTop, 0) + for (let i = 0; i < 20; i++) await click(button(view, 'Zoom in')) + assert.equal(button(view, 'Zoom in').disabled, true) + await click(button(view, 'List')) + assert.equal(view.querySelector('[data-science-scene]'), null) + assert.equal(view.querySelectorAll('[data-science-node]').length, 4) +}) + +test('a researcher drills the complete hierarchy and returns through breadcrumbs and browser history', async t => { + const view = await mount(t) + assert.ok(view.querySelector('[data-science-node="domain:3"]'), 'broad science branches are missing') + for (const id of ['domain:3', 'field:19', 'subfield:1908']) { + await click(view.querySelector(`[data-science-node="${id}"]`)) + assert.equal(new URLSearchParams(window.location.search).get('node'), id) + assert.equal(view.querySelector('#science-branch-title').textContent, source('lib/lab-science-tree.ts').getScienceNode(id).label) + assert.equal(document.activeElement, view.querySelector('#science-branch-title')) + } + await click(button(view, 'List')) + // Global search crosses the current branch; exact names rank first. + await input(view.querySelector('input[type="search"]'), 'Geological and Geochemical Analysis') + await click(view.querySelector('[data-science-node="topic:T10001"]')) + assert.match(view.querySelector('[data-science-detail]').textContent, /tectonic evolution/i) + assert.ok(view.querySelector('a[href="https://openalex.org/T10001"]')) + assert.equal(view.querySelector('[aria-label="Science breadcrumb"]').querySelectorAll('a').length, 4) + assert.equal(view.querySelector('input[aria-label="Direct link to this branch"]').value, 'https://example.org/lab/explorations/observatory/?node=topic%3AT10001') + assert.equal(view.querySelector('input[aria-label="Link to this view"]').value, window.location.href) + await click(view.querySelector('[aria-label="Science breadcrumb"] a')) + assert.equal(view.querySelector('#science-branch-title').textContent, 'All sciences') + await act(async () => { + window.history.replaceState({}, '', '/lab/explorations/observatory/?node=topic%3AT10001') + window.dispatchEvent(new dom.window.PopStateEvent('popstate')) + }) + assert.match(view.querySelector('[data-science-detail]').textContent, /Geological and Geochemical Analysis/) + assert.equal(view.querySelectorAll('iframe, canvas').length, 0) +}) + +test('search spans non-PL fields, gives an honest empty state, and leaves source attribution inspectable', async t => { + const view = await mount(t) + await input(view.querySelector('input[type="search"]'), 'Veterinary') + assert.ok(view.querySelector('[data-science-node="field:34"]')) + assert.match(view.textContent, /whole snapshot/i) + await input(view.querySelector('input[type="search"]'), 'no-such-field-zzzz') + assert.match(view.textContent, /No matches/) + await click(button(view, 'Clear search')) + assert.ok(view.querySelector('[data-science-node="domain:1"]')) + assert.match(view.textContent, /4,516 topics/) + assert.match(view.textContent, /not an exhaustive ontology/i) + assert.match(view.textContent, /machine.generated/i) + assert.ok(view.querySelector('a[href="https://help.openalex.org/data/topics/"]')) + assert.match(view.textContent, /CC0/) +}) diff --git a/scripts/lab-science-tree.test.mjs b/scripts/lab-science-tree.test.mjs new file mode 100644 index 00000000..87b7d45f --- /dev/null +++ b/scripts/lab-science-tree.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const model = () => { + assert.ok(existsSync('src/lib/lab-science-tree.ts'), 'a broad sourced science model is missing') + return source('lib/lab-science-tree.ts') +} + +test('branch graph fits laptop panes and packs sparse roots without a blank upper half', () => { + const m = model() + assert.equal(typeof m.scienceGraphFrame, 'function') + const root = m.scienceGraphFrame(4, 841) + assert.equal(root.height, 304) + assert.ok(root.width * root.fit <= 825) + assert.equal(root.positions[0].y, 12) + assert.ok(root.positions.every(p => p.y >= 0 && p.y + 128 <= root.height)) + const full = m.scienceGraphFrame(8, 760) + assert.equal(full.height, 584) + assert.ok(full.fit >= .94, 'preserve at least 14.1px titles, then pan rather than shrinking to fit') + assert.equal(m.scienceGraphFrame(4, 619).fit, m.scienceGraphFrame(4, 620).fit, 'one-pixel wider pane cannot shrink labels abruptly') + assert.ok(m.scienceGraphFrame(4, 620).fit >= .94) + assert.ok(m.scienceGraphFrame(4, 320).fit >= .94, 'opt-in phone map pans readable nodes; phone defaults to List') +}) + +test('global search, source links, breadcrumbs and branch pagination cover the full universe', () => { + const m = model() + assert.equal(typeof m.searchScience, 'function', 'full-universe search is missing') + assert.equal(m.getScienceChildren('science').length, 4) + assert.deepEqual(m.getSciencePath('topic:T10001').map(n => n.id), ['science', 'domain:3', 'field:19', 'subfield:1908', 'topic:T10001']) + assert.equal(m.searchScience('Geological and Geochemical Analysis')[0].id, 'topic:T10001') + assert.ok(m.searchScience('zircon').some(n => n.id === 'topic:T10001')) + assert.ok(m.searchScience('veterinary').length > 0) + assert.deepEqual(m.searchScience('nonexistent-zzzzzz'), []) + assert.deepEqual(m.searchScience(' '), []) + const children = m.getScienceChildren('field:27') + const first = m.pageScienceNodes(children, 0, 8) + const second = m.pageScienceNodes(children, 1, 8) + assert.equal(first.items.length, 8) + assert.equal(first.total, children.length) + assert.ok(second.items.every(n => !first.items.some(a => a.id === n.id))) + assert.deepEqual(m.pageScienceNodes([], 999, 8), { items: [], page: 0, pages: 1, total: 0 }) + assert.equal(m.pageScienceNodes(children, -5, 8).page, 0) + assert.equal(m.scienceHref('topic:T10001'), '/lab/explorations/observatory/?node=topic%3AT10001') + assert.throws(() => m.scienceHref('__proto__'), /Unknown/) + assert.equal(m.scienceSourceHref('topic:T10001'), 'https://openalex.org/T10001') + assert.equal(m.scienceSourceHref('field:19'), 'https://api.openalex.org/fields/19') + assert.equal(m.getScienceNode('__proto__'), undefined) + assert.equal(m.getScienceChildren('topic:T10001').length, 0) +}) + +test('the pinned OpenAlex snapshot contains every sourced hierarchy node exactly once', () => { + const { scienceNodes, scienceSnapshot, validateScienceSnapshot, getSciencePath } = model() + assert.deepEqual(validateScienceSnapshot(scienceSnapshot), []) + assert.deepEqual(scienceSnapshot.counts, { domain: 4, field: 26, subfield: 252, topic: 4516 }) + assert.equal(scienceNodes.length, 4799) // includes one explicitly editorial universe root + assert.equal(new Set(scienceNodes.map(n => n.id)).size, scienceNodes.length) + assert.match(scienceSnapshot.provenance.sourceSha256, /^[a-f0-9]{64}$/) + assert.equal(scienceSnapshot.provenance.license, 'CC0') + for (const node of scienceNodes.filter(n => n.kind === 'topic')) { + const path = getSciencePath(node.id) + assert.deepEqual(path.map(n => n.kind), ['root', 'domain', 'field', 'subfield', 'topic']) + assert.equal(node.id, `topic:T${node.sourceId}`) + assert.ok(node.description.length > 0) + } + const fields = scienceNodes.filter(n => n.kind === 'field').map(n => n.label) + for (const label of ['Mathematics', 'Physics and Astronomy', 'Chemistry', 'Medicine', 'Arts and Humanities', 'Social Sciences', 'Agricultural and Biological Sciences']) assert.ok(fields.includes(label), label) + const corrupt = structuredClone(scienceSnapshot) + corrupt.nodes[4].parent = 'missing:parent' + assert.ok(validateScienceSnapshot(corrupt).some(e => /parent/i.test(e))) + const duplicate = structuredClone(scienceSnapshot) + duplicate.nodes.push(duplicate.nodes[0]) + assert.ok(validateScienceSnapshot(duplicate).some(e => /duplicate/i.test(e))) +}) diff --git a/scripts/lab-security-regressions-auth.test.mjs b/scripts/lab-security-regressions-auth.test.mjs new file mode 100644 index 00000000..e7089037 --- /dev/null +++ b/scripts/lab-security-regressions-auth.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' + +test('F4: local cleanup failure leaves runtime signed out and offers account-side recovery without remote claims', async () => { + const h = harness({ signOutError: true }) + await h.runtime.initialize() + await assert.rejects(h.runtime.logout(), /local session cleanup/i) + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.equal(h.runtime.getSnapshot().oauthSession, null) + assert.match(h.runtime.getSnapshot().error, /account settings/) + assert.doesNotMatch(h.runtime.getSnapshot().error, /private SDK/) + const docs = fs.readFileSync('docs/open-lab/protocol-runbook.md', 'utf8') + assert.match(docs, /best-effort remote revocation/) + assert.doesNotMatch(docs, /a revocation failure is displayed/) +}) + +test('F4: installed SDK can suppress a remote revocation failure while still deleting local storage', async () => { + const require = createRequire(import.meta.url) + const sdk = createRequire(require.resolve('@atproto/oauth-client-browser')) + const { OAuthSession } = sdk('@atproto/oauth-client') + const { OAuthServerAgent } = await import(pathToFileURL(path.join(path.dirname(sdk.resolve('@atproto/oauth-client')), 'oauth-server-agent.js'))) + let requests = 0, removed = 0 + const h = harness() + h.session.signOut = () => OAuthSession.prototype.signOut.call({ sub: did, getTokenSet: async () => ({ access_token: 'MOCK_TOKEN_ONLY' }), server: { revoke: token => OAuthServerAgent.prototype.revoke.call({ request: async () => { requests++; throw new Error('Synthetic offline'); } }, token) }, sessionGetter: { delStored: async () => { removed++; } } }) + await h.runtime.initialize() + await h.runtime.logout() + assert.equal(requests, 1) + assert.equal(removed, 1) + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.equal(h.runtime.getSnapshot().error, null, 'SDK success is not remote-revocation confirmation') +}) +const { createLabAuthRuntime } = source('lib/lab-auth.tsx') +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +const origin = 'https://lab.example.org' +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }) +globalThis.fetch = async () => { throw new Error('Live network forbidden') } +function deferred() { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } } + +test('F2: logout or invalidation cancels an already-pending authorization before navigation', async () => { + for (const action of ['login', 'authorizeWrite']) { + for (const cancel of ['logout', 'deleted']) { + const authorize = deferred(), h = harness({ authorize }) + await h.runtime.initialize() + const pending = action === 'login' ? h.runtime.login('science.bsky.social', '/lab/') : h.runtime.authorizeWrite('note', 'create', '/lab/') + await tick() + if (cancel === 'logout') await h.runtime.logout(); else h.deleted() + authorize.resolve(new URL('https://auth.example.org/authorize')) + await pending + assert.deepEqual(h.redirects, [], `${action}/${cancel}`) + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.equal(h.runtime.getSnapshot().isLoading, false) + } + } +}) + +const tick = () => new Promise(r => setImmediate(r)) +function harness({ init, signOutError = false, authorize } = {}) { + let deleted, signs = 0 + const redirects = [] + const session = { sub: did, did, signOut: async () => { signs++; if (signOutError) throw new Error('private SDK failure'); deleted(did) } } + const runtime = createLabAuthRuntime({ loadConfig: async () => config, loadClient: async (_config, onDeleted) => { deleted = onDeleted; return { init: async () => init ? init.promise : { session }, authorize: async () => authorize ? authorize.promise : new URL('https://auth.example.org/authorize'), signInRedirect: async () => { const url = authorize ? await authorize.promise : new URL('https://auth.example.org/authorize'); redirects.push(url.href) } } }, location: () => ({ origin, pathname: '/lab/' }), replace: path => redirects.push(path) }) + return { runtime, session, redirects, deleted: () => deleted(did), signs: () => signs } +} + +test('F2: awaited logout invalidates pending restore/callback and signs out the late session', async () => { + for (const signOutError of [false, true]) { + const init = deferred(), h = harness({ init, signOutError }) + const pending = h.runtime.initialize() + await tick() + await h.runtime.logout() + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + init.resolve({ session: h.session, state: '/lab/profile/#draft' }) + await pending + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.equal(h.runtime.getSnapshot().oauthSession, null) + assert.equal(h.runtime.getSnapshot().isLoading, false) + assert.equal(h.signs(), 1) + assert.deepEqual(h.redirects, []) + } +}) + +test('F2: logout from a session subscriber also cancels the callback navigation', async () => { + const init = deferred(), h = harness({ init }) + let logout + h.runtime.subscribe(() => { if (h.runtime.getSnapshot().isAuthenticated) logout = h.runtime.logout() }) + const pending = h.runtime.initialize() + await tick() + init.resolve({ session: h.session, state: '/lab/profile/' }) + await pending + await logout + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.deepEqual(h.redirects, []) +}) + +test('F2: SDK invalidation during pending initialization prevents late authentication and callback redirect', async () => { + const init = deferred(), h = harness({ init }) + const pending = h.runtime.initialize() + await tick() + h.deleted() + init.resolve({ session: h.session, state: '/lab/profile/' }) + await pending + assert.equal(h.runtime.getSnapshot().isAuthenticated, false) + assert.equal(h.runtime.getSnapshot().oauthSession, null) + assert.equal(h.signs(), 1) + assert.deepEqual(h.redirects, []) +}) diff --git a/scripts/lab-security-regressions-transport.test.mjs b/scripts/lab-security-regressions-transport.test.mjs new file mode 100644 index 00000000..17778f5a --- /dev/null +++ b/scripts/lab-security-regressions-transport.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const { createLabRecordWriter } = source('lib/lab-records.ts') +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', origin = 'https://lab.example.org' +const cid = 'bafyreie5nqv6kd3qnfjuprw2scvucpip4llntfhthpcwhlwuewpghmfesa' +const note = { text: 'Synthetic test only', field: 'neurotech', postType: 'question' } +const profile = { workingOn: 'Synthetic', lookingFor: 'Review', interests: [] } +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }) +globalThis.window = { location: { origin } } +globalThis.fetch = async () => { throw new Error('Live network forbidden') } +function harness({ action = 'create', override, timeoutMs = 30 } = {}) { + const kind = action === 'update' ? 'profile' : 'note' + const initialUri = `at://${did}/org.plresearch.lab.${kind}/${kind === 'profile' ? 'self' : 'synthetic'}` + let stored = { uri: initialUri, cid, value: { ...(kind === 'profile' ? profile : note), $type: `org.plresearch.lab.${kind}`, community: 'https://www.plrd.org/lab/', createdAt: '2025-01-01T00:00:00.000Z' } } + let committed = false, target = initialUri + const calls = [] + const session = { sub: did, did, getTokenInfo: async () => ({ sub: did, aud: 'https://pds.example.org', scope: `atproto repo:org.plresearch.lab.${kind}?action=${action}` }), async fetchHandler(path, init) { + assert.equal(this, session, 'official session method receiver retained') + calls.push({ path, init }) + const mutation = !path.includes('getRecord') + if (mutation) { + const body = await new Response(init.body).json() + target = `at://${did}/${body.collection}/${body.rkey}` + if (action !== 'create') assert.equal(body.swapRecord, cid) + if (action !== 'delete') stored = { uri: target, cid, value: body.record } + committed = true + } + const context = { path, init, mutation, committed, stored, target } + const custom = override?.(context) + if (custom !== undefined) return custom + if (mutation) return Response.json(action === 'delete' ? {} : { uri: target, cid }) + if (committed && action === 'delete') return Response.json({ error: 'RecordNotFound' }, { status: 400 }) + return Response.json(stored) + } } + const writer = createLabRecordWriter(session, async () => config, { timeoutMs }) + const consent = { public: true, experimental: true, did, action, ...(action !== 'create' ? { expectedCid: cid } : {}) } + return { calls, session, writer, target: () => target, run: () => action === 'delete' ? writer.delete(initialUri, consent) : writer.publish(kind, action === 'update' ? { ...profile, workingOn: 'Updated' } : note, consent) } +} + + + +test('F3: normal create/update/delete keep CAS, latest readback and finite signals', async () => { + for (const action of ['create', 'update', 'delete']) { + const h = harness({ action, timeoutMs: 100 }) + const receipt = await h.run() + assert.equal(receipt.verified, true) + assert.equal(receipt.uri, h.target()) + assert.equal(h.calls.length, action === 'create' ? 2 : 3) + assert.ok(h.calls.every(c => c.init.signal instanceof AbortSignal)) + for (const call of h.calls.filter(c => c.path.includes('getRecord'))) assert.equal(new URL(call.path, origin).searchParams.has('cid'), false) + if (action === 'update') assert.equal(receipt.record.createdAt, '2025-01-01T00:00:00.000Z') + } +}) + +test('F3: postcommit update/delete timeout returns only the exact ambiguous target and never retries', async () => { + for (const action of ['update', 'delete']) { + const h = harness({ action, override: ({ committed, mutation }) => committed && !mutation ? new Promise(() => {}) : undefined }) + const result = await settles(h.run()) + assert.equal(result.error?.name, 'LabWriteVerificationError') + assert.equal(result.error.uri, h.target()) + assert.equal(h.calls.length, 3) + assert.equal(h.calls.filter(c => !c.path.includes('getRecord')).length, 1) + } +}) + +test('F3: precommit verification timeout never attempts an update/delete', async () => { + for (const action of ['update', 'delete']) { + const h = harness({ action, override: ({ committed }) => !committed ? new Promise(() => {}) : undefined }) + const result = await settles(h.run()) + assert.ok(result.error) + assert.notEqual(result.error.name, 'LabWriteVerificationError', 'no mutation was attempted') + assert.equal(h.calls.length, 1) + assert.ok(h.calls.every(c => c.path.includes('getRecord'))) + } +}) + +test('F3: bad authority, consent and session actor make zero transport requests', async () => { + for (const bad of ['authority', 'consent', 'actor']) { + const h = harness() + if (bad === 'actor') h.session.did = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb' + await assert.rejects(h.writer.publish('note', { ...note, ...(bad === 'authority' ? { authorDid: did } : {}) }, { public: bad !== 'consent', experimental: true, did, action: 'create' })) + assert.equal(h.calls.length, 0) + } +}) + +test('F3: bounds run before SDK buffering, cancel overflow and dispose late responses', async () => { + const { boundedLabFetch, LAB_AUTH_MAX_RESPONSE_BYTES } = source('lib/lab-bounded-transport.ts') + let chunks = 0, canceled = 0 + const fetchBounded = boundedLabFetch({ fetchHandler: async () => { + const response = new Response(new ReadableStream({ pull(controller) { chunks++; controller.enqueue(new Uint8Array(262144)); }, cancel() { canceled++ } }), { headers: { 'content-type': 'application/json', 'content-length': '10' } }) + response.arrayBuffer = () => { throw new Error('Original unbounded buffer must not be called') } + return response + } }, { timeoutMs: 100 }) + await assert.rejects(fetchBounded('/synthetic', {}), /1 MiB/) + assert.equal(canceled, 1) + assert.ok(chunks <= 6, 'stream stops at the cap with at most one prefetched chunk') + const exact = boundedLabFetch({ fetchHandler: async () => new Response(new Uint8Array(LAB_AUTH_MAX_RESPONSE_BYTES)) }) + assert.equal((await (await exact('/synthetic', {})).arrayBuffer()).byteLength, LAB_AUTH_MAX_RESPONSE_BYTES) + let release + const late = boundedLabFetch({ fetchHandler: () => new Promise(resolve => { release = resolve }) }, { timeoutMs: 10 }) + await assert.rejects(late('/synthetic', {}), /deadline/) + release(new Response(new ReadableStream({ cancel() { canceled++ } }))) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(canceled, 2) +}) + +test('F3: caller abort is preserved and invalid deadline options cannot remove bounds', async () => { + const { boundedLabFetch } = source('lib/lab-bounded-transport.ts') + let requests = 0 + const session = { fetchHandler: async () => { requests++; return new Response('{}') } } + for (const timeoutMs of [0, -1, Infinity, NaN, 12001]) assert.throws(() => boundedLabFetch(session, { timeoutMs }), /deadline/) + const controller = new AbortController() + controller.abort(new Error('caller canceled')) + await assert.rejects(boundedLabFetch(session)('/synthetic', { signal: controller.signal }), /caller canceled/) + assert.equal(requests, 0) +}) + +async function settles(promise) { + let timer + try { return await Promise.race([promise.then(receipt => ({ receipt }), error => ({ error })), new Promise(resolve => { timer = setTimeout(() => resolve({ stalled: true }), 250) })]) } + finally { clearTimeout(timer) } +} + +test('F3: stalled responses and nonending streams terminate despite transports ignoring abort', async () => { + for (const stage of ['mutation', 'readback']) { + for (const mode of ['response', 'stream']) { + let canceled = 0 + const h = harness({ override: ({ mutation }) => { + if (mutation !== (stage === 'mutation')) return undefined + if (mode === 'response') return new Promise(() => {}) + return new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('{')); }, cancel() { canceled++; return new Promise(() => {}) } }), { headers: { 'content-type': 'application/json' } }) + } }) + const result = await settles(h.run()) + assert.equal(result.error?.name, 'LabWriteVerificationError', `${stage}/${mode} must settle as ambiguous, not stall or succeed`) + assert.equal(result.error.uri, h.target()) + assert.equal(h.calls.filter(c => !c.path.includes('getRecord')).length, 1) + assert.equal(h.calls.at(-1).init.signal.aborted, true) + if (mode === 'stream') assert.equal(canceled, 1) + } + } +}) + +test('F3: >1 MiB success-looking mutation and readback envelopes cannot produce verified receipts', async () => { + for (const stage of ['mutation', 'readback']) { + const h = harness({ override: ({ mutation, stored, target }) => mutation === (stage === 'mutation') ? Response.json({ ...(mutation ? { uri: target, cid } : stored), padding: 'x'.repeat(1_100_000) }) : undefined }) + await assert.rejects(h.run(), error => error.name === 'LabWriteVerificationError' && error.uri === h.target()) + assert.equal(h.calls.filter(c => !c.path.includes('getRecord')).length, 1, 'no duplicate write') + assert.ok(h.calls.every(c => c.init.signal instanceof AbortSignal)) + } +}) diff --git a/scripts/lab-security-regressions-url.test.mjs b/scripts/lab-security-regressions-url.test.mjs new file mode 100644 index 00000000..519797d9 --- /dev/null +++ b/scripts/lab-security-regressions-url.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' +const { safeLabHttpsUrl, validateLabData } = source('lib/lab-validation.ts') +const { createLabRecordWriter } = source('lib/lab-records.ts') +globalThis.fetch = async () => { throw new Error('Live network forbidden') } +const origin = 'https://lab.example.org' +const did = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' +globalThis.window = { location: { origin } } +const note = { text: 'Synthetic research link', field: 'neurotech', postType: 'question' } +const config = source('lib/lab-oauth-config.ts').getLabOAuthConfig({ LAB_PUBLIC_URL: origin, LAB_ENABLE_PUBLISH: 'true' }) + +test('F1: credential fragments and encoded hash-router parameter names fail before any writer transport', async () => { + for (const fragment of ['access_token=MOCK', '/return?access_token=MOCK', '/return?%61ccess%5ftoken=MOCK', '?refresh_token=MOCK', 'methods&api_key=MOCK', '/route?topic=neuro&%73ignature=MOCK', 'id_token=MOCK']) { + const url = `https://example.org/#${fragment}` + let requests = 0 + const session = { sub: did, did, getTokenInfo: async () => ({ sub: did, aud: 'https://pds.example.org', scope: 'atproto repo:org.plresearch.lab.note?action=create' }), fetchHandler: async () => { requests++; throw new Error('Unexpected transport') } } + assert.throws(() => safeLabHttpsUrl(url), /credential-bearing/, fragment) + assert.throws(() => validateLabData('note', { ...note, evidenceUrl: url }), /credential-bearing/) + await assert.rejects(createLabRecordWriter(session, async () => config).publish('note', { ...note, evidenceUrl: url }, { public: true, experimental: true, did, action: 'create' }), /credential-bearing/) + assert.equal(requests, 0, fragment) + } +}) + +test('F1: harmless research anchors and query links remain byte-for-byte intact', () => { + for (const url of ['https://example.org/paper#methods', 'https://example.org/paper#:~:text=neural%20recording', 'https://example.org/#/paper?section=methods', 'https://scholar.google.com/citations?user=Researcher&hl=en', 'https://doi.org/10.1000/example#section-2']) { + assert.equal(safeLabHttpsUrl(url), url) + assert.equal(validateLabData('note', { ...note, evidenceUrl: url }).evidenceUrl, url) + } +}) diff --git a/scripts/lab-shell-responsive.test.mjs b/scripts/lab-shell-responsive.test.mjs new file mode 100644 index 00000000..15ce7f62 --- /dev/null +++ b/scripts/lab-shell-responsive.test.mjs @@ -0,0 +1,62 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import postcss from 'postcss'; + +const path = 'src/components/lab/lab-app-shell.css'; +test('app shell uses a compact scoped responsive layout with drawer and centered geometry', () => { + assert.ok(existsSync(path), 'App shell stylesheet exists'); + const css = readFileSync(path, 'utf8'); const root = postcss.parse(css); + const rules = []; root.walkRules(rule => rules.push(rule)); + assert.ok(rules.every(rule => rule.selector.includes('.lab-app-shell') || rule.parent.name === 'keyframes'), 'Styles must stay inside the app shell'); + for (const token of ['--lab-nav-width: 224px','--lab-feed-width: 1100px','--lab-context-width: 300px','--lab-content-width: 1600px']) assert.ok(css.includes(token), token); + assert.match(css, /\.lab-sidebar\s*\{[^}]*position:\s*fixed/); + assert.match(css, /\[data-variant="drawer"\]\s*\{[^}]*height:\s*100dvh/); + assert.match(css, /\[data-variant="centered"\]/); + assert.match(css, /@media\s*\(max-width:\s*1199px\)/); + assert.match(css, /@media\s*\(max-width:\s*800px\)/); + assert.match(css, /prefers-reduced-motion/); + assert.match(readFileSync('src/components/lab/LabShell.tsx','utf8'), /import.*lab-app-shell\.css/); +}); + +test('Observatory has genuine light instrument, node, inspector and input surfaces with dark opt-in', () => { + const root = postcss.parse(readFileSync('src/components/lab/explorations/lab-explorations.module.css', 'utf8')); + const declarations = selector => {const found = {}; root.walkRules(selector, rule => rule.walkDecls(d => found[d.prop] = d.value)); return found;}; + assert.equal(declarations('.observatory')['color-scheme'], 'light'); + assert.equal(declarations('.observatory').background, 'var(--paper)'); + for (const selector of ['.mapCenter','.questionNodes a','.questionBrief','.shareField input']) assert.match(declarations(selector).background, /^var\(/, selector); + assert.equal(declarations(':global(.dark) .observatory')['color-scheme'], 'dark'); + assert.equal(declarations('.mapGeometry').color, 'var(--line)'); + assert.equal(declarations('.mapGeometry :is(path, ellipse, line)').stroke, 'currentColor'); + assert.equal(declarations('.mapGeometry path[data-selected=\'true\']').stroke, 'var(--accent)'); + const observatory = readFileSync('src/components/lab/explorations/Observatory.tsx', 'utf8'); + assert.doesNotMatch(observatory, /stroke=\"#(?:303843|414a56|566270)\"/, 'Field-map geometry must not hardcode dark strokes in light mode'); + assert.match(observatory, /stroke=\"currentColor\"/, 'Field-map geometry should inherit the light instrument line color'); +}); + +test('public site invitation uses the action-first Open Lab invitation copy', () => { + const page = readFileSync('src/app/page.tsx', 'utf8'); + assert.match(page, /Made something that makes science easier\?/); +}); + +// Run after canonical browser-harness records actual layout probes. Normal test runs +// still check the CSS contracts; no synthetic geometry is substituted for a browser. +test('section headings retain the app scale rather than legacy marketing size', () => { + const css = readFileSync(path, 'utf8'); + const rule = /\.open-lab\.lab-app-shell \.lab-section-heading h2\s*\{([^}]+)\}/.exec(css)?.[1]; + assert.ok(rule, 'Legacy profile section selector needs an equally specific app-scale rule'); + assert.match(rule, /font-size:\s*17px/); +}); + +const probeFile = process.env.LAB_SHELL_PROBE_FILE; +test('real responsive browser probes have no overflow and retain the scope/account/navigation', {skip:!probeFile}, () => { + const probes = JSON.parse(readFileSync(probeFile, 'utf8')); + assert.ok(probes.length >= 4, 'Desktop, tablet, mobile, and small mobile required'); + for (const p of probes) { + assert.ok(p.scrollWidth <= p.width, `No horizontal overflow at ${p.width}: ${p.scrollWidth}`); + assert.equal(p.activeNavCount, 1, p.route); + assert.ok(p.scopeVisible && p.accountVisible && p.searchVisible, `Persistent actions at ${p.width}`); + assert.ok(p.headingSize <= 32, `Heading ${p.headingSize}px at ${p.width}`); + assert.ok(p.width > 800 ? p.sidebarVisible : p.menuVisible, `Navigation at ${p.width}`); + } +}); diff --git a/scripts/lab-shell-v2.test.mjs b/scripts/lab-shell-v2.test.mjs new file mode 100644 index 00000000..9c5b2726 --- /dev/null +++ b/scripts/lab-shell-v2.test.mjs @@ -0,0 +1,213 @@ +import { test, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import React, { act } from 'react'; +import { source } from './velocity/test-source-loader.mjs'; +process.env.__NEXT_TRAILING_SLASH = 'true'; +const require = createRequire(import.meta.url); +require.extensions['.css'] = m => { m.exports = new Proxy({}, {get: (_, p) => p === '__esModule' ? false : String(p)}); }; +const dom = new JSDOM('
', {url: 'https://lab.example.org/lab/'}); +for (const key of ['window', 'document', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent', 'KeyboardEvent', 'StorageEvent', 'localStorage', 'sessionStorage']) globalThis[key] = dom.window[key]; +window.matchMedia = () => ({matches: false, addEventListener(){}, removeEventListener(){}}); +globalThis.self = window; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +window.HTMLDialogElement.prototype.showModal = function(){this.open = true;}; +window.HTMLDialogElement.prototype.close = function(){this.open = false;}; +const { createRoot } = await import('react-dom/client'); +const { PathnameContext } = require('next/dist/shared/lib/hooks-client-context.shared-runtime'); +// Cross-lane interface only: never install a placeholder in production source. +const gatePath = resolve('src/components/lab/social/LabOnboardingGate.tsx'); +const gateIsPending = !existsSync(gatePath); +if (gateIsPending) { + const Module = require('node:module'); const original = Module._resolveFilename; + Module._resolveFilename = function(id, ...args) {return id === '@/components/lab/social/LabOnboardingGate' ? gatePath : original.call(this, id, ...args);}; + require.cache[gatePath] = {id:gatePath, filename:gatePath, loaded:true, exports:{__esModule:true, default:() => React.createElement('span', {'data-onboarding-interface':true})}}; +} +const auth = source('lib/lab-identity.ts'); +let root, identity, loginCalls, configured; +const mount = async (C, props = {}) => act(async () => { root.render(React.createElement(PathnameContext.Provider, {value: window.location.pathname}, React.createElement(C, props))); await new Promise(r => setTimeout(r, 10)); }); +const click = async text => { const el = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === text || b.getAttribute('aria-label') === text); assert.ok(el, text); await act(() => el.click()); }; +const fill = async (selector, value) => {const el = document.querySelector(selector); assert.ok(el, selector); await act(() => {Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, value); el.dispatchEvent(new Event('input', {bubbles:true}));});}; +beforeEach(() => { + localStorage.clear(); sessionStorage.clear(); window.history.replaceState(null, '', '/lab/'); + document.documentElement.classList.remove('dark'); document.body.style.overflow = ''; + loginCalls = []; configured = false; + identity = {isAuthenticated: false, isLoading: false, session: null, error: null, login: async (...args) => {loginCalls.push(args); throw Error('Provider unavailable in test');}}; + mock.method(auth, 'useLabIdentity', () => identity); + mock.method(globalThis, 'fetch', async path => {assert.ok(['/api/lab/feed/', '/api/lab/capabilities/'].includes(path), `Unexpected network: ${path}`); return Response.json(path.includes('feed') ? {items:[], status:'empty'} : {canSignIn:configured, canPublish:false, mode:configured?'ready':'unconfigured', message:configured?'':'Sign-in is unavailable on this origin.'});}); + root = createRoot(document.getElementById('root')); +}); +afterEach(async () => {await act(() => root.unmount()); mock.restoreAll();}); + +test('action navigation selects one destination, and global search and account remain accessible', async () => { + const Shell = source('components/lab/LabShell.tsx').default; + sessionStorage.setItem('open-lab:welcome:v2', 'seen'); + const destinations = [['/lab/', 'Catch up'], ['/lab/bottlenecks/', 'Work on ideas'], ['/lab/apps/', 'Find tools'], ['/lab/explorations/observatory/', 'Explore the tech tree'], ['/lab/collaborate/', 'Contribute'], ['/lab/people/', 'Find people'], ['/lab/profile/', 'My bench']]; + for (const route of ['/lab/', '/lab/feed/', '/lab/bottlenecks/', '/lab/people/', '/lab/explorations/observatory/']) { + window.history.replaceState(null, '', route); await mount(Shell); + const nav = document.querySelector('nav[aria-label="Open Lab"]'); assert.ok(nav); + assert.deepEqual([...nav.querySelectorAll('a')].map(a => [a.getAttribute('href'), a.textContent.trim()]), destinations); + const selected = nav.querySelectorAll('[aria-current="page"]'); assert.equal(selected.length, 1); + assert.equal(selected[0].getAttribute('href'), route === '/lab/feed/' ? '/lab/' : route); + } + const search = document.querySelector('form[role="search"]'); assert.ok(search); + for (const route of ['/lab/atlas/', '/lab/efforts/']) { + window.history.replaceState(null, '', route); await mount(Shell); + const selected = document.querySelectorAll('#lab-sidebar [aria-current="page"]'); + assert.equal(selected.length, 1); assert.equal(selected[0].getAttribute('href'), route); + } + assert.equal(search.getAttribute('action'), '/lab/feed/'); assert.equal(search.getAttribute('method'), 'get'); + assert.ok(search.querySelector('input[name="q"][type="search"][aria-label]')); + assert.equal(search.querySelector('input').getAttribute('aria-label'), 'Search work'); + assert.equal(search.querySelector('input').getAttribute('placeholder'), 'Search feed ideas, tools, and requests'); + assert.ok(document.querySelector('button[aria-label="Sign in to Open Lab"]')); + assert.ok(document.querySelector('.lab-header-actions a[href="/lab/profile/"][aria-label="My bench"]'), 'Unsigned people still need a persistent profile control'); + identity = {...identity, isAuthenticated:true, session:{did:'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',handle:'person.example.org',displayName:'Test person'}}; + await mount(Shell); + assert.ok(document.querySelector('.lab-header-actions a[aria-label="My bench — Test person"]')); + const menu = document.querySelector('button[aria-controls="lab-sidebar"]'); assert.ok(menu); assert.equal(menu.getAttribute('aria-expanded'), 'false'); + await act(() => menu.click()); assert.equal(menu.getAttribute('aria-expanded'), 'true'); +}); + +test('single global scope control opens a truthful demo disclosure and switches without touching drafts', async () => { + const Shell = source('components/lab/LabShell.tsx').default; + sessionStorage.setItem('open-lab:welcome:v2', 'seen'); + localStorage.setItem('shell-test-real-draft', 'unchanged'); await mount(Shell); + assert.ok(!document.querySelector('.lab-composition-banner'), 'No page-wide mode banner'); + assert.equal(document.querySelectorAll('[data-lab-scope-control]').length, 1); + assert.match(document.querySelector('[data-lab-scope-control]').textContent, /Demo.*on/); + assert.equal(document.querySelector('dialog'), null); + await act(() => document.querySelector('[data-lab-scope-control]').click()); + const dialog = document.querySelector('dialog'); assert.ok(dialog); + assert.match(dialog.textContent, /example people and activity/i); + assert.match(dialog.textContent, /local.*demo|demo.*local/i); + assert.match(dialog.textContent, /no messages.*sent/i); + assert.match(dialog.textContent, /not.*scientific evidence/i); + await click('Show real / empty view'); + assert.match(document.querySelector('[data-lab-scope-control]').textContent, /Demo.*off/); + assert.equal(localStorage.getItem('shell-test-real-draft'), 'unchanged'); + assert.ok(document.querySelector('.lab-header-actions [aria-label^="Your next actions"]')); + identity.isLoading = true; await mount(Shell); + assert.ok(document.querySelector('.lab-header-actions [aria-label="Notifications loading"]'), 'Toolbar keeps its notification affordance while auth settles'); + identity.isLoading = false; await mount(Shell); + await click('Show demo community'); + assert.ok(document.querySelector('.lab-header-actions [aria-label^="Demo notifications:"]')); +}); + +test('welcome waits for auth, invites once per session, and never interrupts OAuth return', async () => { + const Shell = source('components/lab/LabShell.tsx').default; + identity.isLoading = true; await mount(Shell); assert.ok(!document.querySelector('dialog')); + identity.isLoading = false; await mount(Shell); + assert.ok(document.querySelector('dialog'), 'Settled guest gets a real login invitation'); + assert.match(document.querySelector('dialog').textContent, /Continue browsing/); + await click('Continue browsing'); + await act(() => root.unmount()); root = createRoot(document.getElementById('root')); + window.history.replaceState(null, '', '/lab/apps/'); await mount(Shell); assert.ok(!document.querySelector('dialog'), 'No repeat on remount or navigation'); + sessionStorage.clear(); + await act(() => root.unmount()); root = createRoot(document.getElementById('root')); + window.history.replaceState(null, '', '/lab/oauth/return/#code=test&state=test'); await mount(Shell); assert.ok(!document.querySelector('dialog')); +}); + +test('welcome uses the existing identity API, keeps errors, and fails closed when unconfigured', async () => { + const Shell = source('components/lab/LabShell.tsx').default; + await mount(Shell); assert.ok(document.querySelector('dialog')); + assert.ok(document.querySelector('dialog button[type="submit"]').disabled, 'Unavailable sign-in is not offered as working'); + assert.match(document.querySelector('dialog').textContent, /Sign-in is unavailable on this preview/); + await fill('input[name="handle"]', 'person.bsky.social'); + await act(async () => {document.querySelector('dialog form').dispatchEvent(new Event('submit', {bubbles:true,cancelable:true}));}); + assert.equal(loginCalls.length, 0); assert.match(document.querySelector('dialog').textContent, /unavailable/i); + await act(() => root.unmount()); root = createRoot(document.getElementById('root')); configured = true; + await mount(Shell); await click('Sign in to Open Lab'); + await fill('input[name="handle"]', 'person.bsky.social'); + await act(async () => {document.querySelector('dialog form').dispatchEvent(new Event('submit', {bubbles:true,cancelable:true}));}); + assert.deepEqual(loginCalls, [['person.bsky.social','/lab/']]); + assert.match(document.querySelector('dialog [role="alert"]').textContent, /Provider unavailable in test/); + assert.doesNotMatch(document.body.textContent, /signed in successfully/i); +}); + +test('LabDialog defaults to a named right drawer, traps focus, and restores scrolling and trigger focus', async () => { + const Dialog = source('components/lab/LabDialog.tsx').default; + let closed = 0; const trigger = document.createElement('button'); trigger.textContent = 'Inspect source'; document.body.append(trigger); trigger.focus(); + document.body.style.overflow = 'scroll'; + await mount(Dialog, {title:'Source detail', onClose:() => closed++, wide:true, children:React.createElement('button', {id:'last-control'}, 'Review source')}); + const dialog = document.querySelector('dialog'); + assert.equal(dialog.dataset.variant, 'drawer'); assert.equal(dialog.getAttribute('aria-modal'), 'true'); + assert.equal(document.getElementById(dialog.getAttribute('aria-labelledby')).textContent, 'Source detail'); + assert.equal(document.body.style.overflow, 'hidden'); + assert.ok(dialog.classList.contains('lab-dialog-wide')); + const last = document.getElementById('last-control'); last.focus(); + await act(() => last.dispatchEvent(new KeyboardEvent('keydown', {key:'Tab',bubbles:true,cancelable:true}))); + assert.equal(document.activeElement.getAttribute('aria-label'), 'Close dialog'); + await act(() => dialog.dispatchEvent(new Event('cancel', {cancelable:true}))); assert.equal(closed, 1); + await act(() => root.render(null)); + assert.equal(document.body.style.overflow, 'scroll'); assert.ok(document.activeElement === trigger); trigger.remove(); + await mount(Dialog, {title:'Welcome', variant:'centered', onClose:() => {}, children:'Browse freely'}); + assert.equal(document.querySelector('dialog').dataset.variant, 'centered'); +}); + +test('nested LabDialogs keep body locked until the last one closes', async () => { + const Dialog = source('components/lab/LabDialog.tsx').default; + const Both = ({inner}) => React.createElement(Dialog, {title:'Outer', onClose:()=>{}}, inner && React.createElement(Dialog, {title:'Inner', onClose:()=>{}}, 'Review')); + await mount(Both, {inner:true}); assert.equal(document.body.style.overflow, 'hidden'); + await mount(Both, {inner:false}); assert.equal(document.body.style.overflow, 'hidden'); + await act(() => root.render(null)); assert.equal(document.body.style.overflow, ''); +}); + +test('ResearchMap paints from theme tokens instead of a fixed dark palette', async () => { + const Map = source('components/lab/ResearchMap.tsx').default; + await mount(Map); + const geometry = document.querySelector('.lab-map-svg'); assert.ok(geometry); + const colors = [...geometry.querySelectorAll('[fill], [stroke]')].flatMap(e => [e.getAttribute('fill'),e.getAttribute('stroke')]).filter(Boolean); + assert.ok(colors.every(c => !c.startsWith('#')), 'Every SVG color follows the current light/dark theme'); + assert.ok(colors.some(c => c.includes('var(--lab-blue)'))); + assert.match(document.querySelector('.lab-map-legend').textContent, /not endorsements/); + const systems = [...geometry.querySelectorAll('.lab-map-field')].find(t => t.textContent === 'OPEN SYSTEMS'); + assert.ok(Number(systems.getAttribute('y')) > 128, 'Field label sits below its hub, clear of the content-addressing label'); +}); + +test('shell mounts the worker onboarding interface once, only after verified identity restoration', async () => { + const code = readFileSync('src/components/lab/LabShell.tsx', 'utf8'); + assert.match(code, /import LabOnboardingGate from ["']@\/components\/lab\/social\/LabOnboardingGate["']/); + assert.equal((code.match(//g) || []).length, 1); + if (!gateIsPending) return; // The integrated worker owns its own behavior tests. + const Shell = source('components/lab/LabShell.tsx').default; + sessionStorage.setItem('open-lab:welcome:v2', 'seen'); await mount(Shell); + assert.equal(document.querySelectorAll('[data-onboarding-interface]').length, 0); + identity = {...identity,isAuthenticated:true,isLoading:true,session:{did:'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',handle:'person.example.org'}}; + await mount(Shell); assert.equal(document.querySelectorAll('[data-onboarding-interface]').length, 0); + identity.isLoading = false; await mount(Shell); + assert.equal(document.querySelectorAll('[data-onboarding-interface]').length, 1); +}); + +test('the single demo provider keeps local activity isolated by authenticated DID', async () => { + const {useDemoCommunity} = source('components/lab/demo/DemoCommunityProvider.tsx'); + const Shell = source('components/lab/LabShell.tsx').default; + const a = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', b = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; + let demo; + function Probe() { demo = useDemoCommunity(); return React.createElement('span', null, demo.state.scope); } + identity = {...identity,isLoading:false,isAuthenticated:true,session:{did:a,handle:'a.example.org'}}; + await mount(Shell, {children:React.createElement(Probe)}); + assert.equal(demo.state.scope,a,'The actual shell must pass its authenticated identity to the demo provider'); + await act(() => {assert.equal(demo.act({type:'reply',threadId:'split-boundary',text:'A local demo note'}).ok,true)}); + assert.equal(demo.state.replies.length,1); + identity = {...identity,session:{did:b,handle:'b.example.org'}}; + await mount(Shell, {children:React.createElement(Probe)}); + assert.equal(demo.state.scope,b); assert.equal(demo.state.replies.length,0); + identity = {...identity,session:{did:a,handle:'a.example.org'}}; + await mount(Shell, {children:React.createElement(Probe)}); + assert.equal(demo.state.replies[0].text,'A local demo note'); +}); + +test('home renders the real FeedWorkbench below one slim invitation', async () => { + await mount(source('components/lab/Landing.tsx').default); + assert.ok(document.querySelector('[aria-label="Mixed science feed"]'), 'Actual feed is the default home surface'); + const invitation = document.querySelector('[aria-label="Workshop invitation"]'); + assert.ok(invitation, 'A single compact workshop header owns the invitation and composer'); + assert.match(invitation.textContent, /Catch up.*Find what changed/); + assert.ok(invitation.querySelector('button[aria-label="What are you making? Show a build →"]')); + assert.equal(document.querySelector('.lab-welcome'), null, 'No second marketing invitation above the feed'); + assert.equal(document.querySelector('.lab-hero'), null); + assert.equal(document.querySelector('.lab-home-work'), null); +}); diff --git a/scripts/lab-social-ui.test.mjs b/scripts/lab-social-ui.test.mjs new file mode 100644 index 00000000..cc78a4cd --- /dev/null +++ b/scripts/lab-social-ui.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = () => {} +const dom = new JSDOM('
', { url: 'http://localhost/lab/onboarding/' }) +for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'CustomEvent', 'KeyboardEvent', 'localStorage']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const React = await import('react') +const { createRoot } = await import('react-dom/client') +let networkCalls = 0 +globalThis.fetch = async () => { networkCalls++; throw Error('No network allowed in social UI') } +const click = async text => { + const el = [...document.querySelectorAll('button, a')].find(el => el.textContent.trim() === text) + assert.ok(el, `Missing action: ${text}`) + await React.act(() => el.click()) +} +const fill = async (label, value) => { + const el = document.querySelector(`[aria-label="${label}"]`) + assert.ok(el, `Missing field: ${label}`) + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + await React.act(() => { Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })) }) +} +async function mount(name, props = {}) { + const file = `components/lab/social/${name}.tsx` + assert.ok(existsSync(`src/${file}`), `${name} missing`) + const Component = source(file)[name] + const root = createRoot(document.getElementById('root')) + await React.act(() => root.render(React.createElement(Component, props))) + return { root, unmount: () => React.act(() => root.unmount()) } +} + +test('blocked dismiss is visible and keeps the action; changing owner never exposes guest prompts or drafts', async () => { + localStorage.clear() + const { saveDraft } = source('lib/lab-drafts.ts') + saveDraft(localStorage, 'note', 'guest', { text: 'Guest-only synthetic note' }) + const view = await mount('LabActionInbox') + const original = dom.window.Storage.prototype.setItem + try { + await React.act(() => document.querySelector('button[aria-label^="Your next actions"]').click()) + dom.window.Storage.prototype.setItem = () => { throw Error('quota exceeded') } + await React.act(() => document.querySelector('button[aria-label="Dismiss Complete your profile draft"]').click()) + assert.match(document.querySelector('[role="alert"]').textContent, /Could not save/) + assert.ok(document.querySelector('button[aria-label="Dismiss Complete your profile draft"]')) + dom.window.Storage.prototype.setItem = original + const Component = source('components/lab/social/LabActionInbox.tsx').LabActionInbox + await React.act(() => view.root.render(React.createElement(Component, { ownerId: 'did:plc:test-only-owner' }))) + assert.equal(document.querySelector('[role="dialog"]'), null) + assert.match(document.querySelector('button[aria-label^="Your next actions"]').getAttribute('aria-label'), /2 unread, 2 actions/) + } finally { dom.window.Storage.prototype.setItem = original; await view.unmount() } +}) + +test('onboarding route uses the parent provider and never attempts a public write', async () => { + localStorage.clear() + const Page = source('app/lab/onboarding/page.tsx').default + const calls = [] + const previous = globalThis.fetch + globalThis.fetch = async (url, init) => { calls.push({ url, method: init?.method || 'GET' }); throw Error('Unconfigured synthetic test environment') } + const root = createRoot(document.getElementById('root')) + try { + await React.act(async () => { root.render(React.createElement(source('lib/lab-auth.tsx').LabAuthProvider, null, React.createElement(Page))); await new Promise(resolve => setTimeout(resolve, 20)) }) + assert.match(document.body.textContent, /Save starting choices/) + assert.match(document.body.textContent, /Save profile locally/) + assert.ok(calls.every(c => c.method === 'GET')) + } finally { await React.act(() => root.unmount()); globalThis.fetch = previous } +}) + +test('bell counts real local actions, persists mark/dismiss across reopen, restores focus, and resolves completed profile prompts', async () => { + localStorage.clear() + const { saveDraft } = source('lib/lab-drafts.ts') + saveDraft(localStorage, 'note', 'guest', { text: 'Test-only unfinished note' }) + saveDraft(localStorage, 'contribution:example', 'guest', { observation: 'Test-only evidence proposal' }) + let view = await mount('LabActionInbox') + const bell = () => document.querySelector('button[aria-label^="Your next actions"]') + try { + assert.match(bell().getAttribute('aria-label'), /4 unread, 4 actions/) + await React.act(() => bell().click()) + assert.match(document.body.textContent, /Review your local evidence proposal/) + assert.match(document.body.textContent, /Resume your saved note draft/) + const mark = document.querySelector('button[aria-label="Mark Complete your profile draft read"]') + await React.act(() => mark.click()) + assert.match(bell().getAttribute('aria-label'), /3 unread, 4 actions/) + const dismiss = document.querySelector('button[aria-label="Dismiss Complete your profile draft"]') + await React.act(() => dismiss.click()) + assert.match(bell().getAttribute('aria-label'), /3 unread, 3 actions/) + await React.act(() => document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))) + assert.equal(document.querySelector('[role="dialog"]'), null) + assert.equal(document.activeElement, bell()) + await view.unmount() + view = await mount('LabActionInbox') + assert.match(bell().getAttribute('aria-label'), /3 unread, 3 actions/) + await React.act(() => bell().click()) + await click('Restore dismissed prompts') + assert.match(bell().getAttribute('aria-label'), /3 unread, 4 actions/) + saveDraft(localStorage, 'profile', 'guest', { workingOn: 'Test-only question', lookingFor: 'Test-only help', interests: 'neurotech' }) + await React.act(() => window.dispatchEvent(new Event('open-lab:local-drafts-changed'))) + assert.match(bell().getAttribute('aria-label'), /2 unread, 2 actions/) + assert.doesNotMatch(document.body.textContent, /Complete your profile draft/) + assert.equal(networkCalls, 0) + } finally { await view.unmount() } +}) + +test('completion panel saves actual work and optional links, gives no credit for invalid URLs, and supports skipping', async () => { + localStorage.clear() + const { loadDraft, saveDraft } = source('lib/lab-drafts.ts') + saveDraft(localStorage, 'profile', 'guest', { interests: 'neurotech' }) + const view = await mount('ProfileCompletion') + try { + await fill('What are you working on?', 'A test-only reproducibility question') + await fill('What help are you looking for?', 'A source checker') + await fill('LinkedIn URL', 'https://www.linkedin.com/company/not-a-person/') + await click('Save profile locally') + assert.equal(loadDraft(localStorage, 'profile', 'guest').data.workingOn, 'A test-only reproducibility question') + assert.match(document.body.textContent, /3 of 4 useful fields/) + assert.match(document.body.textContent, /Check this LinkedIn profile URL/) + await click('Skip LinkedIn') + assert.match(document.body.textContent, /3 of 3 useful fields/) + assert.equal(networkCalls, 0) + } finally { await view.unmount() } +}) + +test('React onboarding saves/reloads canonical interests and mode, preserves work, and recomputes recommendations without publishing', async () => { + localStorage.clear() + const { saveDraft, loadDraft } = source('lib/lab-drafts.ts') + saveDraft(localStorage, 'profile', 'guest', { workingOn: 'Test-only draft', interests: 'existing specialty', githubUrl: 'https://github.com/example' }) + let view = await mount('InterestOnboarding') + try { + await click('Neuroscience') + await click('Check evidence') + assert.ok(document.querySelector('a[href="/lab/bottlenecks/?case=reproducibility"]')) + assert.match(document.querySelector('.lab-social-recommendations').textContent, /You chose Neuroscience/) + await click('Save starting choices') + const saved = loadDraft(localStorage, 'profile', 'guest').data + assert.equal(saved.workingOn, 'Test-only draft') + assert.equal(saved.githubUrl, 'https://github.com/example') + assert.match(saved.interests, /existing specialty/) + assert.match(saved.interests, /neurotech/) + assert.match(document.body.textContent, /Saved in this browser/) + await view.unmount() + view = await mount('InterestOnboarding') + assert.equal([...document.querySelectorAll('button')].find(b => b.textContent === 'Neuroscience').getAttribute('aria-pressed'), 'true') + assert.equal([...document.querySelectorAll('button')].find(b => b.textContent === 'Check evidence').getAttribute('aria-pressed'), 'true') + await click('Neuroscience') + await click('AI & machine learning') + assert.equal(document.querySelector('a[href="/lab/bottlenecks/?field=neurotech"]'), null) + assert.ok(document.querySelector('a[href="/lab/bottlenecks/?case=reproducibility"]')) + assert.match(document.querySelector('.lab-social-recommendations').textContent, /You chose AI & machine learning/) + assert.doesNotMatch(document.querySelector('.lab-social-recommendations').textContent, /You chose Neuroscience/) + assert.equal(networkCalls, 0) + } finally { await view.unmount() } +}) diff --git a/scripts/lab-social.test.mjs b/scripts/lab-social.test.mjs new file mode 100644 index 00000000..a5d27f9c --- /dev/null +++ b/scripts/lab-social.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +function memoryStore() { + const map = new Map() + return { get length() { return map.size }, key: i => [...map.keys()][i] ?? null, getItem: k => map.get(k) ?? null, setItem: (k, v) => map.set(k, v) } +} + +test('profile edits share the bench draft, preserve fields, and never transfer between owners', () => { + const api = social() + assert.equal(typeof api.saveSocialProfile, 'function', 'canonical profile seam missing') + const { saveDraft, loadDraft } = source('lib/lab-drafts.ts') + const store = memoryStore() + saveDraft(store, 'profile', 'guest', { workingOn: 'Synthetic draft', lookingFor: 'Source checks', interests: 'custom interest', githubUrl: 'https://github.com/example' }) + assert.equal(api.saveSocialProfile(store, 'guest', { interests: ['neurotech', 'custom interest'] }).ok, true) + const data = loadDraft(store, 'profile', 'guest').data + assert.equal(data.workingOn, 'Synthetic draft') + assert.equal(data.githubUrl, 'https://github.com/example') + assert.equal(data.interests, 'neurotech, custom interest') + assert.deepEqual(api.loadSocialState(store, 'did:plc:test-owner').profile, {}) + assert.equal(api.saveSocialMeta(store, 'guest', { mode: 'evidence', dismissed: ['profile'] }).ok, true) + assert.equal(api.loadSocialState(store, 'guest').meta.mode, 'evidence') + assert.deepEqual(api.loadSocialState(store, 'did:plc:test-owner').meta.dismissed, []) +}) + +test('corrupt and blocked storage fail visibly and never overwrite unreadable drafts', () => { + const api = social() + assert.equal(typeof api.loadSocialState, 'function', 'safe persistence missing') + const { draftKey } = source('lib/lab-drafts.ts') + const store = memoryStore() + store.setItem(draftKey('profile', 'guest'), '{broken') + assert.match(api.loadSocialState(store, 'guest').error, /could not be read/) + assert.equal(api.saveSocialProfile(store, 'guest', { interests: ['neurotech'] }).ok, false) + assert.equal(store.getItem(draftKey('profile', 'guest')), '{broken') + const blocked = { getItem() { throw Error('blocked') }, setItem() { throw Error('blocked') }, get length() { throw Error('blocked') } } + assert.match(api.loadSocialState(blocked, 'guest').error, /unavailable/) + assert.equal(api.saveSocialMeta(blocked, 'guest', { mode: 'tools' }).ok, false) + const silent = { ...memoryStore(), setItem() {} } + assert.equal(api.saveSocialProfile(silent, 'guest', { workingOn: 'Lost write' }).ok, false) +}) + +test('completion counts useful fields, excludes omitted/skipped links, and rejects malformed links', () => { + const api = social() + assert.equal(typeof api.profileCompletion, 'function', 'completion missing') + const profile = { workingOn: 'Synthetic work', lookingFor: 'Source review', interests: ['neurotech'] } + assert.deepEqual([api.profileCompletion({}).completed, api.profileCompletion({}).total], [0, 3]) + assert.deepEqual([api.profileCompletion(profile).completed, api.profileCompletion(profile).total], [3, 3]) + const invalid = api.profileCompletion({ ...profile, linkedinUrl: 'javascript:alert(1)' }) + assert.deepEqual([invalid.completed, invalid.total, invalid.percent], [3, 4, 75]) + assert.equal(api.profileCompletion({ ...profile, scholarUrl: 'https://scholar.google.com/citations?user=sample' }).completed, 4) + assert.equal(api.profileCompletion({ ...profile, githubUrl: 'https://github.com/example/repo' }).completed, 3) + assert.equal(api.profileCompletion({ ...profile, linkedinUrl: 'bad' }, ['linkedinUrl']).total, 3) + assert.equal(api.profileCompletion({ ...profile, interests: [] }).completed, 2) + assert.equal(api.profileCompletion({ ...profile, workingOn: 'unsafe\u202evalue', interests: ['bad\u0000interest'] }).completed, 1) + assert.equal(api.profileCompletion({ ...profile, workingOn: 'é'.repeat(1200) }).completed, 3) +}) + +test('LinkedIn profile support is optional and retains profile and other record constraints', () => { + const { validateLabData } = source('lib/lab-validation.ts') + const data = { workingOn: 'Synthetic work', interests: [], lookingFor: 'Source review' } + const url = 'https://www.linkedin.com/in/sample-person/' + assert.deepEqual(validateLabData('profile', { ...data, linkedinUrl: url }), { ...data, linkedinUrl: url }) + assert.deepEqual(validateLabData('profile', data), data) + for (const linkedinUrl of ['https://linkedin.com/company/example/', 'https://linkedin.com.evil.org/in/example', 'http://linkedin.com/in/example', 'https://www.linkedin.com/in/example?token=secret', 'https://www.linkedin.com/in/example#bad', 'javascript:alert(1)', 'https://www.linkedin.com/in/']) assert.throws(() => validateLabData('profile', { ...data, linkedinUrl })) + assert.throws(() => validateLabData('profile', { ...data, verified: true })) + assert.throws(() => validateLabData('note', { text: 'A note', field: 'neurotech', postType: 'question', linkedinUrl: url })) +}) + +test('unreadable storage never invents incomplete-profile actions', () => { + const api = social() + const blocked = { getItem() { throw Error('blocked') }, setItem() { throw Error('blocked') }, get length() { throw Error('blocked') } } + const actions = api.localNextActions(api.loadSocialState(blocked, 'guest')) + assert.deepEqual(actions, []) +}) + +function social() { + assert.ok(existsSync('src/lib/lab-social.ts'), 'social recommendations are not implemented') + return source('lib/lab-social.ts') +} + +test('starting places prioritize bottlenecks and explain interest/mode choices deterministically', () => { + const { recommendStartingPlaces } = social() + const result = recommendStartingPlaces(['neurotech', 'unknown'], 'evidence') + assert.equal(result[0].href, '/lab/bottlenecks/?case=reproducibility') + assert.match(result[0].why, /Neuroscience/) + assert.ok(result.some(r => r.href === '/lab/bottlenecks/?case=reproducibility')) + assert.deepEqual(result, recommendStartingPlaces(['neurotech', 'unknown'], 'evidence')) + assert.ok(recommendStartingPlaces(['cross-field'], 'tools').some(r => r.href === '/lab/apps/')) + assert.ok(recommendStartingPlaces([], 'intervention').some(r => r.href === '/lab/collaborate/')) + assert.ok(recommendStartingPlaces(['unknown'], '').every(r => !r.href.includes('unknown'))) +}) diff --git a/scripts/lab-status.test.mjs b/scripts/lab-status.test.mjs new file mode 100644 index 00000000..a8c9c095 --- /dev/null +++ b/scripts/lab-status.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import path from 'node:path' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +const sessionPath = path.resolve('src/lib/session.ts') +let current = {} +require.cache[sessionPath] = { id: sessionPath, filename: sessionPath, loaded: true, exports: { getSession: async () => { + if (current instanceof Error) throw current + return current +} } } + +test('auth status returns only public identity fields, never serialized OAuth credentials or future private fields', async () => { + current = { did: 'did:plc:jfhpnnst6flqway4eaeqzj2a', handle: 'test.bsky.social', displayName: 'Test', avatar: 'https://cdn.bsky.app/avatar/example', oauthSession: JSON.stringify({ refreshToken: 'MOCK_REFRESH_ONLY', dpopJwk: { d: 'MOCK_PRIVATE_KEY_ONLY' } }), futurePrivate: 'MOCK_FUTURE_SECRET' } + const { GET } = source('app/api/status/route.ts') + const response = await GET() + assert.equal(response.status, 200) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + assert.deepEqual(await response.json(), { did: current.did, handle: current.handle, displayName: current.displayName, avatar: current.avatar }) +}) + +test('signed-out status and session errors are not cacheable and leak no internals', async () => { + const { GET } = source('app/api/status/route.ts') + current = {} + const anonymous = await GET() + assert.deepEqual(await anonymous.json(), {}) + assert.equal(anonymous.headers.get('cache-control'), 'private, no-store') + current = new Error('MOCK_DO_NOT_EXPOSE') + const failed = await GET() + assert.equal(failed.status, 500) + assert.deepEqual(await failed.json(), {}) + assert.equal(failed.headers.get('cache-control'), 'private, no-store') +}) diff --git a/scripts/lab-ui-core.test.mjs b/scripts/lab-ui-core.test.mjs new file mode 100644 index 00000000..d88d3b3f --- /dev/null +++ b/scripts/lab-ui-core.test.mjs @@ -0,0 +1,210 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { source } from "./velocity/test-source-loader.mjs"; + +test("client adapter fails closed on missing public backend and absent SDK identity; rejects unsafe URLs", async () => { + const { createLabClient, safeUrl } = source("lib/lab-client.ts"); + const offline = createLabClient( + async () => new Response("missing", { status: 404 }), + ); + assert.equal((await offline.capabilities()).canPublish, false); + assert.equal((await offline.feed()).status, "unavailable"); + await assert.rejects( + offline.publish("note", { text: "hi" }), + /Sign in with your Open Lab identity/i, + ); + const malformed = createLabClient(async () => Response.json({ ok: true })); + // HTTP receipts cannot substitute for an injected SDK identity. Exact SDK receipt + // validation is exercised by lab-integration-client and lab-protocol-writes. + await assert.rejects(malformed.publish("note", { text: "hi" }), /Sign in with your Open Lab identity/i); + for (const url of [ + "javascript:alert(1)", + "data:text/html,test", + "http://localhost/a", + "https://x.com@evil.test/", + ]) + assert.equal(safeUrl(url), null); + assert.equal(safeUrl("https://marimo.io/"), "https://marimo.io/"); +}); + +test("public adapter preserves source provenance; notebook requires an explicit DID", async () => { + const { createLabClient } = source("lib/lab-client.ts"); + const post = { uri: "at://did:plc:fixture/app.bsky.feed.post/one", text: "Fixture post", author: { did: "did:plc:fixture", handle: "fixture.test", displayName: { invalid: true } }, url: "https://bsky.app/profile/fixture.test/post/one", createdAt: "2026-09-10T12:00:00Z" }; + const c = createLabClient(async () => Response.json({ items: [post], sourceLabel: "Fixture source", sourceUrl: "https://bsky.app/profile/fixture.test/feed/science", status: "live", fetchedAt: "2026-09-10T12:00:00Z" })); + const feed = await c.feed(); + assert.equal(feed.sourceUrl, "https://bsky.app/profile/fixture.test/feed/science"); + assert.equal(feed.items[0].author.displayName, undefined); + const malformed = createLabClient(async () => Response.json({ profile: { workingOn: "Fixture", interests: {}, lookingFor: "" }, records: [] })); + await assert.rejects(malformed.records(), /explicit Open Lab DID/i); +}); + +test("HTTP client never substitutes for the dedicated SDK identity", async () => { + let calls = 0; + const client = source("lib/lab-client.ts").createLabClient(async () => { + calls++; + return Response.json({ redirectUrl: "https://example.org/oauth" }); + }); + await assert.rejects(client.login("fixture.bsky.social", "/lab/profile/"), /unavailable|integrat/i); + assert.equal(calls, 0); +}); + +test("editorial starter search relates artifacts by topic, never member metrics", () => { + const { artifacts, filterArtifacts, relatedArtifacts } = + source("lib/lab-data.ts"); + assert.ok(artifacts.length >= 6); + assert.equal( + filterArtifacts(artifacts, { + query: "MARIMO", + field: "all", + type: "all", + })[0].id, + "marimo", + ); + assert.equal( + filterArtifacts(artifacts, { + query: "", + field: "neurotech", + type: "all", + }).every((a) => a.field === "neurotech"), + true, + ); + const related = relatedArtifacts("neuromatch"); + assert.ok(related.some((a) => a.field === "neurotech")); + for (const a of artifacts) { + assert.match(a.url, /^https:\/\//); + assert.ok(a.source); + assert.equal(a.followers, undefined); + } +}); + +test("bounded work packet contains source provenance, output schema and no dispatch fiction", () => { + const { buildWorkPacket, packetMarkdown } = source("lib/lab-packets.ts"); + const p = buildWorkPacket("reference-audit", "review", 25); + assert.equal(p.taskId, "flywire-source-audit-v1"); + assert.equal(p.budgetHintMinutes, 25); + assert.ok(p.sourceUrls.length > 0); + assert.ok(p.stopConditions.length > 2); + assert.ok(p.outputSchema); + assert.match(packetMarkdown(p), /Human acceptance/); + assert.throws( + () => buildWorkPacket("reference-audit", "review", -1), + /budget/i, + ); +}); + +test("paired audit packets hold one exact claim/source and parent-compatible independent return templates", () => { + const { buildPairedWorkPackets, packetMarkdown, buildWorkPacket } = source("lib/lab-packets.ts"); + assert.equal(typeof buildPairedWorkPackets, "function"); + const [research, review] = buildPairedWorkPackets(30); + assert.equal(research.taskId, "flywire-source-audit-v1"); + assert.equal(research.claim, review.claim); + assert.equal(research.sourceUrl, "https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain"); + assert.equal(review.sourceUrl, research.sourceUrl); + assert.equal(research.returnTemplate.role, "research"); + assert.equal(review.returnTemplate.role, "review"); + assert.deepEqual(Object.keys(review.returnTemplate).sort(), ["schemaVersion", "taskId", "sourceUrl", "role", "contributor", "runner", "quote", "location", "assessment", "limitation"].sort()); + assert.match(review.instructions, /before reading a research return/); + assert.match(packetMarkdown(review), /Return template/); + assert.match(packetMarkdown(review), /FlyWire/); + assert.throws(() => buildWorkPacket("reference-audit", "reproduce", 30), /research or review/); + assert.throws(() => buildPairedWorkPackets(5.5), /whole/); +}); + +test("synthetic signal is deterministic, sampling changes measurements, reset parameters reproduce output", () => { + const { sampleSignal } = source("lib/lab-signal.ts"); + const a = sampleSignal(5, 40, 0); + const b = sampleSignal(5, 10, 0); + assert.deepEqual(a, sampleSignal(5, 40, 0)); + assert.equal(a.length, 41); + assert.equal(b.length, 11); + assert.ok(Math.abs(a[2].value - 1) < 1e-9); + assert.throws(() => sampleSignal(5, 0, 0), /sample/i); +}); + +test("signal experiment export preserves the exact model, configuration, aliasing lesson, and result", () => { + const { buildSignalExperiment, sampleSignal } = source("lib/lab-signal.ts"); + assert.equal(typeof buildSignalExperiment, "function", "export a reproducible experiment, not just anonymous points"); + const e = buildSignalExperiment(12, 16, 0); + assert.equal(e.version, 1); + assert.equal(e.configuration.frequencyHz, 12); + assert.equal(e.result.foldedFrequencyHz, 4); + assert.equal(e.result.samplingRisk, "aliasing"); + assert.match(e.model.noise, /127\.1/); + assert.deepEqual(e.samples, sampleSignal(12, 16, 0)); + assert.deepEqual(e, JSON.parse(JSON.stringify(buildSignalExperiment(12, 16, 0)))); + assert.ok(e.samples.every(p => Math.abs(p.value + Math.sin(2 * Math.PI * 4 * p.time)) < 1e-12)); + assert.equal(buildSignalExperiment(5, 10, 0).result.samplingRisk, "nyquist-boundary"); + assert.equal(buildSignalExperiment(5, 40, 0).result.samplingRisk, "above-nyquist"); + assert.match(e.limitation, /synthetic/i); +}); + +test("record editor validates source URLs and required content, serializes profile interests", () => { + const { validateEntry, entryPayload } = source("lib/lab-entry.ts"); + assert.ok( + validateEntry("app", { + title: "A", + url: "javascript:1", + description: "Use", + field: "neurotech", + }).url, + ); + assert.ok( + validateEntry("note", { + text: "", + postType: "question", + field: "cross-field", + }).text, + ); + assert.deepEqual( + entryPayload("profile", { + workingOn: "A", + interests: "neural maps, open tools", + lookingFor: "review", + }).interests, + ["neural maps", "open tools"], + ); + assert.equal( + Object.keys( + validateEntry("contribution", { + targetUrl: "https://example.org/", + observation: "A correction", + evidenceUrl: "https://example.org/evidence", + field: "neurotech", + }), + ).length, + 0, + ); +}); + +test("drafts round-trip by kind and owner, reject corruption, and report blocked storage", () => { + const { saveDraft, loadDraft, draftKey } = source("lib/lab-drafts.ts"); + const store = new Map(); + const storage = { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + }; + assert.equal( + saveDraft(storage, "note", "guest", { text: "An unfinished question" }).ok, + true, + ); + assert.deepEqual(loadDraft(storage, "note", "guest").data, { + text: "An unfinished question", + }); + assert.equal(loadDraft(storage, "note", "did:plc:other").data, null); + store.set(draftKey("note", "guest"), "{broken"); + assert.equal(loadDraft(storage, "note", "guest").status, "corrupt"); + assert.equal( + saveDraft( + { + setItem() { + throw Error("quota"); + }, + }, + "note", + "guest", + {}, + ).ok, + false, + ); +}); diff --git a/scripts/lab-ui-interactions.test.mjs b/scripts/lab-ui-interactions.test.mjs new file mode 100644 index 00000000..2f683433 --- /dev/null +++ b/scripts/lab-ui-interactions.test.mjs @@ -0,0 +1,220 @@ +import { test, beforeEach, afterEach, mock } from "node:test"; +import { createRequire } from 'node:module'; +// Composed shell imports scoped styles; Node probes behavior, browser QA owns geometry. +createRequire(import.meta.url).extensions['.css'] = m => { m.exports = {}; }; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { source } from "./velocity/test-source-loader.mjs"; + +// DOM fixtures exercise actual components, not OAuth or public repositories. +const dom = new JSDOM('
', { url: "https://lab.example/lab/" }); +for (const key of ["window", "document", "HTMLElement", "HTMLInputElement", "HTMLTextAreaElement", "Event", "MouseEvent", "KeyboardEvent", "localStorage"]) + globalThis[key] = dom.window[key]; +globalThis.self = dom.window; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +// jsdom does not implement the browser's native dialog layout/focus trap. +// These shims cover lifecycle only; native trapping is a browser QA gate. +window.HTMLDialogElement.prototype.showModal = function () { this.open = true; }; +window.HTMLDialogElement.prototype.close = function () { this.open = false; }; +const { createRoot } = await import("react-dom/client"); +const identityModule = source("lib/lab-identity.ts"); +const services = source("components/lab/LabShell.tsx"); +const downloads = source("lib/lab-packets.ts"); +let root, exports, identity; +beforeEach(() => { + localStorage.clear(); + window.history.replaceState(null, "", "/lab/"); + exports = []; + identity = { session: null, isAuthenticated: false, isLoading: false, error: null, login: async () => {}, logout: async () => {} }; + const sdkSessions = new Map(); + mock.method(identityModule, "useLabIdentity", () => { + const did = identity.session?.did; + if (did && !sdkSessions.has(did)) sdkSessions.set(did, { sub: did, did }); + return { ...identity, oauthSession: did ? sdkSessions.get(did) : null, authorizeWrite: async () => {} }; + }); + mock.method(services, "useLab", () => ({ capabilities: { canSignIn: false, canPublish: false, mode: "unconfigured" }, openLogin() {} })); + mock.method(downloads, "downloadText", (name, text, type) => exports.push({ name, text, type })); + root = createRoot(document.getElementById("root")); +}); +afterEach(async () => { await act(() => root.unmount()); mock.restoreAll(); }); +const mount = async (Component, props = {}) => act(() => root.render(React.createElement(Component, props))); +const button = (text) => [...document.querySelectorAll("button")].find(b => b.textContent.includes(text)); +const click = async (text) => { assert.ok(button(text), `button exists: ${text}`); await act(() => button(text).click()); }; +const change = async (selector, value) => { + const input = document.querySelector(selector); + assert.ok(input, selector); + await act(() => { + const proto = input.tagName === "TEXTAREA" ? window.HTMLTextAreaElement.prototype : input.tagName === "SELECT" ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, "value").set.call(input, value); + input.dispatchEvent(new window.Event(input.tagName === "SELECT" ? "change" : "input", { bubbles: true })); + }); +}; + +test("dialog Tab and Shift-Tab wrap within the modal instead of reaching browser chrome", async () => { + const Dialog = source("components/lab/LabDialog.tsx").default; + await mount(Dialog, { title: "Focus fixture", onClose() {}, children: React.createElement("a", { href: "https://example.org/", id: "last-modal-control" }, "Last control") }); + const first = document.querySelector('dialog button'); + const last = document.getElementById('last-modal-control'); + last.focus(); + await act(() => last.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }))); + assert.ok(document.activeElement === first, "Tab should wrap to the modal's first control"); + await act(() => first.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }))); + assert.ok(document.activeElement === last, "Shift-Tab should wrap to the last control"); +}); + +test("a publication request keeps the reviewed draft immutable until its fixture receipt returns", async () => { + identity = { ...identity, isAuthenticated: true, session: { did: "did:plc:fixture-a", handle: "fixture-a.test" } }; + services.useLab.mock.mockImplementation(() => ({ capabilities: { canPublish: true, canSignIn: true, mode: "ready" }, openLogin() {} })); + let finish; + mock.method(source("lib/lab-client.ts"), "createLabClient", () => ({ publish: () => new Promise(resolve => { finish = resolve; }) })); + await mount(source("components/lab/RecordEditor.tsx").default, { kind: "note", initial: { text: "Reviewed fixture" }, onClose() {} }); + await click("Review draft"); + await act(() => document.querySelector('input[type="checkbox"]').click()); + await click("Publish public record"); + try { assert.equal(document.querySelector('#entry-text').disabled, true, "pending receipt must describe the same draft shown on screen"); } + finally { await act(async () => finish({ uri: "at://did:plc:fixture-a/org.example.note/one", cid: "fixture-cid", record: {} })); } +}); + +test("reviewing a prefilled draft saves it before sign-in and moves focus to the public decision", async () => { + await mount(source("components/lab/RecordEditor.tsx").default, { kind: "note", initial: { text: "Synthetic fixture observation", field: "cross-field", postType: "finding" }, onClose() {} }); + await click("Review draft"); + const draft = source("lib/lab-drafts.ts").loadDraft(localStorage, "note", "guest"); + assert.equal(draft.data?.text, "Synthetic fixture observation"); + assert.ok(document.activeElement.classList.contains("lab-record-preview")); + assert.match(document.querySelector('.lab-record-preview').textContent, /not automatically listed/); +}); + +test("profile links are local links and a failed public read is never an empty-success claim", async () => { + source("lib/lab-drafts.ts").saveDraft(localStorage, "profile", "guest", { workingOn: "Local profile fixture", interests: "open tools, signals", githubUrl: "https://github.com/fixture", scholarUrl: "https://scholar.google.com/citations?user=fixture" }); + const Profile = source("components/lab/ProfileWorkbench.tsx").default; + await mount(Profile); + assert.ok(document.querySelector('a[href="https://github.com/fixture"]')); + assert.ok(document.querySelector('a[href="https://scholar.google.com/citations?user=fixture"]')); + assert.match(document.body.textContent, /Showing your local draft. Not published or synced/); + mock.method(source("lib/lab-client.ts"), "createLabClient", () => ({ records: async () => { throw Error("Fixture read unavailable"); } })); + identity = { ...identity, isAuthenticated: true, session: { did: "did:plc:fixture-a", handle: "fixture-a.test" } }; + await mount(Profile); + assert.match(document.body.textContent, /Fixture read unavailable/); + assert.ok(!document.body.textContent.includes("No public records were returned"), "failure cannot be presented as a successful empty read"); + assert.ok(!document.body.textContent.includes("Local profile fixture")); +}); + +test("feed search, filters and local Promising survive reload; records follow the active DID", async () => { + let recordReads = 0; + mock.method(globalThis, "fetch", async url => { + if (String(url).includes("records")) { + recordReads++; + return Response.json({ profile: null, records: [{ uri: `at://${identity.session.did}/org.example.note/1`, kind: "note", data: { text: `Record from ${identity.session.handle}`, field: "neurotech", postType: "question" } }] }); + } + return Response.json({ status: "empty", items: [], fetchedAt: null, sourceLabel: "Public fixture source" }); + }); + // Inject the account-bound notebook seam for this rendering-only fixture. + // Production FeedWorkbench must pass session.did (parent-owned integration). + const realClient = source("lib/lab-client.ts").createLabClient; + mock.method(source("lib/lab-client.ts"), "createLabClient", (...args) => ({ + ...realClient(...args), + records: async () => (await fetch("fixture:records")).json(), + })); + const Feed = source("components/lab/FeedWorkbench.tsx").default; + await mount(Feed);await click("Public sources"); + await change('[aria-label="Search scientific work"]', "marimo"); + assert.equal(document.querySelectorAll('.lab-feed-entry').length, 1); + assert.equal(new URLSearchParams(window.location.search).get('q'), "marimo"); + await click("Promising"); + await act(() => root.render(null)); + await mount(Feed);await click("Public sources"); + assert.ok(button("Promising · saved")); + await change('[aria-label="Search scientific work"]', ""); + await change('select[aria-label="Source kind"]', "negative-result"); + assert.match(document.querySelector('.lab-empty').textContent, /No editorial starters/); + await change('select[aria-label="Source kind"]', "all"); + identity = { ...identity, isAuthenticated: true, session: { did: "did:plc:fixture-a", handle: "fixture-a.test" } }; + await mount(Feed); + await click("Your records"); + assert.match(document.querySelector('.lab-stream').textContent, /fixture-a.test/); + identity = { ...identity, session: { did: "did:plc:fixture-b", handle: "fixture-b.test" } }; + await mount(Feed); + assert.ok(!document.querySelector('.lab-stream').textContent.includes("fixture-a.test"), "clear prior-account records"); + assert.match(document.querySelector('.lab-stream').textContent, /fixture-b.test/); + assert.equal(recordReads, 2); +}); + +test("recovering a pre-sign-in draft requires a fresh review and public consent", async () => { + const { saveDraft } = source("lib/lab-drafts.ts"); + saveDraft(localStorage, "note", "guest", { text: "Guest draft", field: "neurotech", postType: "question" }); + identity = { ...identity, isAuthenticated: true, session: { did: "did:plc:fixture-a", handle: "fixture-a.test" } }; + services.useLab.mock.mockImplementation(() => ({ capabilities: { canPublish: true, canSignIn: true, mode: "ready" }, openLogin() {} })); + await mount(source("components/lab/RecordEditor.tsx").default, { kind: "note", onClose() {} }); + await change('#entry-text', "Account draft"); + await click("Review draft"); + await act(() => document.querySelector('input[type="checkbox"]').click()); + await click("Recover that draft"); + assert.equal(document.querySelector('#entry-text').value, "Guest draft"); + assert.ok(!button("Publish public record"), "a recovered draft must return to review, not keep publish consent"); + assert.ok(button("Review draft")); +}); + +test("public consent belongs to one exact draft and DID; account changes cannot carry it forward", async () => { + const Editor = source("components/lab/RecordEditor.tsx").default; + identity = { ...identity, isAuthenticated: true, session: { did: "did:plc:fixture-a", handle: "fixture-a.test" } }; + services.useLab.mock.mockImplementation(() => ({ capabilities: { canPublish: true, canSignIn: true, mode: "ready" }, openLogin() {} })); + const props = { kind: "note", onClose() {} }; + await mount(Editor, props); + await change('#entry-text', "Draft belonging to fixture A"); + await click("Review draft"); + assert.equal(button("Publish public record").disabled, true); + await act(() => document.querySelector('input[type="checkbox"]').click()); + assert.equal(button("Publish public record").disabled, false); + identity = { ...identity, session: { did: "did:plc:fixture-b", handle: "fixture-b.test" } }; + await mount(Editor, props); + assert.equal(document.querySelector('#entry-text').value, "", "no cross-account draft carryover"); + assert.equal(button("Publish public record"), undefined, "review/consent must reset"); + assert.match(localStorage.getItem(source("lib/lab-drafts.ts").draftKey("note", "did:plc:fixture-a")), /fixture A/); +}); + +test("evidence drafts stay attached to their own source and remain recoverable from My bench", async () => { + const Editor = source("components/lab/RecordEditor.tsx").default; + await mount(Editor, { kind: "contribution", initial: { targetUrl: "https://example.org/first", field: "neurotech" }, onClose() {} }); + await change('#entry-observation', "Observation about the first source"); + await act(() => root.render(null)); + await mount(Editor, { kind: "contribution", initial: { targetUrl: "https://example.org/second", field: "neurotech" }, onClose() {} }); + assert.equal(document.querySelector('#entry-observation').value, "", "never relabel the old observation as evidence about a new source"); + await change('#entry-observation', "Observation about the second source"); + await act(() => root.render(null)); + await mount(source("components/lab/ProfileWorkbench.tsx").default); + assert.match(document.querySelector('.lab-draft-list').textContent, /first source/); + assert.match(document.querySelector('.lab-draft-list').textContent, /second source/); + await click("Observation about the first source"); + assert.equal(document.querySelector('#entry-targetUrl').value, "https://example.org/first"); + assert.equal(document.querySelector('#entry-observation').value, "Observation about the first source"); +}); + +test("collaboration downloads paired compatible packets and exposes the parent return seam", async () => { + await mount(source("components/lab/CollaborateWorkbench.tsx").default, { evidenceWorkbench: React.createElement("p", null, "Parent evidence return slot") }); + await click("Download researcher packet"); + await click("Download reviewer packet"); + const [a, b] = exports.map(e => JSON.parse(e.text)); + assert.equal(a.taskId, b.taskId); + assert.equal(a.returnTemplate.role, "research"); + assert.equal(b.returnTemplate.role, "review"); + assert.match(document.body.textContent, /Parent evidence return slot/); + await change('input[type="number"]', "0"); + assert.equal(button("Download researcher packet").disabled, true); +}); + +test("sandbox challenge changes the measured signal and downloads a replayable experiment", async () => { + const Sandbox = source("components/lab/SignalSandbox.tsx").default; + await mount(Sandbox); + await click("Try the aliasing challenge"); + assert.equal(document.querySelector('[aria-label="Signal frequency"]').value, "12"); + assert.equal(document.querySelector('[aria-label="Sampling rate"]').value, "16"); + assert.match(document.querySelector('.lab-sandbox-explanation').textContent, /4 Hz/); + await click("Download experiment"); + const exported = JSON.parse(exports[0].text); + assert.deepEqual(exported, source("lib/lab-signal.ts").buildSignalExperiment(12, 16, 0)); + await click("Reset"); + assert.equal(document.querySelector('[aria-label="Signal frequency"]').value, "5"); + assert.ok(button("Draft an observation")); +}); diff --git a/scripts/lab-ui-layout.test.mjs b/scripts/lab-ui-layout.test.mjs new file mode 100644 index 00000000..f56341ea --- /dev/null +++ b/scripts/lab-ui-layout.test.mjs @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +test("new work briefs and dark experiment guidance have scoped readable styles and focus targets", () => { + const css = readFileSync("src/app/lab/lab.css", "utf8"); + assert.match(css, /\.open-lab \.lab-work-brief/); + assert.match(css, /\.open-lab \.lab-artifact-brief summary:focus-visible/); + assert.match(css, /\.open-lab \.lab-sandbox \.lab-smallprint\s*\{[^}]*color:\s*#bed6e8/); + assert.match(css, /\.open-lab \.lab-paired-audit/); +}); + +test("mobile hero has deliberate compact type so the work is not below a giant wall of words", () => { + const css = readFileSync("src/app/lab/lab.css", "utf8"); + assert.match( + css, + /@media\s*\(max-width:\s*700px\)[\s\S]*?\.lab-hero h1\s*\{[^}]*font-size:\s*62px/, + ); + assert.match( + css, + /@media\s*\(max-width:\s*380px\)[\s\S]*?\.lab-hero h1\s*\{[^}]*font-size:\s*52px/, + ); + assert.match( + readFileSync("src/components/lab/LabShell.tsx", "utf8"), + /pl_logo_mark\.svg/, + ); +}); diff --git a/scripts/lab-ui-surfaces.test.mjs b/scripts/lab-ui-surfaces.test.mjs new file mode 100644 index 00000000..7844d190 --- /dev/null +++ b/scripts/lab-ui-surfaces.test.mjs @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import { createRequire } from 'node:module'; +// Composed shell imports scoped styles; Node probes behavior, browser QA owns geometry. +createRequire(import.meta.url).extensions['.css'] = m => { m.exports = {}; }; +import assert from "node:assert/strict"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { source } from "./velocity/test-source-loader.mjs"; +import { readFileSync, existsSync } from "node:fs"; +import { JSDOM } from "jsdom"; +// Next sets this from next.config.ts when rendering the real app. +process.env.__NEXT_TRAILING_SLASH = "true"; + +test("signal SVG is stable across tiny math-library rounding differences during hydration", () => { + const Sandbox = source("components/lab/SignalSandbox.tsx").default; + const html = renderToStaticMarkup(React.createElement(Sandbox)); + const sin = Math.sin; + let other; + try { + Math.sin = v => sin(v) + 1e-15; + other = renderToStaticMarkup(React.createElement(Sandbox)); + } finally { Math.sin = sin; } + const svg = h => new JSDOM(h).window.document.querySelector('svg').outerHTML; + assert.ok(svg(html) === svg(other), "SVG geometry should be display-rounded, not raw engine-dependent floats"); +}); + +test("the first screen offers bounded work, and app discovery previews sources before external launch", () => { + const Provider=source("lib/lab-auth.tsx").LabAuthProvider; + const html = renderToStaticMarkup(React.createElement(Provider,null,React.createElement(source("components/lab/Landing.tsx").default))); + assert.ok(new JSDOM(html).window.document.querySelector('[aria-label="Workshop invitation"] button'), "an actual build composer belongs in the first-use invitation"); + const app = renderToStaticMarkup(React.createElement(Provider,null,React.createElement(source("components/lab/AppsWorkbench.tsx").default))); + for (const label of ["Find tools. Keep building.", "View app", "Editorial", "No embedded runtimes"]) assert.ok(app.includes(label), label); + assert.equal(new JSDOM(app).window.document.querySelector('iframe,canvas,[data-signal-sandbox]'),null); + const map = renderToStaticMarkup(React.createElement(source("components/lab/ResearchMap.tsx").default)); + assert.match(map, /Which measurement bottleneck/); + assert.match(map, /Editorial opening/); +}); + +test("visitors without an account get a separate signup route; Atlas points to the public field article", () => { + const login = readFileSync("src/components/lab/LabShell.tsx", "utf8"); + assert.ok(login.includes('href="https://bsky.app/"'), "provide a signup starting point without discarding local drafts"); + const atlas = renderToStaticMarkup(React.createElement(source("components/lab/AtlasWorkbench.tsx").default)); + assert.ok(atlas.includes('https://www.plneuro.xyz/insights/neurotech-frontier-human-flourishing/')); + assert.match(atlas, /access required/); +}); + +test("Open Lab has one distinct shell with every browse destination and accessible native dialog", () => { + for (const path of [ + "page.tsx", + "feed/page.tsx", + "apps/page.tsx", + "atlas/page.tsx", + "collaborate/page.tsx", + "profile/page.tsx", + ]) + assert.ok(existsSync("src/app/lab/" + path), path); + const shell = readFileSync("src/components/lab/LabShell.tsx", "utf8"); + for (const path of [ + "/lab/", + "/lab/feed/", + "/lab/apps/", + "/lab/atlas/", + "/lab/collaborate/", + "/lab/profile/", + ]) + assert.ok(shell.includes(path)); + assert.match( + readFileSync("src/components/lab/LabDialog.tsx", "utf8"), + /showModal/, + ); + assert.match(readFileSync("src/components/SiteShell.tsx", "utf8"), /isLab/); +}); diff --git a/scripts/lab-work-handoff-ui.test.mjs b/scripts/lab-work-handoff-ui.test.mjs new file mode 100644 index 00000000..baba3b4d --- /dev/null +++ b/scripts/lab-work-handoff-ui.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require=createRequire(import.meta.url) +require.extensions['.css']=m=>{m.exports=new Proxy({}, {get:(_,p)=>p==='__esModule'?false:String(p)})} +const dom=new JSDOM('
',{url:'https://example.org/lab/feed/'}) +for(const k of ['window','document','navigator','HTMLElement','HTMLInputElement','HTMLTextAreaElement','HTMLDialogElement','Event','KeyboardEvent','StorageEvent','localStorage'])Object.defineProperty(globalThis,k,{value:dom.window[k],configurable:true,writable:true}) +HTMLDialogElement.prototype.showModal=function(){this.open=true};HTMLDialogElement.prototype.close=function(){this.open=false} +globalThis.self=window;globalThis.IS_REACT_ACT_ENVIRONMENT=true +const React=await import('react'),{createRoot}=await import('react-dom/client') +let identity={isLoading:false,isAuthenticated:false,session:null} +source('lib/lab-identity.ts').useLabIdentity=()=>identity +const D=source('components/lab/demo/DemoCommunityProvider.tsx'),b=source('lib/lab-inventions.ts'),m=source('lib/lab-feed-model.ts'),d=source('lib/lab-demo.ts') +const rows=()=>m.buildFeedRows({isDemo:true,demo:d.emptyDemoState(),drafts:[]}) +const click=async label=>{const e=[...document.querySelectorAll('button,a,summary')].find(e=>e.getAttribute('aria-label')===label||e.textContent.trim()===label);assert.ok(e,'Missing action '+label);await React.act(async()=>{e.click();await Promise.resolve()});return e} +const fill=async(label,value)=>{const e=document.querySelector(`[aria-label="${label}"]`);assert.ok(e,'Missing input '+label);await React.act(()=>{Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,value);e.dispatchEvent(new Event('input',{bubbles:true}))})} +test('exact feed task is copied for an own agent, exported for GitHub, then returned to the same source; no network',async()=>{ + localStorage.clear();const row=rows()[0],C=source('components/lab/feed/FeedDetail.tsx').default + const root=createRoot(document.getElementById('root'));let copied='',downloaded,blob,network=0 + Object.defineProperty(navigator,'clipboard',{value:{writeText:async t=>{copied=t}},configurable:true}) + const fetch=globalThis.fetch,create=URL.createObjectURL,revoke=URL.revokeObjectURL,anchorClick=window.HTMLAnchorElement.prototype.click + globalThis.fetch=()=>{network++;throw Error('No network from handoff')};URL.createObjectURL=b=>{blob=b;return 'blob:test'};URL.revokeObjectURL=()=>{};window.HTMLAnchorElement.prototype.click=function(){downloaded=this.download} + try{ + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C,{row,onClose:()=>{}})))) + await click('Use my agent');await click('Copy brief');assert.ok(copied.includes(row.ideaId));assert.ok(copied.includes(row.request));assert.match(copied,/Stop condition/) + assert.equal(b.loadBench(localStorage,'guest','demo').state.tasks[0].sourceId,row.ideaId) + await click('Work on GitHub');await fill('GitHub destination URL','https://github.com.evil.test/research');await click('Copy brief');assert.match(document.querySelector('[data-work-handoff]').textContent,/GitHub URL/) + for (const url of ['https:github.com/owner/repo','https:/github.com/owner/repo','https://github.com\\owner/repo']) { + await fill('GitHub destination URL',url) + assert.equal([...document.querySelectorAll('a')].find(a=>a.textContent.includes('Open your selected GitHub')),undefined) + assert.match(document.querySelector('[data-work-handoff] [role="alert"]').textContent,/GitHub URL/) + assert.equal([...document.querySelectorAll('button')].find(b=>b.textContent==='Copy brief').disabled,true) + } + await fill('GitHub destination URL','https://github.com/example/research/issues/42') + assert.equal([...document.querySelectorAll('a')].find(a=>a.textContent.includes('Open your selected GitHub')).href,'https://github.com/example/research/issues/42') + await click('Download brief');assert.match(downloaded,/\.txt$/);assert.match(await blob.text(),/issues\/42/) + await click('Return a result →');await fill('Result note','Counterexample: held-out rows reached preprocessing.');await fill('Result artifact URL','https://example.org/failure');await click('Outcome: did-not-work');await click('Save result to My bench') + assert.match(document.querySelector('[aria-label="Results for this source"]').textContent,/Counterexample/) + assert.equal(b.loadBench(localStorage,'guest','demo').state.tasks.length,1);assert.equal(network,0) + }finally{await React.act(()=>root.unmount());globalThis.fetch=fetch;URL.createObjectURL=create;URL.revokeObjectURL=revoke;window.HTMLAnchorElement.prototype.click=anchorClick} +}) +test('export rereads the bench, keeps saved goals/results and refuses a newly corrupt store',async()=>{ + localStorage.clear();const row=rows().find(r=>r.artifactId==='marimo'),C=source('components/lab/feed/FeedDetail.tsx').default,root=createRoot(document.getElementById('root')) + const task={id:row.ideaId,sourceId:row.ideaId,title:'Original test',request:'Original exact saved goal',artifact:'Original artifact',artifactUrl:'https://example.org/original'} + b.changeBench(localStorage,'guest','demo',{type:'take-task',task});b.changeBench(localStorage,'guest','demo',{type:'return-result',id:task.id,result:{note:'Preserve my failed attempt',artifactUrl:'',outcome:'did-not-work'}}) + let copied=[];Object.defineProperty(navigator,'clipboard',{value:{writeText:async text=>copied.push(text)},configurable:true}) + try{ + await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C,{row,onClose:()=>{}})))) + await click('Copy brief');assert.match(copied[0],/Original exact saved goal/) + assert.match(b.loadBench(localStorage,'guest','demo').state.tasks[0].result.note,/Preserve/) + const key=b.benchKey('guest','demo'),corrupt='{unreadable-original';localStorage.setItem(key,corrupt) + await click('Copy brief');assert.equal(copied.length,1);assert.equal(localStorage.getItem(key),corrupt) + assert.match(document.querySelector('[data-work-handoff] [role="alert"]').textContent,/preserved|could not be read/) + }finally{await React.act(()=>root.unmount())} +}) +test('tool feed details lead with the actual app listing and retain bounded contribution work',async()=>{ + localStorage.clear();const row=rows().find(r=>r.artifactId==='marimo'),C=source('components/lab/feed/FeedDetail.tsx').default,root=createRoot(document.getElementById('root')) + try{await React.act(()=>root.render(React.createElement(D.DemoCommunityProvider,null,React.createElement(C,{row,onClose:()=>{}}))));assert.ok(document.querySelector('[data-app-launch]'));assert.ok(document.querySelector('[data-work-handoff]'));assert.equal(document.querySelector('[data-work-handoff]').closest('details')?.open,false);assert.equal([...document.querySelectorAll('a')].find(a=>a.textContent==='Open source ↗'),undefined);assert.equal(document.querySelector('iframe,canvas'),null)}finally{await React.act(()=>root.unmount())} +}) diff --git a/scripts/lab-work-handoff.test.mjs b/scripts/lab-work-handoff.test.mjs new file mode 100644 index 00000000..c8ca47a6 --- /dev/null +++ b/scripts/lab-work-handoff.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' +const model=source('lib/lab-feed-model.ts'),demo=source('lib/lab-demo.ts') +const rows=()=>model.buildFeedRows({isDemo:true,demo:demo.emptyDemoState(),drafts:[]}) +test('a local idea handoff includes the actual source text, not only the generic next-step recipe',()=>{ + const h=source('lib/lab-work-handoff.ts') + const row=model.buildFeedRows({isDemo:false,demo:demo.emptyDemoState(),drafts:[{slot:'note:fixture',kind:'note',data:{text:'Compare missing-value handling in two synthetic sensor streams.',field:'cross-field'}}]}).find(r=>r.draftSlot) + const text=h.formatWorkHandoff(h.createWorkHandoff({row,destination:'agent',mode:'live'})) + assert.ok(text.includes(row.text),'The exported brief loses the actual local idea') + assert.match(text,/Source context/) + const task={id:row.ideaId,sourceId:row.ideaId,title:row.title,request:'Previously saved goal',artifact:row.artifact,artifactUrl:''} + const revisited=h.formatWorkHandoff(h.createWorkHandoff({row,destination:'github',mode:'live',savedTask:task})) + assert.match(revisited,/Current source context.*not part of the saved task snapshot/) + assert.ok(revisited.includes(task.request));assert.ok(revisited.includes(row.text)) +}) +test('a saved task snapshot is exported unchanged; legacy or mismatched sources fail closed',()=>{ + const h=source('lib/lab-work-handoff.ts'),row=rows()[0] + const savedTask={id:row.ideaId,sourceId:row.ideaId,title:'Original bounded title',request:'Original exact goal',artifact:'Original expected artifact',artifactUrl:'https://example.org/original',result:{note:'Keep this result',artifactUrl:'',outcome:'uncertain'}} + const packet=h.createWorkHandoff({row,destination:'agent',mode:'demo',savedTask}) + assert.equal(packet.goal,savedTask.request);assert.equal(packet.task.title,savedTask.title);assert.equal(packet.task.artifactUrl,savedTask.artifactUrl);assert.equal(packet.task.result,undefined) + assert.ok(packet.sources.some(s=>s.href===savedTask.artifactUrl));assert.ok(!h.formatWorkHandoff(packet).includes('Keep this result')) + for(const task of [{...savedTask,sourceId:undefined},{...savedTask,sourceId:'wrong'},{...savedTask,id:'wrong'}])assert.throws(()=>h.createWorkHandoff({row,destination:'here',mode:'demo',savedTask:task}),/source|task/i) + assert.throws(()=>h.createWorkHandoff({row,destination:'agent',mode:'live'}),/Demo/) +}) +test('handoff carries the exact task and source, required output, limits, and stop condition for each work destination',()=>{ + assert.ok(existsSync('src/lib/lab-work-handoff.ts'),'Source-bound work handoff missing') + const h=source('lib/lab-work-handoff.ts'),row=rows()[0] + for(const destination of ['here','github','agent']){ + const packet=h.createWorkHandoff({row,destination,mode:'demo',githubUrl:destination==='github'?'https://github.com/example/research/issues/42':''}) + assert.equal(packet.task.id,row.ideaId);assert.equal(packet.task.sourceId,row.ideaId);assert.equal(packet.goal,row.request);assert.ok(packet.expectedOutput);assert.ok(packet.stopCondition) + assert.equal(packet.sources[0].href,demo.demoThreadHref(row.threadId));assert.match(packet.disclosure,/illustrative|fictional/i) + const text=h.formatWorkHandoff(packet) + for(const value of [row.ideaId,row.request,row.artifact,'Expected output','Stop condition','No credentials','My bench'])assert.ok(text.includes(value),value) + assert.match(text,/not.*(dispatch|run)|No.*agent.*(run|launch)/i) + } + const editorial=rows().find(r=>r.artifactId==='marimo') + const p=h.createWorkHandoff({row:editorial,destination:'agent',mode:'live'}) + assert.ok(p.sources.some(s=>s.href==='https://marimo.io/'));assert.ok(p.sources.some(s=>s.href==='https://github.com/marimo-team/marimo')) + assert.match(p.disclosure,/editorial/i) + for(const bad of ['https://github.com.evil.test/a','https://user@github.com/a','https://github.com/a\n','javascript:alert(1)'])assert.throws(()=>h.createWorkHandoff({row,destination:'github',mode:'demo',githubUrl:bad}),/GitHub/) +}) diff --git a/scripts/refresh-lab-science-tree.py b/scripts/refresh-lab-science-tree.py new file mode 100644 index 00000000..6211b289 --- /dev/null +++ b/scripts/refresh-lab-science-tree.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Offline, fail-closed OpenAlex Topics CSV -> reviewed static science snapshot. + +Download the official CSV separately; this script never performs network requests. +No runtime service, credentials, dependency installation, or user state is involved. +""" +import argparse +import csv +import hashlib +import io +import json +from datetime import date +from pathlib import Path + +SOURCE = 'https://docs.google.com/spreadsheets/d/1v-MAq64x4YjhO7RWcB-yrKV5D_2vOOsxl4u6GBKEXY8/export?format=csv' +EXPECTED = {'domain': 4, 'field': 26, 'subfield': 252, 'topic': 4516} + + +def make_snapshot(raw, checked_on): + date.fromisoformat(checked_on) + rows = list(csv.DictReader(io.StringIO(raw.decode('utf-8-sig')))) + nodes = {} + for row in rows: + parent = 'science' + for kind in EXPECTED: + source_id = row[f'{kind}_id'] + if not source_id.isascii() or not source_id.isdecimal() or source_id.startswith('0'): + raise ValueError(f'Invalid source {kind} ID: {source_id!r}') + node_id = f'{kind}:{"T" if kind == "topic" else ""}{source_id}' + label = row[f'{kind}_name'] + if not label.strip() or any(ord(c) < 32 for c in label): + raise ValueError(f'Invalid label for {node_id}') + node = {'id': node_id, 'sourceId': source_id, 'label': label, 'kind': kind, 'parent': parent} + if kind == 'topic': + if node_id in nodes: + raise ValueError(f'Duplicate topic: {node_id}') + if not row['summary'].strip(): + raise ValueError(f'Missing description: {node_id}') + node.update(description=row['summary'], keywords=row['keywords']) + if node_id in nodes and nodes[node_id] != node: + raise ValueError(f'Conflicting source hierarchy: {node_id}') + nodes[node_id] = node + parent = node_id + counts = {kind: sum(n['kind'] == kind for n in nodes.values()) for kind in EXPECTED} + if counts != EXPECTED: + raise ValueError(f'Source universe changed or is incomplete: expected {EXPECTED}, got {counts}. Review before updating expected counts.') + return { + 'schema': 'openlab.science-tree.v1', + 'provenance': { + 'publisher': 'OpenAlex / OurResearch', 'sourceUrl': SOURCE, + 'documentationUrl': 'https://help.openalex.org/data/topics/', + 'repositoryUrl': 'https://github.com/ourresearch/openalex-topic-classification', + 'license': 'CC0', 'licenseUrl': 'https://help.openalex.org/access/get-the-data', + 'checkedOn': checked_on, 'sourceSha256': hashlib.sha256(raw).hexdigest(), + 'classification': 'Research-literature containment. Topic names and descriptions are machine-generated from citation clusters; not an exhaustive ontology or a prerequisite map.', + }, + 'counts': counts, + 'nodes': sorted(nodes.values(), key=lambda n: (list(EXPECTED).index(n['kind']), int(n['sourceId']))), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--input', type=Path, required=True) + parser.add_argument('--checked-on', required=True, help='Actual primary-source verification date, YYYY-MM-DD') + parser.add_argument('--output', type=Path, default=Path('src/data/lab-science-tree.json')) + args = parser.parse_args() + snapshot = make_snapshot(args.input.read_bytes(), args.checked_on) + text = json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')) + '\n' + args.output.parent.mkdir(parents=True, exist_ok=True) + temporary = args.output.with_suffix(args.output.suffix + '.tmp') + temporary.write_text(text) + temporary.replace(args.output) + print(json.dumps({'output': str(args.output), 'counts': snapshot['counts'], 'sourceSha256': snapshot['provenance']['sourceSha256'], 'bytes': len(text.encode())})) + + +if __name__ == '__main__': + main() diff --git a/src/app/api/lab/capabilities/route.ts b/src/app/api/lab/capabilities/route.ts new file mode 100644 index 00000000..a6473688 --- /dev/null +++ b/src/app/api/lab/capabilities/route.ts @@ -0,0 +1,6 @@ +import { configForRequest, getLabOAuthConfig } from '@/lib/lab-oauth-config' +export const dynamic = 'force-dynamic' +export function GET(request: Request) { + const config = getLabOAuthConfig() + return Response.json(request ? configForRequest(config, request) : config, { headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }) +} diff --git a/src/app/api/lab/feed/route.ts b/src/app/api/lab/feed/route.ts new file mode 100644 index 00000000..2b5e4ed2 --- /dev/null +++ b/src/app/api/lab/feed/route.ts @@ -0,0 +1,17 @@ +import { loadLabPublicFeed } from '@/lib/lab-public-feed' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** Read-only, public, source-attributed discovery. No user-selected upstream. */ +export async function GET() { + const body = await loadLabPublicFeed() + const unavailable = body.status === 'unavailable' + return Response.json(body, { + status: unavailable ? 503 : 200, + headers: { + 'Cache-Control': unavailable ? 'no-store' : 'public, max-age=30, s-maxage=60', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/src/app/api/lab/oauth/client-metadata.json/route.ts b/src/app/api/lab/oauth/client-metadata.json/route.ts new file mode 100644 index 00000000..f74db78d --- /dev/null +++ b/src/app/api/lab/oauth/client-metadata.json/route.ts @@ -0,0 +1,10 @@ +import { configForRequest, getLabOAuthConfig } from '@/lib/lab-oauth-config' +export const dynamic = 'force-dynamic' +export function GET(request: Request) { + const config = configForRequest(getLabOAuthConfig(), request) + const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Access-Control-Allow-Origin': '*' } + if (!config.metadata) { + return Response.json({ error: 'Open Lab OAuth metadata is not configured for this origin.' }, { status: 404, headers }) + } + return Response.json(config.metadata, { headers }) +} diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 6082196b..90e89b86 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -6,9 +6,14 @@ export const dynamic = 'force-dynamic' export async function GET() { try { const session = await getSession() - return NextResponse.json(session) - } catch (error) { - console.error('Failed to get session:', error) - return NextResponse.json({}, { status: 500 }) + // Public identity DTO only. Session also contains private OAuth material. + const identity = Object.fromEntries( + (['did', 'handle', 'displayName', 'avatar'] as const) + .filter(key => typeof session[key] === 'string') + .map(key => [key, session[key]]) + ) + return NextResponse.json(identity, { headers: { 'Cache-Control': 'private, no-store' } }) + } catch { + return NextResponse.json({}, { status: 500, headers: { 'Cache-Control': 'private, no-store' } }) } } diff --git a/src/app/lab/apps/page.tsx b/src/app/lab/apps/page.tsx new file mode 100644 index 00000000..6a35661c --- /dev/null +++ b/src/app/lab/apps/page.tsx @@ -0,0 +1,11 @@ +import AppsWorkbench from "@/components/lab/AppsWorkbench"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Science apps", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/apps/" }, +}; +export default function Page() { + return ; +} diff --git a/src/app/lab/atlas/page.tsx b/src/app/lab/atlas/page.tsx new file mode 100644 index 00000000..8690b208 --- /dev/null +++ b/src/app/lab/atlas/page.tsx @@ -0,0 +1,12 @@ +import { DemoCommunityPanel } from "@/components/lab/demo"; +import AtlasWorkbench from "@/components/lab/AtlasWorkbench"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Atlas contributions", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/atlas/" }, +}; +export default function Page() { + return <>
; +} diff --git a/src/app/lab/bottlenecks/page.tsx b/src/app/lab/bottlenecks/page.tsx new file mode 100644 index 00000000..4ca39e22 --- /dev/null +++ b/src/app/lab/bottlenecks/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import LabBottleneckExperience from '@/components/lab/LabBottleneckExperience'; +import '@/components/lab/BottleneckWorkbench.css'; + +export const metadata: Metadata = { + title: 'Co-create an intervention | Open Lab', + description: 'Refine a public-source bottleneck, design a bounded intervention, and prepare a local proposal for human review.', +}; +export default function BottlenecksPage() { + return ; +} diff --git a/src/app/lab/collaborate/page.tsx b/src/app/lab/collaborate/page.tsx new file mode 100644 index 00000000..26fd57ec --- /dev/null +++ b/src/app/lab/collaborate/page.tsx @@ -0,0 +1,13 @@ +import { DemoCommunityPanel } from "@/components/lab/demo"; +import CollaborateWorkbench from "@/components/lab/CollaborateWorkbench"; +import LabEvidenceWorkbench from "@/components/lab/LabEvidenceWorkbench"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Collaborate", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/collaborate/" }, +}; +export default function Page() { + return <>
} />; +} diff --git a/src/app/lab/demo/page.tsx b/src/app/lab/demo/page.tsx new file mode 100644 index 00000000..d97da328 --- /dev/null +++ b/src/app/lab/demo/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from 'next'; +import { DemoCommunityProvider, DemoCommunityExperience } from '@/components/lab/demo'; +export const metadata: Metadata = { title: 'Demo community — Open Lab', description: 'A clearly fictional community exploring shared scientific bottlenecks. Try local-only discussions and interest signals.', robots: { index: false, follow: false } }; +export default function DemoCommunityPage() { + return ; +} diff --git a/src/app/lab/efforts/page.tsx b/src/app/lab/efforts/page.tsx new file mode 100644 index 00000000..de38a91d --- /dev/null +++ b/src/app/lab/efforts/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; +import EffortBacking from '@/components/lab/EffortBacking'; +import '@/components/lab/EffortBacking.css'; + +export const metadata: Metadata = { + title: 'Support useful work · Open Lab', + description: 'An optional, local points allocation experiment. Explore editorial work proposals, add local evidence and export your portfolio. No money or global vote.', + alternates: { canonical: '/lab/efforts/' }, +}; + +export default function Page() { + return ; +} diff --git a/src/app/lab/explorations/arcade/page.tsx b/src/app/lab/explorations/arcade/page.tsx new file mode 100644 index 00000000..814ffd84 --- /dev/null +++ b/src/app/lab/explorations/arcade/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' + +export const metadata: Metadata = { + title: 'Science apps — Open Lab', + description: 'Inspect source-linked app listings, then launch tools on their own sites.', + robots: { index: false, follow: false }, +} + +// Keep old entry links working without mounting the retired embedded experiment. +export default function ArcadePage() { redirect('/lab/apps/') } diff --git a/src/app/lab/explorations/observatory/[question]/page.tsx b/src/app/lab/explorations/observatory/[question]/page.tsx new file mode 100644 index 00000000..753eb62b --- /dev/null +++ b/src/app/lab/explorations/observatory/[question]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import Observatory from '@/components/lab/explorations/Observatory' +import { findFrontierQuestion, frontierQuestions } from '@/components/lab/explorations/lab-explorations' + +type Props = { params: Promise<{ question: string }> } +export function generateStaticParams() { return frontierQuestions.map(item => ({ question: item.id })) } +export async function generateMetadata({ params }: Props): Promise { + const question = findFrontierQuestion((await params).question) + return { + title: question ? `${question.shortTitle} — Observatory` : 'Question not found', + description: question?.question, + robots: { index: false, follow: false }, + } +} +export default async function QuestionPage({ params }: Props) { + const question = findFrontierQuestion((await params).question) + if (!question) notFound() + return +} diff --git a/src/app/lab/explorations/observatory/page.tsx b/src/app/lab/explorations/observatory/page.tsx new file mode 100644 index 00000000..06505733 --- /dev/null +++ b/src/app/lab/explorations/observatory/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next' +import Observatory from '@/components/lab/explorations/Observatory' + +export const metadata: Metadata = { + title: 'Explore the tech tree — Open Lab', + description: 'Explore science through the OpenAlex research-literature hierarchy: searchable domains, fields, subfields, and topics, with source attribution and PL R&D context.', + robots: { index: false, follow: false }, +} + +export default function ObservatoryPage() { return } diff --git a/src/app/lab/explorations/page.tsx b/src/app/lab/explorations/page.tsx new file mode 100644 index 00000000..6b890c2c --- /dev/null +++ b/src/app/lab/explorations/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next' +import ExplorationComparison from '@/components/lab/explorations/ExplorationComparison' + +export const metadata: Metadata = { + title: 'Three ways into Open Lab', + description: 'Compare Open Lab’s foundation, a working science-tools entrance, and an editorial frontier map.', + robots: { index: false, follow: false }, +} + +export default function ExplorationsPage() { return } diff --git a/src/app/lab/feed/page.tsx b/src/app/lab/feed/page.tsx new file mode 100644 index 00000000..16b04d1a --- /dev/null +++ b/src/app/lab/feed/page.tsx @@ -0,0 +1,11 @@ +import FeedWorkbench from "@/components/lab/FeedWorkbench"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "The lab", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/feed/" }, +}; +export default function Page() { + return ; +} diff --git a/src/app/lab/lab.css b/src/app/lab/lab.css new file mode 100644 index 00000000..23a84bec --- /dev/null +++ b/src/app/lab/lab.css @@ -0,0 +1,3311 @@ +/* Open Lab is its own surface. Every rule is scoped; the brochure remains unchanged. */ +.open-lab { + --lab-paper: #f8f7f3; + --lab-card: #fdfcf9; + --lab-ink: #17191a; + --lab-muted: #626660; + --lab-line: #d9dcd5; + --lab-blue: #0969ce; + --lab-soft: #eaf1f6; + --lab-dark: #131c21; + --lab-display: var(--font-serif), Georgia, serif; + background: var(--lab-paper); + color: var(--lab-ink); + font-family: Aileron, sans-serif; + font-size: 16px; + line-height: 1.55; + min-height: 100vh; +} +.dark .open-lab { + --lab-paper: #161a1c; + --lab-card: #1d2225; + --lab-ink: #eeeee5; + --lab-muted: #b0b8b5; + --lab-line: #394348; + --lab-blue: #83bdff; + --lab-soft: #25343e; + --lab-dark: #10171b; +} +.open-lab * { + box-sizing: border-box; +} +.open-lab :is(h1, h2, h3, h4, p) { + margin: 0; +} +.open-lab :is(h1, h2, h3) { + font-family: var(--lab-display); + font-weight: 400; + text-wrap: pretty; +} +.open-lab :is(button, input, textarea, select) { + font: inherit; +} +.open-lab button { + cursor: pointer; +} +.open-lab a { + color: inherit; + text-decoration: none; +} +.open-lab :is(a, button, input, textarea, select):focus-visible { + outline: 2px solid var(--lab-blue); + outline-offset: 4px; +} +.open-lab :is(button, a) { + -webkit-tap-highlight-color: transparent; +} +.open-lab button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.open-lab ::selection { + background: #a8cff5; + color: #111; +} +.open-lab .lab-wrap { + max-width: 1480px; + margin-inline: auto; + padding-inline: 48px; +} +.open-lab .lab-main { + min-height: 70vh; +} +.open-lab .lab-eyebrow, +.open-lab .lab-section-label { + font-family: Aileron, sans-serif; + font-size: 10px; + line-height: 1.5; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--lab-muted); +} +.open-lab .lab-eyebrow { + margin-bottom: 20px; +} +.open-lab .lab-smallprint { + font-size: 12px; + line-height: 1.65; + color: var(--lab-muted); +} +.open-lab .lab-smallprint a { + text-decoration: underline; + text-underline-offset: 3px; +} +.open-lab .lab-blue-dot, +.open-lab .lab-status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lab-blue); + flex-shrink: 0; +} +.open-lab .lab-status-dot { + background: #929b93; +} +.open-lab .lab-eyebrow .lab-blue-dot { + margin-right: 8px; +} +.open-lab .lab-button { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 24px; + min-height: 46px; + padding: 11px 22px; + border: 1px solid var(--lab-ink); + background: var(--lab-ink); + color: var(--lab-paper); + font-size: 13px; + font-weight: 600; + border-radius: 4px; + transition: + background 0.18s, + transform 0.18s, + border-color 0.18s; + text-align: center; + line-height: 1.5; +} +.open-lab .lab-button:hover { + transform: translateY(-1px); + background: var(--lab-blue); + border-color: var(--lab-blue); + color: #fff; +} +.open-lab .lab-button.lab-primary { + background: #116fda; + border-color: #116fda; + color: #fff; +} +.open-lab .lab-button.lab-primary:hover { + background: #075cb9; +} +.open-lab .lab-button.lab-quiet { + background: transparent; + color: var(--lab-ink); + border-color: var(--lab-line); +} +.open-lab .lab-button.lab-quiet:hover { + background: var(--lab-soft); + border-color: var(--lab-ink); + color: var(--lab-ink); +} +.open-lab .lab-button.lab-small { + font-size: 12px; + min-height: 42px; + padding: 9px 16px; + gap: 12px; + max-width: 220px; + overflow-wrap: anywhere; +} +.open-lab .lab-text-button { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 13px; + font-weight: 600; + color: var(--lab-ink); + border: 0; + background: transparent; + padding: 8px 0; + text-align: left; + gap: 10px; +} +.open-lab .lab-text-button:hover { + color: var(--lab-blue); +} +.open-lab .lab-icon-button { + border: 0; + background: transparent; + min-width: 44px; + min-height: 44px; + font-size: 24px; + display: inline-grid; + place-items: center; + color: inherit; + border-radius: 4px; +} +.open-lab .lab-icon-button:hover { + background: var(--lab-soft); +} +.open-lab .lab-skip { + position: absolute; + z-index: 100; + left: 16px; + top: -100px; + padding: 12px 20px; + background: var(--lab-ink); + color: var(--lab-paper); +} +.open-lab .lab-skip:focus { + top: 10px; +} +/* Masthead: a place, not an administration console. */ +.open-lab .lab-header { + border-bottom: 1px solid var(--lab-line); + background: var(--lab-paper); +} +.open-lab .lab-header-inner { + max-width: 1480px; + min-height: 91px; + padding: 12px 48px; + margin: auto; + display: flex; + align-items: center; + gap: 44px; +} +.open-lab .lab-brand { + display: flex; + gap: 11px; + align-items: center; + flex-shrink: 0; +} +.open-lab .lab-brand img { + width: 37px; + height: 42px; + object-fit: contain; +} +.open-lab .lab-brand > span { + font-family: var(--lab-display); + font-size: 25px; + line-height: 1; +} +.open-lab .lab-brand small { + display: block; + font-family: Aileron, sans-serif; + font-size: 8px; + letter-spacing: 0.14em; + margin-top: 6px; + color: var(--lab-muted); +} +.open-lab .lab-header nav { + display: flex; + align-items: center; + gap: 26px; + flex: 1; +} +.open-lab .lab-header nav a { + font-size: 12px; + white-space: nowrap; + min-height: 44px; + display: flex; + align-items: center; + position: relative; +} +.open-lab .lab-header nav a[aria-current="page"]::after { + content: ""; + height: 2px; + background: var(--lab-blue); + position: absolute; + bottom: 0; + left: 0; + right: 0; +} +.open-lab .lab-header nav a:hover { + color: var(--lab-blue); +} +.open-lab .lab-header-actions { + display: flex; + align-items: center; + gap: 13px; +} +.open-lab .lab-footer { + max-width: 1480px; + padding: 40px 48px; + margin: auto; + display: grid; + grid-template-columns: 1fr auto; + gap: 22px; + align-items: start; + border-top: 1px solid var(--lab-line); + font-size: 12px; +} +.open-lab .lab-footer-name { + font-family: var(--lab-display); + font-size: 23px; +} +.open-lab .lab-footer-name span { + font-family: Aileron, sans-serif; + font-size: 11px; + display: block; + color: var(--lab-muted); + margin-top: 7px; +} +.open-lab .lab-footer > div { + display: flex; + gap: 30px; +} +.open-lab .lab-footer > div a { + min-height: 44px; + display: flex; + align-items: center; +} +.open-lab .lab-footer > p { + font-size: 10px; + letter-spacing: 0.06em; + color: var(--lab-muted); + grid-column: 1/-1; +} +/* Invitation: asymmetry and an actual object to inspect. */ +.open-lab .lab-hero { + display: grid; + grid-template-columns: 0.95fr 1.05fr; + gap: 54px; + padding-top: 62px; + padding-bottom: 54px; + align-items: center; +} +.open-lab .lab-hero h1 { + font-size: clamp(62px, 6.8vw, 102px); + line-height: 0.98; + letter-spacing: -0.047em; + margin-bottom: 28px; +} +.open-lab em { + font-style: italic; + color: var(--lab-blue); +} +.open-lab .lab-hero-deck { + font-size: 17px; + line-height: 1.65; + max-width: 430px; + color: var(--lab-muted); + margin-bottom: 25px; +} +.open-lab .lab-hero-actions { + display: flex; + gap: 23px; + flex-wrap: wrap; + align-items: center; +} +.open-lab .lab-hero-note { + display: grid; + grid-template-columns: 105px 1fr; + gap: 20px; + border-top: 1px solid var(--lab-line); + padding-top: 23px; + margin-top: 35px; + max-width: 445px; +} +.open-lab .lab-hero-note > span { + font-size: 8px; + line-height: 1.8; + letter-spacing: 0.1em; + color: var(--lab-blue); + font-weight: 600; +} +.open-lab .lab-hero-note p { + font-size: 11px; + color: var(--lab-muted); + line-height: 1.7; +} +.open-lab .lab-hero-map { + min-width: 0; +} +.open-lab .lab-frontier-strip { + border-block: 1px solid var(--lab-line); + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; + padding-block: 20px; +} +.open-lab .lab-frontier-strip > span { + font-size: 8px; + letter-spacing: 0.12em; + font-weight: 600; + color: var(--lab-muted); +} +.open-lab .lab-frontier-strip > a { + font-family: var(--lab-display); + font-size: 19px; + min-height: 44px; + display: flex; + align-items: center; +} +.open-lab .lab-frontier-strip > a:hover { + color: var(--lab-blue); +} +.open-lab .lab-home-work { + display: grid; + grid-template-columns: 0.85fr 1.15fr; + gap: 100px; + padding-block: 88px; +} +.open-lab .lab-home-work-intro h2, +.open-lab .lab-home-tools h2 { + font-size: 49px; + line-height: 1.07; + letter-spacing: -0.03em; + margin-bottom: 24px; +} +.open-lab .lab-home-work-intro > p:not(.lab-eyebrow) { + max-width: 300px; + font-size: 15px; + color: var(--lab-muted); + margin-bottom: 24px; +} +.open-lab .lab-source-notes > .lab-smallprint { + margin-bottom: 18px; + max-width: 420px; +} +.open-lab .lab-source-note { + border-top: 1px solid var(--lab-line); + padding: 28px 0; + display: flex; + gap: 25px; +} +.open-lab .lab-note-number { + font-size: 12px; + color: var(--lab-blue); + padding-top: 3px; +} +.open-lab .lab-source-note .lab-eyebrow { + font-size: 9px; + margin-bottom: 12px; +} +.open-lab .lab-source-note h3 { + font-size: 28px; + line-height: 1.15; + margin-bottom: 12px; +} +.open-lab .lab-source-note p:not(.lab-eyebrow) { + font-size: 14px; + color: var(--lab-muted); + max-width: 440px; +} +.open-lab .lab-source-note .lab-text-button { + margin-top: 10px; +} +.open-lab .lab-home-tools { + display: grid; + grid-template-columns: 0.95fr 1.15fr 0.7fr; + gap: 34px; + padding-block: 65px 88px; + border-top: 1px solid var(--lab-line); + align-items: center; +} +.open-lab .lab-home-tools h2 { + font-size: 43px; +} +.open-lab .lab-home-tools > div > p:not(.lab-eyebrow) { + font-size: 14px; + color: var(--lab-muted); + max-width: 280px; + margin-bottom: 18px; +} +.open-lab .lab-signal-teaser { + padding: 25px 28px; + background: #e6edf0; + min-height: 355px; + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + color: #172c39; + overflow: hidden; +} +.open-lab .lab-signal-teaser .lab-section-label { + color: #405e6e; + font-size: 8px; +} +.open-lab .lab-teaser-wave { + font-family: Georgia, serif; + color: #3e80a3; + font-size: 115px; + letter-spacing: -16px; + line-height: 0.8; + margin: 10px 0 5px; + white-space: nowrap; + user-select: none; +} +.open-lab + .lab-signal-teaser + > div:not(.lab-teaser-wave):not(.lab-section-label) { + display: flex; + justify-content: space-between; + align-items: end; + gap: 15px; +} +.open-lab .lab-signal-teaser h3 { + font-size: 32px; + line-height: 1.1; + letter-spacing: -0.02em; +} +.open-lab .lab-signal-teaser span { + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.open-lab .lab-signal-teaser small { + font-size: 10px; + line-height: 1.5; + color: #44616d; + max-width: 290px; + margin-top: 18px; +} +.open-lab .lab-signal-teaser:hover { + background: #d8e6ee; +} +.open-lab .lab-tool-index { + border-top: 1px solid var(--lab-line); +} +.open-lab .lab-tool-index > a { + position: relative; + display: block; + border-bottom: 1px solid var(--lab-line); + padding: 22px 20px 22px 0; +} +.open-lab .lab-tool-index > a span { + font-family: var(--lab-display); + font-size: 23px; + display: block; +} +.open-lab .lab-tool-index > a small { + display: block; + font-size: 10px; + color: var(--lab-muted); + margin-top: 5px; +} +.open-lab .lab-tool-index > a b { + position: absolute; + right: 0; + top: 27px; + font-weight: 400; +} +.open-lab .lab-agent-invitation { + background: var(--lab-dark); + color: #f1f2eb; + padding-block: 69px; +} +.open-lab .lab-agent-invitation > .lab-wrap { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 90px; + align-items: center; +} +.open-lab .lab-agent-invitation h2 { + font-size: 67px; + line-height: 1.02; + letter-spacing: -0.035em; +} +.open-lab .lab-agent-invitation em { + color: #95bde0; +} +.open-lab .lab-agent-invitation .lab-eyebrow { + color: #9caeb6; +} +.open-lab .lab-agent-invitation p:not(.lab-eyebrow) { + color: #b9c5ca; + font-size: 16px; + line-height: 1.7; + max-width: 400px; + margin-bottom: 25px; +} +.open-lab .lab-button.lab-paper-button { + background: #e9eee8; + color: #18252e; + border-color: #e9eee8; +} +.open-lab .lab-button.lab-paper-button:hover { + background: #fff; +} +.open-lab .lab-agent-invitation small { + display: block; + font-size: 10px; + color: #9caeb6; + margin-top: 20px; +} +/* The constellation is a field-to-source graph, with a fully equivalent list. */ +.open-lab .lab-map { + background: var(--lab-paper); + color: var(--lab-ink); + border-radius: 4px; + overflow: hidden; + max-width: 950px; + margin-inline: auto; + border: 1px solid var(--lab-line); + box-shadow: none; +} +.open-lab .lab-map-heading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 25px 14px; +} +.open-lab .lab-map-kicker { + display: block; + font-size: 12px; + line-height: 1.5; + letter-spacing: 0.12em; + color: var(--lab-muted); + font-weight: 600; + text-transform: uppercase; +} +.open-lab .lab-map-heading p { + font-family: var(--lab-display); + font-size: 30px; + margin-top: 5px; + letter-spacing: -0.02em; +} +.open-lab .lab-map-toggle { + border: 1px solid var(--lab-line); + min-height: 44px; + border-radius: 3px; + padding: 7px 11px; + font-size: 12px; + color: var(--lab-ink); + background: transparent; + white-space: nowrap; +} +.open-lab .lab-map-toggle:hover { + background: var(--lab-soft); +} +.open-lab .lab-map-legend { + padding: 0 25px; + display: flex; + gap: 16px; + align-items: center; + font-size: 12px; + color: var(--lab-muted); + flex-wrap: wrap; +} +.open-lab .lab-map-legend span { + display: flex; + gap: 5px; + align-items: center; +} +.open-lab .lab-map-legend i { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--lab-ink); +} +.open-lab .lab-map-legend i.lab-ring { + background: transparent; + border: 1px solid var(--lab-muted); + width: 5px; + height: 5px; +} +.open-lab .lab-map-svg { + display: block; + width: 100%; + height: auto; + margin-top: 4px; +} +.open-lab .lab-map-svg text { + font: + 12px Aileron, + sans-serif; + fill: var(--lab-ink); +} +.open-lab .lab-map-svg .lab-map-field { + font-size: 12px; + letter-spacing: 1px; + fill: var(--lab-muted); +} +.open-lab .lab-map-node { + cursor: pointer; + outline: none; +} +.open-lab .lab-map-node:focus-visible circle { + stroke: var(--lab-blue); + stroke-width: 3; +} +.open-lab .lab-map-node:hover text, +.open-lab .lab-map-node[aria-pressed="true"] text { + fill: var(--lab-blue); +} +.open-lab .lab-map-inspector { + background: var(--lab-card); + border-top: 1px solid var(--lab-line); + padding: 22px 25px 18px; + display: grid; + grid-template-columns: 1fr auto; + gap: 12px 24px; +} +.open-lab .lab-map-selected h3 { + font-size: 23px; + line-height: 1.1; + letter-spacing: -0.02em; + margin-top: 9px; + max-width: 400px; +} +.open-lab .lab-map-selected p { + font-size: 12px; + line-height: 1.55; + color: var(--lab-muted); + max-width: 450px; + margin-top: 9px; +} +.open-lab .lab-map-inspector-actions { + display: flex; + flex-direction: column; + align-items: start; + justify-content: center; + gap: 4px; +} +.open-lab .lab-map-inspector-actions :is(a, button) { + min-height: 44px; + font-size: 12px; + background: none; + border: 0; + color: var(--lab-ink); + white-space: nowrap; + padding: 5px 0; +} +.open-lab .lab-map-inspector-actions button { + color: var(--lab-blue); +} +.open-lab .lab-map-inspector > small { + grid-column: 1/-1; + font-size: 12px; + color: var(--lab-muted); + letter-spacing: 0.02em; + border-top: 1px solid var(--lab-line); + padding-top: 11px; +} +.open-lab .lab-map-list { + padding: 18px 25px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + min-height: 385px; +} +.open-lab .lab-map-list > button { + display: flex; + flex-direction: column; + align-items: start; + justify-content: center; + text-align: left; + color: var(--lab-ink); + background: var(--lab-card); + border: 1px solid var(--lab-line); + padding: 12px; + min-height: 78px; + font-size: 12px; + border-radius: 2px; +} +.open-lab .lab-map-list > button small { + font-size: 12px; + color: var(--lab-muted); + margin-top: 5px; +} +.open-lab .lab-map-list > button[aria-pressed="true"] { + border-color: var(--lab-blue); + background: var(--lab-soft); +} +.open-lab .lab-map-list > button:hover { + background: var(--lab-soft); +} +/* Shared workbench and forms. */ +.open-lab .lab-workbench { + padding-top: 48px; + padding-bottom: 75px; +} +.open-lab .lab-workbench-heading { + display: flex; + justify-content: space-between; + align-items: end; + gap: 36px; + margin-bottom: 42px; +} +.open-lab .lab-workbench-heading h1, +.open-lab .lab-atlas-intro h1, +.open-lab .lab-collab-intro h1 { + font-size: 66px; + line-height: 1.04; + letter-spacing: -0.04em; +} +.open-lab .lab-workbench-heading p:not(.lab-eyebrow) { + font-size: 15px; + max-width: 540px; + color: var(--lab-muted); + margin-top: 17px; +} +.open-lab .lab-workbench-heading > .lab-button { + flex-shrink: 0; + margin-bottom: 4px; +} +.open-lab .lab-segment { + display: flex; + border: 1px solid var(--lab-line); + padding: 3px; + border-radius: 4px; + gap: 3px; + flex-shrink: 0; + align-self: end; +} +.open-lab .lab-segment button { + min-height: 39px; + border: 0; + background: transparent; + padding: 9px 16px; + font-size: 11px; + white-space: nowrap; + color: var(--lab-muted); + border-radius: 2px; +} +.open-lab .lab-segment button[aria-pressed="true"] { + background: var(--lab-ink); + color: var(--lab-paper); +} +.open-lab .lab-field { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + color: var(--lab-ink); + font-weight: 600; + min-width: 0; + position: relative; +} +.open-lab .lab-field :is(input, textarea, select) { + width: 100%; + border: 1px solid var(--lab-line); + border-radius: 3px; + background: var(--lab-card); + color: var(--lab-ink); + padding: 11px 12px; + min-height: 46px; + font-size: 14px; + font-weight: 400; + line-height: 1.5; + min-width: 0; +} +.open-lab .lab-field textarea { + resize: vertical; +} +.open-lab .lab-field :is(input, textarea):focus { + outline: 2px solid var(--lab-blue); + outline-offset: 1px; +} +.open-lab .lab-field small { + font-size: 11px; + color: var(--lab-muted); + font-weight: 400; + line-height: 1.5; +} +.open-lab .lab-required { + font-size: 9px; + position: absolute; + right: 0; + top: 1px; + color: var(--lab-muted); + font-weight: 400; +} +.open-lab .lab-field [aria-invalid="true"] { + border-color: #bf5142; +} +.open-lab .lab-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px 18px; +} +.open-lab .lab-field-wide { + grid-column: 1/-1; +} +.open-lab .lab-notice { + background: var(--lab-soft); + padding: 15px 18px; + font-size: 12px; + color: var(--lab-ink); + line-height: 1.65; + border-radius: 3px; + margin-block: 18px; +} +.open-lab .lab-error { + color: #b23729 !important; + font-size: 12px; + line-height: 1.6; +} +.dark .open-lab .lab-error { + color: #ffb0a3 !important; +} +.open-lab .lab-checkbox { + display: flex; + align-items: start; + gap: 11px; + font-size: 12px; + cursor: pointer; + line-height: 1.6; + padding-block: 10px; +} +.open-lab .lab-checkbox input { + width: 17px; + height: 17px; + flex-shrink: 0; + margin-top: 2px; + accent-color: var(--lab-blue); +} +.open-lab .lab-form-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid var(--lab-line); + padding-top: 20px; + margin-top: 24px; +} +.open-lab .lab-form-actions > :first-child { + margin-right: auto; +} +.open-lab .lab-search { + display: flex; + gap: 11px; + align-items: center; + border-bottom: 1px solid var(--lab-line); + min-height: 48px; + min-width: 0; +} +.open-lab .lab-search > span { + font-size: 27px; + color: var(--lab-muted); +} +.open-lab .lab-search input { + width: 100%; + border: 0; + background: transparent; + color: var(--lab-ink); + font-size: 13px; + min-height: 46px; + outline-offset: 0; +} +.open-lab .lab-empty { + padding: 40px 18px 40px 0; + border-top: 1px solid var(--lab-line); + margin-top: 20px; +} +.open-lab .lab-empty h2, +.open-lab .lab-empty h3 { + font-size: 29px; + line-height: 1.15; + margin-bottom: 12px; +} +.open-lab .lab-empty p { + font-size: 13px; + color: var(--lab-muted); + max-width: 480px; + margin-bottom: 16px; +} +.open-lab .lab-empty .lab-text-button { + margin-left: 12px; +} +.open-lab .lab-result-count { + font-size: 10px; + color: var(--lab-muted); + margin-top: 12px; +} +.open-lab .lab-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 23px; +} +.open-lab .lab-section-heading h2 { + font-size: 33px; + line-height: 1.1; + letter-spacing: -0.02em; +} +.open-lab .lab-section-heading > .lab-eyebrow { + margin-bottom: 0; + white-space: nowrap; +} +.open-lab .lab-contribute-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; + margin-top: 45px; + border-block: 1px solid var(--lab-line); + padding-block: 23px; +} +.open-lab .lab-contribute-strip p { + font-family: var(--lab-display); + font-size: 26px; +} +.open-lab .lab-inline-status, +.open-lab .lab-status-message { + font-size: 11px; + color: var(--lab-blue); + line-height: 1.6; + padding-block: 12px; +} +.open-lab .lab-receipt { + font-size: 11px; + overflow-wrap: anywhere; + word-break: break-word; + display: block; +} +.open-lab .lab-receipt code { + display: block; + margin-block: 7px; +} +.open-lab .lab-receipt strong { + font-size: 11px; +} +/* Native modal: actual browser focus containment and Escape semantics. */ +.open-lab .lab-dialog { + position: fixed; + inset: 0; + margin: auto; + width: min(520px, calc(100% - 32px)); + max-height: calc(100dvh - 48px); + overflow: auto; + padding: 25px 30px 28px; + border: 1px solid var(--lab-line); + border-radius: 6px; + background: var(--lab-paper); + color: var(--lab-ink); + box-shadow: 0 26px 120px #0005; + overscroll-behavior: contain; +} +.open-lab .lab-dialog::backdrop { + background: #0b1924a6; + backdrop-filter: blur(4px); +} +.open-lab .lab-dialog-wide { + width: min(730px, calc(100% - 32px)); +} +.open-lab .lab-dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.open-lab .lab-dialog-head .lab-eyebrow { + margin: 0; + font-size: 9px; +} +.open-lab .lab-dialog-head button { + margin: -10px -13px 0 0; +} +.open-lab .lab-dialog h2 { + font-size: 36px; + letter-spacing: -0.025em; + line-height: 1.07; + margin-bottom: 20px; + max-width: 530px; +} +.open-lab .lab-dialog-intro { + font-size: 14px; + color: var(--lab-muted); + margin-bottom: 26px; + line-height: 1.65; +} +.open-lab .lab-dialog form > .lab-smallprint { + margin-top: 17px; +} +.open-lab .lab-editor-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: var(--lab-muted); + margin-bottom: 24px; +} +.open-lab .lab-record-preview { + padding: 20px; + background: var(--lab-soft); + margin-top: 24px; + border-radius: 3px; +} +.open-lab .lab-record-preview pre { + font-family: monospace; + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 240px; + overflow: auto; + margin-block: 15px; +} +.open-lab .lab-record-preview .lab-smallprint { + color: var(--lab-ink); +} +/* Stream: narrow functional filters, broad readable work, a quiet margin. */ +.open-lab .lab-feed-grid { + display: grid; + grid-template-columns: 168px minmax(0, 1fr) 226px; + gap: 40px; +} +.open-lab .lab-filter-rail { + padding-top: 14px; + min-width: 0; +} +.open-lab .lab-filter-rail > .lab-section-label { + margin-bottom: 13px; +} +.open-lab .lab-filter-rail > button { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border: 0; + background: none; + min-height: 43px; + padding: 8px 10px; + text-align: left; + font-size: 12px; + border-radius: 3px; + color: var(--lab-muted); +} +.open-lab .lab-filter-rail > button.active { + background: var(--lab-soft); + color: var(--lab-ink); + font-weight: 600; +} +.open-lab .lab-filter-rail > button:hover { + color: var(--lab-blue); +} +.open-lab .lab-filter-rail .lab-field { + margin-top: 26px; +} +.open-lab .lab-field-filter select { + font-size: 11px; + padding: 9px 7px; + min-height: 42px; +} +.open-lab .lab-filter-rail > .lab-smallprint { + font-size: 10px; + margin-top: 20px; +} +.open-lab .lab-stream { + min-width: 0; +} +.open-lab .lab-composer-prompt { + width: 100%; + text-align: left; + border: 1px solid #c5d3d9; + background: #edf2f3; + color: #182730; + border-radius: 4px; + padding: 24px 28px; + display: block; +} +.dark .open-lab .lab-composer-prompt { + background: #22343f; + color: #e2edf1; + border-color: #3c5663; +} +.open-lab .lab-composer-prompt > .lab-section-label { + color: var(--lab-blue); + font-size: 8px; + display: block; + margin-bottom: 18px; +} +.open-lab .lab-composer-prompt strong { + display: block; + font-family: var(--lab-display); + font-size: 37px; + font-weight: 400; + line-height: 1.04; + letter-spacing: -0.025em; + margin-bottom: 17px; +} +.open-lab .lab-composer-prompt > span:not(.lab-section-label) { + display: block; + font-size: 11px; + opacity: 0.75; + line-height: 1.7; +} +.open-lab .lab-composer-prompt b { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid #bacbd0; + margin-top: 22px; + padding-top: 15px; + font-size: 11px; + font-weight: 600; +} +.open-lab .lab-composer-prompt b span { + font-size: 20px; +} +.open-lab .lab-composer-prompt:hover { + border-color: var(--lab-blue); +} +.open-lab .lab-stream-tools { + margin-top: 24px; +} +.open-lab .lab-source-tabs { + display: flex; + gap: 25px; + margin: 14px 0 18px; + border-bottom: 1px solid var(--lab-line); + overflow-x: auto; +} +.open-lab .lab-source-tabs button { + background: none; + border: 0; + border-bottom: 2px solid transparent; + color: var(--lab-muted); + font-size: 11px; + min-height: 42px; + white-space: nowrap; + padding: 8px 0; +} +.open-lab .lab-source-tabs button[aria-pressed="true"] { + border-color: var(--lab-blue); + color: var(--lab-ink); +} +.open-lab .lab-stream > .lab-smallprint { + font-size: 10px; + max-width: 520px; +} +.open-lab .lab-feed-entry { + padding: 30px 0 23px; + border-bottom: 1px solid var(--lab-line); +} +.open-lab .lab-entry-meta { + display: flex; + gap: 14px; + font-size: 9px; + color: var(--lab-muted); + align-items: center; + letter-spacing: 0.025em; + margin-bottom: 15px; +} +.open-lab .lab-entry-meta > span:first-child { + border: 1px solid var(--lab-line); + padding: 3px 7px; + border-radius: 3px; + color: var(--lab-ink); +} +.open-lab .lab-feed-entry h2 { + font-size: 31px; + line-height: 1.12; + letter-spacing: -0.02em; + margin-bottom: 14px; +} +.open-lab .lab-feed-entry h2 a span { + font-family: Aileron, sans-serif; + color: var(--lab-muted); + font-size: 18px; + white-space: nowrap; +} +.open-lab .lab-feed-entry > p { + font-size: 14px; + color: var(--lab-muted); + line-height: 1.75; +} +.open-lab .lab-feed-entry > p.lab-entry-question { + font-family: var(--lab-display); + font-size: 20px; + color: var(--lab-ink); + line-height: 1.4; + margin-top: 17px; +} +.open-lab .lab-entry-source { + font-size: 9px; + color: var(--lab-muted); + margin-top: 19px; +} +.open-lab .lab-entry-actions { + display: flex; + gap: 25px; + margin-top: 12px; +} +.open-lab .lab-entry-actions :is(button, a) { + border: 0; + background: none; + color: var(--lab-muted); + font-size: 10px; + min-height: 38px; + padding: 5px 0; +} +.open-lab .lab-entry-actions button[aria-pressed="true"] { + color: var(--lab-blue); +} +.open-lab .lab-entry-actions :is(button, a):hover { + color: var(--lab-blue); +} +.open-lab .lab-margin-note { + padding: 0 0 0 20px; + border-left: 1px solid var(--lab-line); + align-self: start; + min-width: 0; +} +.open-lab .lab-margin-symbol { + font-family: var(--lab-display); + font-size: 64px; + font-weight: 400; + line-height: 1.3; + color: var(--lab-blue); + display: block; + margin-bottom: 20px; +} +.open-lab .lab-margin-note .lab-eyebrow { + font-size: 8px; + margin-bottom: 15px; +} +.open-lab .lab-margin-note h3 { + font-size: 26px; + line-height: 1.1; + margin-bottom: 22px; +} +.open-lab .lab-margin-note > p:not(.lab-eyebrow) { + font-size: 12px; + color: var(--lab-muted); + line-height: 1.7; + margin-bottom: 19px; +} +.open-lab .lab-margin-note > a { + display: inline-flex; + align-items: center; + font-size: 11px; + min-height: 44px; + font-weight: 600; +} +.open-lab .lab-notebook-rule { + height: 24px; + border-block: 1px solid var(--lab-line); + margin-block: 25px; +} +.open-lab .lab-margin-note > p.lab-smallprint { + font-size: 10px; +} +.open-lab .lab-live-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + margin-top: 15px; +} +.open-lab .lab-live-status button { + margin-left: auto; + font-size: 10px; +} +.open-lab .lab-live-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--lab-ink) !important; +} +/* A genuinely runnable signal instrument. */ +.open-lab .lab-sandbox { + scroll-margin-top: 20px; + background: var(--lab-dark); + color: #e5eceb; + padding: 30px 34px; + border-radius: 4px; +} +.open-lab .lab-sandbox-title { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: start; +} +.open-lab .lab-sandbox .lab-eyebrow { + color: #a9bac5; + font-size: 9px; + margin-bottom: 14px; +} +.open-lab .lab-sandbox-title h2 { + font-size: 44px; + letter-spacing: -0.03em; + line-height: 1.1; +} +.open-lab .lab-sandbox-title p:not(.lab-eyebrow) { + font-size: 12px; + color: #aabac1; + margin-top: 10px; +} +.open-lab .lab-sandbox-badge { + font-size: 8px; + line-height: 1.8; + letter-spacing: 0.1em; + color: #adc0ca; + border: 1px solid #455961; + padding: 10px 14px; + text-align: right; +} +.open-lab .lab-sandbox-plot { + max-height: 300px; + min-height: 150px; + margin: 15px auto; + width: 100%; +} +.open-lab .lab-sandbox-plot svg { + width: 100%; + height: 300px; + display: block; +} +.open-lab .lab-sandbox-plot text { + font: + 9px Aileron, + sans-serif; + fill: #9dafb9; + letter-spacing: 1px; +} +.open-lab .lab-sandbox-bottom { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 55px; + border-top: 1px solid #3a4e58; + padding-top: 22px; +} +.open-lab .lab-sandbox-controls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 24px; +} +.open-lab .lab-sandbox-controls label { + font-size: 10px; + color: #aabbc4; + display: block; +} +.open-lab .lab-sandbox-controls output { + display: block; + color: #e1e9ed; + font-size: 26px; + font-family: var(--lab-display); + margin-top: 4px; +} +.open-lab .lab-sandbox-controls input[type="range"] { + width: 100%; + min-height: 44px; + accent-color: #93c7ee; + cursor: pointer; + margin-top: 8px; +} +.open-lab .lab-sandbox-explanation > span { + font-size: 8px; + letter-spacing: 0.1em; + color: #a8ccef; +} +.open-lab .lab-sandbox-explanation p { + font-size: 12px; + line-height: 1.7; + color: #d0d9dd; + margin-top: 7px; +} +.open-lab .lab-sandbox-explanation small { + display: block; + font-size: 9px; + color: #a0b2be; + line-height: 1.6; + margin-top: 8px; +} +.open-lab .lab-sandbox-actions { + display: flex; + align-items: center; + gap: 24px; + border-top: 1px solid #3a4e58; + margin-top: 24px; + padding-top: 12px; +} +.open-lab .lab-sandbox-actions .lab-checkbox { + font-size: 10px; + margin-right: auto; +} +.open-lab .lab-sandbox-actions button { + background: none; + border: 0; + font-size: 10px; + color: #c5d9e6; + min-height: 44px; +} +.open-lab .lab-sandbox > .lab-smallprint { + color: #bed6e8; + margin-top: 10px; +} +.open-lab .lab-app-catalog { + padding-top: 60px; +} +.open-lab .lab-app-catalog .lab-section-heading .lab-smallprint { + max-width: 320px; + font-size: 10px; +} +.open-lab .lab-app-catalog .lab-eyebrow { + margin-bottom: 13px; +} +.open-lab .lab-catalog-controls { + display: grid; + grid-template-columns: 1fr 220px auto; + align-items: end; + gap: 30px; + margin-bottom: 26px; +} +.open-lab .lab-catalog-controls .lab-result-count { + align-self: center; + font-size: 12px; + white-space: nowrap; +} +.open-lab .lab-app-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px; +} +.open-lab .lab-app-card { + padding: 27px; + background: var(--lab-card); + border: 1px solid var(--lab-line); + border-radius: 4px; + display: flex; + flex-direction: column; +} +.open-lab .lab-app-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + color: var(--lab-muted); + font-size: 9px; + margin-bottom: 25px; +} +.open-lab .lab-app-number { + font-size: 13px; + color: var(--lab-blue); + border: 1px solid var(--lab-line); + padding: 5px 8px; + font-family: var(--lab-display); +} +.open-lab .lab-app-card h3 { + font-size: 33px; + line-height: 1.05; + letter-spacing: -0.02em; + margin-bottom: 14px; +} +.open-lab .lab-app-card > p { + font-size: 13px; + line-height: 1.7; + color: var(--lab-muted); + max-width: 440px; + flex: 1; +} +.open-lab .lab-app-status { + display: flex; + gap: 17px; + font-size: 9px; + color: var(--lab-muted); + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--lab-line); +} +.open-lab .lab-app-links { + display: flex; + align-items: center; + gap: 20px; + margin-block: 14px; +} +.open-lab .lab-app-links > a:not(.lab-button) { + font-size: 10px; + min-height: 44px; + display: flex; + align-items: center; +} +.open-lab .lab-app-links > .lab-button { + font-size: 11px; + min-height: 42px; + padding: 8px 14px; + margin-right: auto; + gap: 12px; +} +.open-lab .lab-app-card > small { + font-size: 9px; + color: var(--lab-muted); +} +/* Atlas: evidence chains, not speculative metrics. */ +.open-lab .lab-atlas-intro { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 100px; + align-items: center; + padding: 15px 0 35px; +} +.open-lab .lab-atlas-intro h1 { + font-size: 79px; + margin-bottom: 25px; +} +.open-lab .lab-atlas-intro > div > p:not(.lab-eyebrow) { + font-size: 15px; + color: var(--lab-muted); + max-width: 460px; + margin-bottom: 25px; + line-height: 1.75; +} +.open-lab .lab-atlas-specimen { + border: 1px solid var(--lab-line); + padding: 26px 36px; + position: relative; + background: var(--lab-card); +} +.open-lab .lab-atlas-specimen:after { + content: ""; + position: absolute; + inset: 6px -6px -6px 6px; + z-index: -1; + border: 1px solid var(--lab-line); +} +.open-lab .lab-atlas-specimen > .lab-section-label { + font-size: 8px; +} +.open-lab .lab-evidence-chain { + padding-top: 22px; +} +.open-lab .lab-evidence-chain > div { + border: 1px solid var(--lab-line); + padding: 14px 20px; +} +.open-lab .lab-evidence-chain small { + font-size: 8px; + letter-spacing: 0.12em; + color: var(--lab-blue); +} +.open-lab .lab-evidence-chain p { + font-family: var(--lab-display); + font-size: 31px; + line-height: 1.3; + padding-top: 4px; +} +.open-lab .lab-evidence-chain > span { + display: block; + text-align: center; + color: var(--lab-blue); + font-size: 21px; + height: 32px; +} +.open-lab .lab-atlas-specimen > p { + font-size: 11px !important; + margin: 20px 0 0 !important; + max-width: none !important; + color: var(--lab-muted); +} +.open-lab .lab-atlas-access { + display: flex; + align-items: center; + gap: 13px; + padding: 18px 20px; + background: var(--lab-soft); + margin: 12px 0 50px; + font-size: 11px; + border-radius: 3px; +} +.open-lab .lab-atlas-access > p { + flex: 1; + line-height: 1.7; +} +.open-lab .lab-atlas-access > a { + font-size: 10px; + white-space: nowrap; + min-height: 44px; + display: flex; + align-items: center; +} +.open-lab .lab-atlas-entries > article { + display: grid; + grid-template-columns: 70px 1fr 0.72fr; + gap: 32px; + border-top: 1px solid var(--lab-line); + padding-block: 35px; +} +.open-lab .lab-atlas-index { + font-family: var(--lab-display); + font-size: 45px; + color: var(--lab-blue); + line-height: 1; +} +.open-lab .lab-atlas-entries article .lab-eyebrow { + font-size: 8px; + margin-bottom: 13px; +} +.open-lab .lab-atlas-entries article h3 { + font-size: 35px; + line-height: 1.1; + letter-spacing: -0.02em; + margin-bottom: 14px; +} +.open-lab .lab-atlas-entries article p:not(.lab-eyebrow) { + font-size: 13px; + color: var(--lab-muted); + line-height: 1.7; + max-width: 500px; +} +.open-lab .lab-atlas-entries article .lab-text-button { + font-size: 11px; + margin-top: 9px; +} +.open-lab .lab-atlas-next { + align-self: center; + border-left: 1px solid var(--lab-line); + padding-left: 30px; +} +.open-lab .lab-atlas-next p { + font-family: var(--lab-display); + font-size: 24px !important; + line-height: 1.25 !important; + color: var(--lab-ink) !important; +} +.open-lab .lab-atlas-next .lab-button { + margin-top: 20px; + font-size: 11px; +} +.open-lab .lab-atlas-protocol { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 90px; + background: var(--lab-soft); + padding: 45px; + margin-top: 30px; +} +.open-lab .lab-atlas-protocol h2 { + font-size: 46px; + line-height: 1.05; + letter-spacing: -0.025em; +} +.open-lab .lab-atlas-protocol ol { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 23px; +} +.open-lab .lab-atlas-protocol li { + display: flex; + gap: 16px; +} +.open-lab .lab-atlas-protocol li > span { + font-size: 11px; + color: var(--lab-blue); + border: 1px solid var(--lab-line); + border-radius: 50%; + width: 23px; + height: 23px; + display: grid; + place-items: center; + flex-shrink: 0; +} +.open-lab .lab-atlas-protocol h3 { + font-size: 22px; + line-height: 1.1; + margin-bottom: 7px; +} +.open-lab .lab-atlas-protocol li p { + font-size: 11px; + line-height: 1.7; + color: var(--lab-muted); +} +/* A visible, bounded packet. No faux dispatch controls. */ +.open-lab .lab-collab-intro { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 60px; + align-items: end; + margin-bottom: 50px; + padding-top: 15px; +} +.open-lab .lab-collab-intro h1 { + font-size: 74px; +} +.open-lab .lab-collab-intro > div > p:not(.lab-eyebrow) { + font-size: 14px; + color: var(--lab-muted); + line-height: 1.75; + margin-bottom: 18px; +} +.open-lab .lab-pilot-label { + font-size: 8px; + letter-spacing: 0.1em; + line-height: 1.8; + color: var(--lab-blue); +} +.open-lab .lab-packet-workbench { + display: grid; + grid-template-columns: 0.9fr 1.1fr; + gap: 40px; +} +.open-lab .lab-step { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; +} +.open-lab .lab-step > span { + font-size: 12px; + color: var(--lab-blue); +} +.open-lab .lab-step h2 { + font-size: 29px; + line-height: 1.1; +} +.open-lab .lab-campaign-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 32px; +} +.open-lab .lab-campaign-options > button { + text-align: left; + border: 1px solid var(--lab-line); + background: var(--lab-card); + padding: 23px; + color: var(--lab-ink); + border-radius: 4px; +} +.open-lab .lab-campaign-options > button[aria-pressed="true"] { + border-color: var(--lab-blue); + background: var(--lab-soft); +} +.open-lab .lab-campaign-options small { + font-size: 8px; + letter-spacing: 0.12em; + color: var(--lab-muted); +} +.open-lab .lab-campaign-options strong { + font-family: var(--lab-display); + font-size: 26px; + font-weight: 400; + line-height: 1.15; + display: block; + margin: 9px 0 13px; +} +.open-lab .lab-campaign-options p { + font-size: 12px; + line-height: 1.65; + color: var(--lab-muted); +} +.open-lab .lab-campaign-options > button > span { + display: block; + font-size: 10px; + color: var(--lab-blue); + margin-top: 15px; +} +.open-lab .lab-packet-setup .lab-form-grid { + grid-template-columns: 1fr; +} +.open-lab .lab-packet-boundary { + margin-top: 28px; + border-top: 1px solid var(--lab-line); + padding-top: 23px; +} +.open-lab .lab-packet-boundary h3 { + font-size: 25px; + margin-bottom: 12px; +} +.open-lab .lab-packet-boundary p { + font-size: 11px; + color: var(--lab-muted); + line-height: 1.7; + margin-bottom: 10px; +} +.open-lab .lab-packet-preview { + background: var(--lab-dark); + color: #dee8ec; + border: 1px solid #3b4b55; + padding: 25px 28px; + border-radius: 4px; + min-width: 0; + align-self: start; +} +.open-lab .lab-packet-top { + display: flex; + justify-content: space-between; + gap: 15px; + align-items: start; +} +.open-lab .lab-packet-top .lab-eyebrow { + color: #9aafba; + font-size: 8px; + margin-bottom: 10px; +} +.open-lab .lab-packet-top h2 { + font-size: 27px; + line-height: 1.1; +} +.open-lab .lab-packet-top .lab-segment { + border-color: #425a65; + align-self: start; +} +.open-lab .lab-packet-top .lab-segment button { + font-size: 9px; + padding: 8px; + min-height: 33px; + color: #b6c8d3; +} +.open-lab .lab-packet-top .lab-segment button[aria-pressed="true"] { + background: #d9e7ee; + color: #152a37; +} +.open-lab .lab-packet-id { + font-family: monospace; + font-size: 10px; + color: #94bdd7; + margin: 25px 0 15px; + padding-bottom: 15px; + border-bottom: 1px solid #3e515c; + overflow-wrap: anywhere; +} +.open-lab .lab-packet-id > span { + display: block; + font-family: Aileron, sans-serif; + font-size: 8px; + letter-spacing: 0.1em; + color: #93a9b5; + margin-top: 8px; +} +.open-lab .lab-packet-preview pre { + font-family: monospace; + font-size: 10px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; + max-height: 535px; + overflow-y: auto; + scrollbar-color: #5b6f7a transparent; + padding-right: 8px; +} +.open-lab .lab-packet-download { + display: flex; + align-items: center; + gap: 25px; + border-top: 1px solid #40565f; + margin-top: 20px; + padding-top: 20px; +} +.open-lab .lab-packet-download .lab-text-button { + color: #b6cee1; + font-size: 11px; +} +.open-lab .lab-packet-download .lab-button { + font-size: 11px; +} +.open-lab .lab-packet-message { + font-size: 11px; + line-height: 1.7; + color: #b6cee1; + margin-top: 15px; +} +.open-lab .lab-collab-return { + display: grid; + grid-template-columns: 70px 1fr auto; + gap: 30px; + align-items: center; + margin-top: 60px; + border-top: 1px solid var(--lab-line); + padding-top: 36px; +} +.open-lab .lab-collab-return .lab-eyebrow { + margin-bottom: 12px; +} +.open-lab .lab-collab-return h2 { + font-size: 35px; + line-height: 1.1; + margin-bottom: 15px; +} +.open-lab .lab-collab-return p:not(.lab-eyebrow) { + font-size: 13px; + color: var(--lab-muted); + max-width: 520px; + line-height: 1.7; +} +.open-lab .lab-collab-return .lab-button { + font-size: 11px; +} +/* Personal bench: deliberately empty until the person puts work here. */ +.open-lab .lab-profile-grid { + display: grid; + grid-template-columns: 0.85fr 1.4fr; + gap: 60px; +} +.open-lab .lab-profile-card { + background: var(--lab-card); + border: 1px solid var(--lab-line); + padding: 30px; + align-self: start; + border-radius: 4px; + min-width: 0; +} +.open-lab .lab-profile-monogram { + width: 62px; + height: 62px; + background: var(--lab-soft); + color: var(--lab-blue); + display: grid; + place-items: center; + font-family: var(--lab-display); + font-size: 40px; + margin-bottom: 24px; + border-radius: 3px; +} +.open-lab .lab-profile-card > .lab-eyebrow { + font-size: 8px; +} +.open-lab .lab-profile-card h2 { + font-size: 34px; + line-height: 1.08; + margin: 12px 0 15px; + overflow-wrap: anywhere; +} +.open-lab .lab-profile-card > p { + font-size: 12px; + color: var(--lab-muted); + overflow-wrap: anywhere; +} +.open-lab .lab-profile-description { + border-top: 1px solid var(--lab-line); + margin-block: 24px; + padding-top: 23px; +} +.open-lab .lab-profile-description h3 { + font-family: Aileron, sans-serif; + font-size: 10px; + color: var(--lab-muted); + text-transform: uppercase; + letter-spacing: 0.09em; + margin-bottom: 10px; +} +.open-lab .lab-profile-description p { + font-size: 13px; + line-height: 1.7; + margin-bottom: 20px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.open-lab .lab-profile-description > a { + margin-right: 16px; + font-size: 11px; +} +.open-lab .lab-profile-card > .lab-smallprint { + font-size: 10px; + margin-top: 16px; +} +.open-lab .lab-profile-interests { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 15px; +} +.open-lab .lab-profile-interests span { + border: 1px solid var(--lab-line); + padding: 4px 9px; + font-size: 10px; + border-radius: 3px; + overflow-wrap: anywhere; +} +.open-lab .lab-identity { + border: 1px solid var(--lab-line); + padding: 10px; + margin-top: 17px; + overflow-wrap: anywhere; +} +.open-lab .lab-identity small { + font-size: 9px; + color: var(--lab-muted); + display: block; + margin-bottom: 7px; +} +.open-lab .lab-identity code { + font-size: 10px; + line-height: 1.7; +} +.open-lab .lab-bench-content { + min-width: 0; +} +.open-lab .lab-bench-content > section { + margin-bottom: 45px; +} +.open-lab .lab-bench-content .lab-section-heading { + margin-bottom: 17px; +} +.open-lab .lab-bench-content .lab-section-heading h2 { + font-size: 30px; +} +.open-lab .lab-bench-content .lab-section-heading > .lab-eyebrow { + font-size: 8px; +} +.open-lab .lab-draft-list { + margin-top: 24px; +} +.open-lab .lab-draft-list > button { + width: 100%; + text-align: left; + background: none; + border: 0; + border-block-start: 1px solid var(--lab-line); + padding: 22px 0; + color: var(--lab-ink); +} +.open-lab .lab-draft-list > button:hover strong { + color: var(--lab-blue); +} +.open-lab .lab-draft-list .lab-eyebrow { + font-size: 8px; + display: block; + margin-bottom: 9px; +} +.open-lab .lab-draft-list strong { + font-family: var(--lab-display); + font-weight: 400; + font-size: 25px; + line-height: 1.15; + display: block; + margin-bottom: 13px; + overflow-wrap: anywhere; +} +.open-lab .lab-draft-list small { + font-size: 10px; + color: var(--lab-muted); + display: flex; + justify-content: space-between; + gap: 15px; +} +.open-lab .lab-draft-list small > span { + color: var(--lab-ink); +} +.open-lab .lab-saved-list > a { + display: flex; + flex-direction: column; + gap: 7px; + border-top: 1px solid var(--lab-line); + padding-block: 16px; +} +.open-lab .lab-saved-list > a > span { + font-family: var(--lab-display); + font-size: 24px; + line-height: 1.2; +} +.open-lab .lab-saved-list small { + font-size: 10px; + color: var(--lab-muted); +} +.open-lab .lab-public-record { + border-top: 1px solid var(--lab-line); + padding-block: 16px; + font-size: 12px; +} +.open-lab .lab-public-record code { + font-size: 10px; + overflow-wrap: anywhere; + display: block; + margin-top: 7px; +} +/* Responsive composition, not shrink-to-fit desktop. */ +@media (min-width: 1600px) { + .open-lab .lab-hero { + gap: 72px; + } + .open-lab .lab-hero h1 { + font-size: 104px; + } + .open-lab .lab-map-heading { + padding-top: 29px; + } + .open-lab .lab-hero-note { + margin-top: 46px; + } +} +@media (max-width: 1180px) { + .open-lab .lab-wrap { + padding-inline: 30px; + } + .open-lab .lab-header-inner { + padding-inline: 30px; + gap: 26px; + } + .open-lab .lab-header nav { + gap: 17px; + } + .open-lab .lab-header-actions { + gap: 3px; + } + .open-lab .lab-hero { + gap: 35px; + } + .open-lab .lab-hero h1 { + font-size: 76px; + } + .open-lab .lab-hero-note { + grid-template-columns: 87px 1fr; + gap: 15px; + } + .open-lab .lab-hero-deck { + font-size: 15px; + } + .open-lab .lab-map-legend { + gap: 10px; + } + .open-lab .lab-map-legend span:last-child { + display: none; + } + .open-lab .lab-map-inspector { + grid-template-columns: 1fr; + gap: 10px; + } + .open-lab .lab-map-inspector-actions { + flex-direction: row; + gap: 25px; + } + .open-lab .lab-map-inspector > small { + font-size: 8px; + } + .open-lab .lab-map-selected p { + font-size: 10px; + } + .open-lab .lab-home-work { + gap: 60px; + } + .open-lab .lab-home-tools { + grid-template-columns: 0.9fr 1.1fr; + } + .open-lab .lab-tool-index { + display: none; + } + .open-lab .lab-frontier-strip { + gap: 17px; + } + .open-lab .lab-frontier-strip > span { + max-width: 120px; + font-size: 7px; + } + .open-lab .lab-frontier-strip > a { + font-size: 17px; + } + .open-lab .lab-feed-grid { + grid-template-columns: 145px minmax(0, 1fr) 195px; + gap: 26px; + } + .open-lab .lab-margin-note { + padding-left: 17px; + } + .open-lab .lab-feed-entry h2 { + font-size: 28px; + } + .open-lab .lab-atlas-intro { + gap: 60px; + } + .open-lab .lab-atlas-intro h1 { + font-size: 68px; + } + .open-lab .lab-collab-intro h1 { + font-size: 62px; + } + .open-lab .lab-packet-workbench { + gap: 28px; + grid-template-columns: 1fr 1fr; + } + .open-lab .lab-packet-top { + flex-direction: column; + } + .open-lab .lab-packet-top .lab-segment { + align-self: start; + } + .open-lab .lab-profile-grid { + gap: 35px; + } + .open-lab .lab-bench-content .lab-section-heading { + align-items: start; + flex-direction: column; + gap: 10px; + } + .open-lab .lab-footer { + padding-inline: 30px; + } +} +@media (max-width: 950px) { + .open-lab .lab-header-inner { + flex-wrap: wrap; + gap: 8px 20px; + padding-top: 18px; + padding-bottom: 0; + justify-content: space-between; + } + .open-lab .lab-header nav { + order: 3; + flex: 1 0 100%; + justify-content: space-between; + margin-top: 3px; + gap: 10px; + } + .open-lab .lab-header nav a { + min-height: 48px; + } + .open-lab .lab-hero { + gap: 30px; + grid-template-columns: 1fr 1fr; + padding-top: 43px; + } + .open-lab .lab-hero h1 { + font-size: 65px; + } + .open-lab .lab-hero-note { + display: block; + margin-top: 27px; + } + .open-lab .lab-hero-note > span { + display: none; + } + .open-lab .lab-hero-note p { + font-size: 11px; + max-width: 270px; + } + .open-lab .lab-hero-actions { + gap: 10px; + } + .open-lab .lab-hero-actions .lab-text-button { + font-size: 11px; + } + .open-lab .lab-map-heading { + padding: 18px 18px 13px; + } + .open-lab .lab-map-heading p { + font-size: 25px; + } + .open-lab .lab-map-kicker { + font-size: 7px; + } + .open-lab .lab-map-legend { + padding-inline: 18px; + } + .open-lab .lab-map-toggle { + min-height: 40px; + font-size: 9px; + padding: 7px; + } + .open-lab .lab-map-inspector { + padding: 19px; + } + .open-lab .lab-map-selected h3 { + font-size: 24px; + } + .open-lab .lab-map-inspector > small { + font-size: 7px; + } + .open-lab .lab-home-work { + gap: 40px; + padding-block: 65px; + } + .open-lab .lab-home-work-intro h2 { + font-size: 43px; + } + .open-lab .lab-home-tools h2 { + font-size: 40px; + } + .open-lab .lab-agent-invitation > .lab-wrap { + gap: 45px; + } + .open-lab .lab-agent-invitation h2 { + font-size: 51px; + } + .open-lab .lab-agent-invitation p:not(.lab-eyebrow) { + font-size: 14px; + } + .open-lab .lab-feed-grid { + grid-template-columns: 150px minmax(0, 1fr); + gap: 30px; + } + .open-lab .lab-margin-note { + display: none; + } + .open-lab .lab-workbench-heading h1 { + font-size: 57px; + } + .open-lab .lab-workbench-heading .lab-segment button { + font-size: 10px; + padding: 8px 12px; + } + .open-lab .lab-sandbox-bottom { + grid-template-columns: 1fr; + gap: 20px; + } + .open-lab .lab-sandbox-explanation { + border-top: 1px solid #3a4e58; + padding-top: 14px; + } + .open-lab .lab-sandbox-explanation p { + max-width: 650px; + } + .open-lab .lab-atlas-intro { + gap: 35px; + } + .open-lab .lab-atlas-intro h1 { + font-size: 58px; + } + .open-lab .lab-atlas-specimen { + padding: 20px; + } + .open-lab .lab-atlas-access { + flex-wrap: wrap; + } + .open-lab .lab-atlas-access > a { + margin-left: 19px; + } + .open-lab .lab-atlas-entries > article { + grid-template-columns: 40px 1fr 0.8fr; + gap: 25px; + } + .open-lab .lab-atlas-entries article h3 { + font-size: 30px; + } + .open-lab .lab-atlas-protocol { + gap: 40px; + padding: 32px; + } + .open-lab .lab-atlas-protocol h2 { + font-size: 38px; + } + .open-lab .lab-collab-intro { + gap: 35px; + grid-template-columns: 1fr; + } + .open-lab .lab-collab-intro > div > p:not(.lab-eyebrow) { + max-width: 620px; + } + .open-lab .lab-packet-workbench { + grid-template-columns: 1fr 1fr; + gap: 24px; + } + .open-lab .lab-packet-preview { + padding: 22px; + } + .open-lab .lab-packet-download { + gap: 8px; + flex-wrap: wrap; + } + .open-lab .lab-packet-preview pre { + max-height: 645px; + font-size: 9px; + } + .open-lab .lab-collab-return { + grid-template-columns: 45px 1fr; + gap: 20px; + } + .open-lab .lab-collab-return > .lab-button { + grid-column: 2; + justify-self: start; + } + .open-lab .lab-profile-grid { + grid-template-columns: 0.9fr 1.1fr; + gap: 25px; + } + .open-lab .lab-profile-card { + padding: 23px; + } + .open-lab .lab-profile-card h2 { + font-size: 30px; + } + .open-lab .lab-bench-content .lab-section-heading h2 { + font-size: 27px; + } +} +@media (max-width: 700px) { + .open-lab .lab-wrap { + padding-inline: 23px; + } + .open-lab .lab-header-inner { + padding-inline: 23px; + } + .open-lab .lab-brand img { + width: 31px; + height: 35px; + } + .open-lab .lab-brand > span { + font-size: 22px; + } + .open-lab .lab-brand small { + font-size: 7px; + } + .open-lab .lab-header nav a { + font-size: 11px; + } + .open-lab .lab-header-actions .lab-button { + font-size: 10px; + padding: 8px 11px; + gap: 5px; + min-height: 40px; + } + .open-lab .lab-header-actions .lab-icon-button { + min-width: 35px; + } + .open-lab .lab-hero { + grid-template-columns: 1fr; + gap: 36px; + padding-top: 40px; + padding-bottom: 35px; + } + .open-lab .lab-hero h1 { + font-size: 62px; + letter-spacing: -0.045em; + max-width: 540px; + line-height: 0.98; + } + .open-lab .lab-hero-deck { + font-size: 16px; + max-width: 460px; + } + .open-lab .lab-hero-note { + max-width: none; + display: grid; + grid-template-columns: 100px 1fr; + gap: 20px; + margin-top: 26px; + padding-top: 19px; + } + .open-lab .lab-hero-note > span { + display: block; + } + .open-lab .lab-hero-note p { + max-width: none; + } + .open-lab .lab-hero-actions { + gap: 20px; + } + .open-lab .lab-hero-actions .lab-text-button { + font-size: 12px; + } + .open-lab .lab-map-svg { + max-height: 390px; + } + .open-lab .lab-map-heading { + padding: 22px; + } + .open-lab .lab-map-heading p { + font-size: 29px; + } + .open-lab .lab-map-kicker { + font-size: 8px; + } + .open-lab .lab-map-legend { + padding-inline: 22px; + font-size: 9px; + } + .open-lab .lab-map-legend span:last-child { + display: flex; + } + .open-lab .lab-map-toggle { + font-size: 10px; + min-height: 44px; + padding: 9px 12px; + } + .open-lab .lab-map-inspector { + padding: 22px; + } + .open-lab .lab-map-selected p { + font-size: 12px; + } + .open-lab .lab-map-inspector-actions :is(a, button) { + font-size: 12px; + min-height: 44px; + } + .open-lab .lab-map-inspector > small { + font-size: 9px; + } + .open-lab .lab-map-list { + min-height: 0; + padding: 20px 22px; + } + .open-lab .lab-map-list > button { + min-height: 82px; + padding: 11px; + font-size: 12px; + } + .open-lab .lab-map-list > button small { + font-size: 9px; + } + .open-lab .lab-frontier-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px 25px; + padding-block: 20px; + } + .open-lab .lab-frontier-strip > span { + grid-column: 1/-1; + max-width: none; + font-size: 8px; + margin-bottom: 10px; + } + .open-lab .lab-frontier-strip > a { + font-size: 21px; + min-height: 40px; + } + .open-lab .lab-home-work { + grid-template-columns: 1fr; + gap: 33px; + padding-block: 52px; + } + .open-lab .lab-home-work-intro h2 { + font-size: 47px; + } + .open-lab .lab-home-work-intro > p:not(.lab-eyebrow) { + max-width: 450px; + } + .open-lab .lab-source-note { + gap: 20px; + } + .open-lab .lab-home-tools { + grid-template-columns: 1fr; + padding-block: 46px 52px; + gap: 28px; + } + .open-lab .lab-home-tools h2 { + font-size: 44px; + } + .open-lab .lab-home-tools > div > p:not(.lab-eyebrow) { + max-width: 450px; + } + .open-lab .lab-signal-teaser { + min-height: 330px; + max-width: 600px; + } + .open-lab .lab-agent-invitation { + padding-block: 50px; + } + .open-lab .lab-agent-invitation > .lab-wrap { + grid-template-columns: 1fr; + gap: 28px; + } + .open-lab .lab-agent-invitation h2 { + font-size: 57px; + } + .open-lab .lab-agent-invitation .lab-eyebrow { + font-size: 8px; + } + .open-lab .lab-agent-invitation p:not(.lab-eyebrow) { + font-size: 15px; + } + .open-lab .lab-workbench { + padding-top: 35px; + padding-bottom: 50px; + } + .open-lab .lab-workbench-heading { + align-items: start; + flex-direction: column; + gap: 25px; + margin-bottom: 30px; + } + .open-lab .lab-workbench-heading h1 { + font-size: 59px; + } + .open-lab .lab-workbench-heading p:not(.lab-eyebrow) { + font-size: 14px; + } + .open-lab .lab-workbench-heading .lab-segment { + align-self: start; + } + .open-lab .lab-feed-grid { + grid-template-columns: 1fr; + gap: 23px; + } + .open-lab .lab-filter-rail { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-top: 0; + border-block: 1px solid var(--lab-line); + padding-block: 12px; + } + .open-lab .lab-filter-rail > .lab-section-label, + .open-lab .lab-filter-rail > .lab-smallprint { + display: none; + } + .open-lab .lab-filter-rail > button { + width: auto; + min-height: 42px; + font-size: 11px; + padding: 7px 9px; + gap: 6px; + } + .open-lab .lab-filter-rail > .lab-field { + width: 100%; + margin-top: 8px; + display: grid; + grid-template-columns: 40px 1fr; + align-items: center; + font-size: 10px; + } + .open-lab .lab-field-filter select { + font-size: 12px; + min-height: 44px; + } + .open-lab .lab-composer-prompt { + padding: 24px; + } + .open-lab .lab-composer-prompt strong { + font-size: 37px; + } + .open-lab .lab-source-tabs { + gap: 25px; + } + .open-lab .lab-source-tabs button { + font-size: 11px; + min-height: 44px; + } + .open-lab .lab-entry-actions :is(a, button) { + font-size: 11px; + min-height: 44px; + } + .open-lab .lab-entry-actions { + justify-content: space-between; + gap: 15px; + } + .open-lab .lab-feed-entry h2 { + font-size: 31px; + } + .open-lab .lab-entry-meta { + font-size: 10px; + } + .open-lab .lab-entry-source { + font-size: 10px; + } + .open-lab .lab-feed-entry > p.lab-entry-question { + font-size: 23px; + } + .open-lab .lab-stream > .lab-smallprint { + font-size: 11px; + } + .open-lab .lab-result-count { + font-size: 11px; + } + .open-lab .lab-sandbox { + padding: 23px 21px; + } + .open-lab .lab-sandbox-title { + display: block; + } + .open-lab .lab-sandbox-badge { + display: none; + } + .open-lab .lab-sandbox-title h2 { + font-size: 39px; + } + .open-lab .lab-sandbox-plot { + margin: 10px -12px; + width: calc(100% + 24px); + height: 210px; + min-height: 0; + } + .open-lab .lab-sandbox-plot svg { + height: 210px; + } + .open-lab .lab-sandbox-controls { + gap: 20px; + grid-template-columns: 1fr; + } + .open-lab .lab-sandbox-controls label { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px; + font-size: 11px; + align-items: center; + } + .open-lab .lab-sandbox-controls output { + font-size: 25px; + margin: 0; + } + .open-lab .lab-sandbox-controls input[type="range"] { + grid-column: 1/-1; + margin: 0; + min-height: 33px; + } + .open-lab .lab-sandbox-explanation p { + font-size: 12px; + } + .open-lab .lab-sandbox-explanation small { + font-size: 10px; + } + .open-lab .lab-sandbox-actions { + flex-wrap: wrap; + gap: 0 24px; + } + .open-lab .lab-sandbox-actions .lab-checkbox { + width: 100%; + font-size: 11px; + } + .open-lab .lab-sandbox-actions button { + font-size: 11px; + } + .open-lab .lab-app-catalog { + padding-top: 39px; + } + .open-lab .lab-section-heading { + flex-direction: column; + align-items: start; + gap: 15px; + } + .open-lab .lab-section-heading h2 { + font-size: 32px; + } + .open-lab .lab-app-catalog .lab-section-heading .lab-smallprint { + max-width: none; + font-size: 11px; + } + .open-lab .lab-catalog-controls { + grid-template-columns: 1fr auto; + gap: 15px; + margin-bottom: 25px; + } + .open-lab .lab-catalog-controls .lab-search { + grid-column: 1/-1; + } + .open-lab .lab-catalog-controls .lab-field { + max-width: 280px; + } + .open-lab .lab-app-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .open-lab .lab-app-card { + padding: 24px; + } + .open-lab .lab-app-card h3 { + font-size: 36px; + } + .open-lab .lab-app-card-top { + font-size: 10px; + } + .open-lab .lab-app-status, + .open-lab .lab-app-card > small { + font-size: 10px; + } + .open-lab .lab-app-links { + gap: 17px; + } + .open-lab .lab-app-links > a:not(.lab-button) { + font-size: 11px; + } + .open-lab .lab-contribute-strip { + flex-direction: column; + align-items: start; + gap: 10px; + margin-top: 30px; + } + .open-lab .lab-contribute-strip p { + font-size: 28px; + line-height: 1.2; + } + .open-lab .lab-atlas-intro { + grid-template-columns: 1fr; + gap: 34px; + padding-top: 0; + } + .open-lab .lab-atlas-intro h1 { + font-size: 68px; + } + .open-lab .lab-atlas-specimen { + padding: 23px 27px; + } + .open-lab .lab-atlas-specimen > .lab-section-label { + font-size: 9px; + } + .open-lab .lab-atlas-access { + margin-bottom: 36px; + padding: 18px; + align-items: start; + } + .open-lab .lab-atlas-access .lab-status-dot { + margin-top: 8px; + } + .open-lab .lab-atlas-access p { + font-size: 12px; + } + .open-lab .lab-atlas-access a { + white-space: normal; + font-size: 11px; + } + .open-lab .lab-atlas-entries > article { + grid-template-columns: 32px 1fr; + gap: 18px; + padding-block: 28px; + } + .open-lab .lab-atlas-index { + font-size: 33px; + } + .open-lab .lab-atlas-entries article h3 { + font-size: 31px; + } + .open-lab .lab-atlas-next { + grid-column: 2; + padding-left: 0; + border-left: 0; + padding-top: 14px; + } + .open-lab .lab-atlas-next p { + font-size: 23px !important; + } + .open-lab .lab-atlas-protocol { + grid-template-columns: 1fr; + gap: 28px; + padding: 25px; + margin-top: 12px; + } + .open-lab .lab-atlas-protocol h2 { + font-size: 39px; + } + .open-lab .lab-atlas-protocol li p { + font-size: 12px; + } + .open-lab .lab-collab-intro { + gap: 24px; + padding-top: 0; + margin-bottom: 35px; + } + .open-lab .lab-collab-intro h1 { + font-size: 59px; + } + .open-lab .lab-collab-intro > div > p:not(.lab-eyebrow) { + font-size: 14px; + } + .open-lab .lab-packet-workbench { + grid-template-columns: 1fr; + gap: 34px; + } + .open-lab .lab-campaign-options > button { + padding: 23px; + } + .open-lab .lab-campaign-options strong { + font-size: 28px; + } + .open-lab .lab-campaign-options p { + font-size: 13px; + } + .open-lab .lab-campaign-options small, + .open-lab .lab-campaign-options > button > span { + font-size: 10px; + } + .open-lab .lab-packet-preview { + padding: 24px; + } + .open-lab .lab-packet-top { + gap: 20px; + } + .open-lab .lab-packet-top .lab-segment button { + min-height: 40px; + padding: 9px 15px; + font-size: 11px; + } + .open-lab .lab-packet-preview pre { + max-height: 380px; + font-size: 11px; + } + .open-lab .lab-packet-id { + font-size: 11px; + } + .open-lab .lab-packet-download { + gap: 18px; + justify-content: space-between; + } + .open-lab .lab-packet-download .lab-button { + font-size: 12px; + padding: 10px 15px; + } + .open-lab .lab-packet-boundary p { + font-size: 12px; + } + .open-lab .lab-collab-return { + grid-template-columns: 1fr; + gap: 10px; + margin-top: 38px; + padding-top: 28px; + } + .open-lab .lab-collab-return > .lab-margin-symbol { + font-size: 50px; + margin-bottom: 5px; + } + .open-lab .lab-collab-return > .lab-button { + grid-column: 1; + margin-top: 13px; + } + .open-lab .lab-collab-return h2 { + font-size: 35px; + } + .open-lab .lab-profile-grid { + grid-template-columns: 1fr; + gap: 37px; + } + .open-lab .lab-profile-card { + padding: 25px; + } + .open-lab .lab-profile-card h2 { + font-size: 36px; + } + .open-lab .lab-bench-content .lab-section-heading h2 { + font-size: 32px; + } + .open-lab .lab-profile-card > .lab-smallprint { + font-size: 11px; + } + .open-lab .lab-bench-content .lab-section-heading > .lab-eyebrow { + font-size: 9px; + } + .open-lab .lab-form-grid { + grid-template-columns: 1fr; + gap: 20px; + } + .open-lab .lab-dialog { + width: calc(100% - 20px); + max-height: calc(100dvh - 24px); + padding: 20px; + } + .open-lab .lab-dialog h2 { + font-size: 34px; + } + .open-lab .lab-dialog .lab-field :is(input, textarea, select) { + font-size: 16px; + } + .open-lab .lab-dialog-head .lab-eyebrow { + font-size: 8px; + } + .open-lab .lab-dialog .lab-field { + font-size: 12px; + } + .open-lab .lab-form-actions { + gap: 9px; + } + .open-lab .lab-form-actions .lab-button { + font-size: 11px; + padding: 10px 13px; + min-height: 44px; + } + .open-lab .lab-form-actions > :first-child { + margin-right: 0; + } + .open-lab .lab-record-preview { + padding: 15px; + } + .open-lab .lab-footer { + padding: 30px 23px; + grid-template-columns: 1fr; + gap: 20px; + } + .open-lab .lab-footer > div { + gap: 26px; + font-size: 11px; + } + .open-lab .lab-footer-name { + font-size: 27px; + } + .open-lab .lab-footer > p { + font-size: 9px; + } +} +@media (max-width: 380px) { + .open-lab .lab-wrap { + padding-inline: 18px; + } + .open-lab .lab-header-inner { + padding-inline: 18px; + } + .open-lab .lab-brand { + gap: 7px; + } + .open-lab .lab-brand img { + width: 27px; + } + .open-lab .lab-brand > span { + font-size: 21px; + } + .open-lab .lab-header-actions .lab-button { + font-size: 9px; + padding: 8px; + gap: 3px; + } + .open-lab .lab-header-actions .lab-icon-button { + min-width: 30px; + font-size: 21px; + } + .open-lab .lab-header nav { + gap: 5px; + } + .open-lab .lab-header nav a { + font-size: 10px; + } + .open-lab .lab-hero h1 { + font-size: 52px; + } + .open-lab .lab-hero-deck { + font-size: 15px; + } + .open-lab .lab-hero-actions { + gap: 8px; + } + .open-lab .lab-hero-actions .lab-button { + font-size: 12px; + padding: 10px 16px; + gap: 20px; + } + .open-lab .lab-hero-note { + grid-template-columns: 76px 1fr; + gap: 15px; + } + .open-lab .lab-hero-note > span { + font-size: 7px; + } + .open-lab .lab-hero-note p { + font-size: 10px; + } + .open-lab .lab-map-heading { + padding: 18px 15px; + } + .open-lab .lab-map-heading p { + font-size: 27px; + } + .open-lab .lab-map-kicker { + font-size: 7px; + } + .open-lab .lab-map-toggle { + font-size: 9px; + padding: 8px; + } + .open-lab .lab-map-list { + padding: 16px 15px; + grid-template-columns: 1fr; + gap: 7px; + } + .open-lab .lab-map-list > button { + min-height: 61px; + font-size: 12px; + } + .open-lab .lab-map-legend { + padding-inline: 15px; + font-size: 8px; + } + .open-lab .lab-map-legend span:last-child { + display: none; + } + .open-lab .lab-map-inspector { + padding: 20px 15px; + } + .open-lab .lab-frontier-strip > a { + font-size: 19px; + } + .open-lab .lab-home-work-intro h2 { + font-size: 42px; + } + .open-lab .lab-source-note h3 { + font-size: 26px; + } + .open-lab .lab-home-tools h2 { + font-size: 41px; + } + .open-lab .lab-signal-teaser { + padding: 23px; + min-height: 325px; + } + .open-lab .lab-signal-teaser h3 { + font-size: 30px; + } + .open-lab + .lab-signal-teaser + > div:not(.lab-teaser-wave):not(.lab-section-label) { + display: block; + } + .open-lab .lab-signal-teaser span { + display: block; + margin-top: 12px; + } + .open-lab .lab-agent-invitation h2 { + font-size: 48px; + } + .open-lab .lab-workbench-heading h1 { + font-size: 52px; + } + .open-lab .lab-composer-prompt { + padding: 20px; + } + .open-lab .lab-composer-prompt strong { + font-size: 33px; + } + .open-lab .lab-entry-actions { + gap: 10px; + } + .open-lab .lab-entry-actions :is(a, button) { + font-size: 10px; + } + .open-lab .lab-source-tabs { + gap: 17px; + } + .open-lab .lab-source-tabs button { + font-size: 10px; + } + .open-lab .lab-sandbox { + padding: 21px 17px; + } + .open-lab .lab-sandbox-title h2 { + font-size: 34px; + } + .open-lab .lab-sandbox .lab-eyebrow { + font-size: 8px; + } + .open-lab .lab-app-links { + gap: 13px; + } + .open-lab .lab-app-links > .lab-button { + padding: 8px 10px; + font-size: 10px; + } + .open-lab .lab-app-links > a:not(.lab-button) { + font-size: 10px; + } + .open-lab .lab-app-card { + padding: 21px; + } + .open-lab .lab-atlas-intro h1 { + font-size: 58px; + } + .open-lab .lab-atlas-specimen { + padding: 22px; + } + .open-lab .lab-atlas-entries > article { + grid-template-columns: 25px 1fr; + gap: 15px; + } + .open-lab .lab-atlas-entries article h3 { + font-size: 28px; + } + .open-lab .lab-atlas-protocol { + padding: 22px; + } + .open-lab .lab-collab-intro h1 { + font-size: 49px; + } + .open-lab .lab-packet-preview { + padding: 20px; + } + .open-lab .lab-packet-download { + gap: 6px; + } + .open-lab .lab-packet-download .lab-button { + font-size: 11px; + } + .open-lab .lab-profile-card { + padding: 22px; + } + .open-lab .lab-bench-content .lab-section-heading h2 { + font-size: 30px; + } + .open-lab .lab-dialog { + padding: 18px; + } + .open-lab .lab-dialog h2 { + font-size: 30px; + } + .open-lab .lab-form-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } + .open-lab .lab-form-actions > :last-child { + grid-column: 1/-1; + } + .open-lab .lab-footer { + padding-inline: 18px; + } +} +/* Shared work briefs keep the opening next to its source. */ +.open-lab .lab-artifact-brief { + margin-top: 18px; + border-top: 1px solid var(--lab-line); + min-width: 0; + grid-column: 1 / -1; +} +.open-lab .lab-artifact-brief summary { + cursor: pointer; + padding-block: 12px; + min-height: 44px; + font-size: 12px; + font-weight: 600; +} +.open-lab .lab-artifact-brief summary:focus-visible { + outline: 2px solid var(--lab-blue); + outline-offset: 3px; +} +.open-lab .lab-work-brief { + display: grid; + grid-template-columns: minmax(90px, 0.3fr) minmax(0, 1fr); + gap: 10px 18px; + margin-block: 18px; + font-size: 13px; + line-height: 1.6; +} +.open-lab .lab-work-brief dt { font-weight: 600; } +.open-lab .lab-work-brief dd { margin: 0; overflow-wrap: anywhere; } +.open-lab .lab-work-brief a { text-decoration: underline; text-underline-offset: 3px; } +.open-lab .lab-map .lab-artifact-brief { border-color: var(--lab-line); } +.open-lab .lab-map .lab-smallprint { color: var(--lab-muted); } +.open-lab .lab-map .lab-work-brief { font-size: 12px; } +.open-lab .lab-map-selected .lab-map-opening { color: var(--lab-muted); margin-top: 12px; } +.open-lab .lab-sandbox .lab-smallprint { color: #bed6e8; } +.open-lab .lab-sandbox-actions { flex-wrap: wrap; } +.open-lab .lab-sandbox-actions button { text-align: left; font-size: 12px; } +.open-lab .lab-paired-audit { margin-block: 50px 30px; padding-block: 32px; border-block: 1px solid var(--lab-line); } +.open-lab .lab-paired-audit h2 { font-size: clamp(30px, 3vw, 42px); line-height: 1.15; } +.open-lab .lab-paired-audit blockquote { margin: 25px 0; padding: 20px; border-left: 3px solid var(--lab-blue); background: var(--lab-soft); font-size: 17px; } +.open-lab .lab-paired-audit .lab-form-actions { display: flex; flex-wrap: wrap; margin-block: 20px; } +@media (max-width: 700px) { + .open-lab .lab-work-brief { grid-template-columns: 1fr; gap: 6px; } + .open-lab .lab-work-brief dd { margin-bottom: 10px; } + .open-lab .lab-header-actions .lab-button { min-height: 44px; } + .open-lab .lab-entry-actions :is(button, a) { min-height: 44px; } + .open-lab .lab-packet-top .lab-segment button { min-height: 44px; } + .open-lab .lab-map-list { max-height: 380px; overflow-y: auto; } +} +@media (prefers-reduced-motion: reduce) { + .open-lab * { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} diff --git a/src/app/lab/layout.tsx b/src/app/lab/layout.tsx new file mode 100644 index 00000000..3af0e930 --- /dev/null +++ b/src/app/lab/layout.tsx @@ -0,0 +1,6 @@ +import { LabAuthProvider } from "@/lib/lab-auth"; +import LabShell from "@/components/lab/LabShell"; +import "./lab.css"; +export default function LabLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/lab/oauth/return/layout.tsx b/src/app/lab/oauth/return/layout.tsx new file mode 100644 index 00000000..533a64e1 --- /dev/null +++ b/src/app/lab/oauth/return/layout.tsx @@ -0,0 +1,4 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +export const metadata: Metadata = { title: 'Open Lab authorization', robots: { index: false, follow: false }, referrer: 'no-referrer' } +export default function LabOAuthReturnLayout({ children }: { children: ReactNode }) { return children } diff --git a/src/app/lab/oauth/return/page.tsx b/src/app/lab/oauth/return/page.tsx new file mode 100644 index 00000000..087111a2 --- /dev/null +++ b/src/app/lab/oauth/return/page.tsx @@ -0,0 +1,18 @@ +'use client' + +import { LabAuthProvider, useLabAuth } from '@/lib/lab-auth' +function ReturnStatus() { + const { isLoading, error, isAuthenticated } = useLabAuth() + return
+

Return to Open Lab

+

+ {isLoading ? 'Completing account authorization…' : error ?? (isAuthenticated ? 'Account authorization is complete. Return to your draft to review it.' : 'No account authorization was completed. You can still explore the lab.')} +

+

Nothing is published automatically. Your draft stays on this browser until you explicitly confirm a public record.

+ Back to Open Lab +
+} +export default function LabOAuthReturnPage() { + // The provider shares the Lab runtime and also permits isolated callback rendering. + return +} diff --git a/src/app/lab/onboarding/page.tsx b/src/app/lab/onboarding/page.tsx new file mode 100644 index 00000000..6409489a --- /dev/null +++ b/src/app/lab/onboarding/page.tsx @@ -0,0 +1,13 @@ +'use client' +import { useLabAuth } from '@/lib/lab-auth' +import { InterestOnboarding } from '@/components/lab/social/InterestOnboarding' +import { ProfileCompletion } from '@/components/lab/social/ProfileCompletion' + +export default function OnboardingPage() { + // The dedicated Open Lab layout owns authentication. Never nest a second provider. + const { session, isLoading } = useLabAuth() + // Never expose a guest editor while an existing authenticated owner is restoring. + if (isLoading) return

Loading your local starting choices…

+ const ownerId = session?.did || 'guest' + return
+} diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx new file mode 100644 index 00000000..93d69867 --- /dev/null +++ b/src/app/lab/page.tsx @@ -0,0 +1,11 @@ +import Landing from "@/components/lab/Landing"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Open Lab", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/" }, +}; +export default function Page() { + return ; +} diff --git a/src/app/lab/people/page.tsx b/src/app/lab/people/page.tsx new file mode 100644 index 00000000..fd068bd8 --- /dev/null +++ b/src/app/lab/people/page.tsx @@ -0,0 +1,3 @@ +import PeopleWorkbench from '@/components/lab/PeopleWorkbench' +export const metadata = {title:'People · Open Lab'} +export default function PeoplePage(){return } diff --git a/src/app/lab/profile/page.tsx b/src/app/lab/profile/page.tsx new file mode 100644 index 00000000..8499519c --- /dev/null +++ b/src/app/lab/profile/page.tsx @@ -0,0 +1,11 @@ +import ProfileWorkbench from "@/components/lab/ProfileWorkbench"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "My bench", + description: + "Open Lab by PL R&D. Explore real scientific artifacts, draft contributions, and make research more reproducible.", + alternates: { canonical: "/lab/profile/" }, +}; +export default function Page() { + return ; +} diff --git a/src/app/lab/record/page.tsx b/src/app/lab/record/page.tsx new file mode 100644 index 00000000..a6fa9a4e --- /dev/null +++ b/src/app/lab/record/page.tsx @@ -0,0 +1,18 @@ +'use client' + +import { useState } from 'react' +import LabPublicInspector from '@/components/lab/LabPublicInspector' +import RecordEditor from '@/components/lab/RecordEditor' +import { readLabRecord } from '@/lib/lab-protocol' +import { displayRecord, presentPdsRecord, recordPermalink, type PublicLabDocument } from '@/lib/lab-record-display' + +const loadRecord = async (uri: string) => presentPdsRecord(await readLabRecord(uri)) + +export default function Page() { + const [proposal, setProposal] = useState<{ record: PublicLabDocument; targetUrl: string } | null>(null) + const title = proposal ? `Evidence for: ${displayRecord(proposal.record).title}`.slice(0, 160) : '' + return <> + setProposal({ record, targetUrl: recordPermalink(window.location.origin, record.uri) })} /> + {proposal && setProposal(null)} />} + +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 510c3183..5e13f790 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -99,7 +99,7 @@ export default function RootLayout({ {/* Apply theme before paint to avoid a flash of the wrong theme */}