Skip to content
Docs

Contributing

Testing

How Startica is tested, from Vitest unit tests on real SQLite to the browser and desktop smoke tests and CI.

Startica has three layers of tests: unit tests (Vitest in the packages, node --test in apps/desktop), a browser smoke test that drives the real server, and a desktop smoke test that launches the real Electron app. All three run against isolated data, so they never touch your Startica database. Every pull request must pass the first two; changes to the Electron shell must pass the third.

The checks

yarn typecheck && yarn lint && yarn test && yarn smoke:desktop
Command What runs
yarn typecheck tsc --noEmit in every workspace; in apps/desktop-web, next typegen first so route types exist
yarn lint ESLint (Next.js config) in apps/desktop-web. Other workspaces have no lint config yet.
yarn test Vitest in packages/core and packages/db, and node --test for the posting window in apps/desktop
yarn smoke:desktop The browser smoke test (despite the name, it doesn’t start Electron)

Unit tests (Vitest)

Tests sit next to the code as *.test.ts files in packages/core/src and packages/db/src. Run them all with yarn test, or one package in watch mode:

yarn workspace @startica/core vitest
yarn workspace @startica/core vitest src/channels

Real SQLite, not mocks

Database tests use a real SQLite file in a temporary folder with the real migrations. The setup from packages/core/src/jobs/runner.test.ts:

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll } from "vitest";
import { ensureWorkspace } from "@startica/db/bootstrap";
import { closeDatabase, migrateDatabase, openDatabase } from "@startica/db/client";

let workspaceId = "";

beforeAll(async () => {
  openDatabase(join(mkdtempSync(join(tmpdir(), "startica-core-")), "test.db"));
  await migrateDatabase(join(import.meta.dirname, "../../../db/migrations"));
  workspaceId = (await ensureWorkspace()).id;
});
afterAll(() => closeDatabase());

The connection is per process, so each test file opens its own database and closes it at the end. Tests that store secrets need a cipher first: call setCipher(fileKeyCipher(<path in the temp folder>)) from @startica/db/secrets.

No network, no AI

Unit tests must not call Claude, OpenAI, Reddit, Hacker News, Bluesky, Mastodon, Discourse, X or any other service. Structure code so the parts worth testing are pure functions:

  • Channel adapters: save a real response as a fixture in packages/core/src/channels/__fixtures__/ and test the parser against it (parseRedditFeed() with reddit-new.atom), or test the pure helpers with small hand-built objects (open-networks.test.ts covers Bluesky, Mastodon and Discourse link parsing, post mapping and channelFromUrl()). Anonymize usernames in fixtures.
  • Adapters with state: stub fetch with vi.stubGlobal and use a temporary database. x.test.ts does this to check the X adapter’s rationing: no request without a token, nothing within the scan interval, since_id on the next scan, and the daily read cap.
  • Posting requests: posting/posting.test.ts checks which replies and pieces get a posting request (character limits, threads, a missing Mastodon server or Discourse forum) and the pages they open. content/campaign.test.ts checks that email campaigns round-trip through their text sections.
  • First-run setup: setup/setup.test.ts checks that a new workspace starts with nothing done, that the wizard and checklist flags are remembered, and that the Getting started checklist pre-selects the first 5 suggestions listening can read, in intel’s order.
  • Metrics connectors: test the summarizing function with hand-built API objects and a fixed now (summarizeSubscriptions() in metrics/metrics.test.ts).
  • Product database: integrations/integrations.test.ts checks the setup SQL (identifiers that could inject SQL are refused), the reader’s connection URL built from a pasted string, keeping the saved password, refusing logins that can write, the error messages and SSL. If initdb is installed (Homebrew or the usual Linux and macOS folders), it also starts a throwaway Postgres on port 58394, runs the generated SQL on a Supabase-like database and checks that startica_reader reads only the daily totals; without Postgres those tests are skipped.
  • Accessibility: a11y/a11y.test.ts covers the pure parts: URL normalization, which URLs a crawl may open, which requests a scanned page may make (never Startica’s port; private addresses only from a local site), sitemaps and sitemap indexes, mapping axe tags to WCAG criteria and levels, element fingerprints, and the score. a11y/scan.test.ts is the exception to “no network”: it runs real scans against the local stand-in site with Playwright’s Chromium, and skips itself if Chromium isn’t installed. See below.
  • SEO & GEO: seo/seo.test.ts covers the pure parts: robots.txt groups and rule precedence (a crawler’s own group, else *; the longest rule; case-insensitive names), the page checks (missing and overlong basics, noindex in the sitemap from the meta tag or X-Robots-Tag, canonicals elsewhere), JSON-LD (types inside @graph, invalid blocks, missing properties) and the page score. The site checks run against the stand-in site in a11y/scan.test.ts.
  • Issue trackers: trackers/trackers.test.ts runs against the stand-in GitHub and GitLab APIs: only https servers (or http on this computer or network), a bad token recorded as the tracker’s error, one repository issue per finding with where it fails and how to fix it, the automatic tracker_sync (impact threshold, closing fixed issues, reopening ones that come back), GitLab, and a code fence an HTML snippet can’t break out of.
  • Jobs: test the database logic directly, or run the runner on a job that has nothing to do. runner.test.ts enqueues listen_scan for a product without a brief, which finishes without any network call. distribution/scan.test.ts stubs the adapters to check that listen_scan keeps going when one source fails and queues what the others found, that an X API error is recorded for Settings instead of failing the scan, how drafting matches a conversation to its watched community (matchCommunity()), and that a Mastodon reply’s length counts the @author mention.

Use the fictional demo product (Fieldnote, https://fieldnote.example) in test data, never a real business.

Accessibility stand-in site

The scan tests never touch a real site. packages/core/src/a11y/standin/ is a small static site with known failures, served by server.mjs:

yarn workspace @startica/desktop-web a11y-standin   # http://localhost:58395
Page Planted failure
/ The shared header (below)
/pricing.html Low-contrast text
/signup.html An input with no label, and a button with no text
/about.html No lang on the page
/sitemap-only.html Only in the sitemap, not linked from any page; has an empty link
/clean.html None: scores 100
/private/notes.html Disallowed by robots.txt, so it’s skipped
/missing.html Linked but missing: answers 404, so it fails

Five pages share a header whose logo image has no alt text, so its link has no name either. Each of those is one issue group on five pages. A full scan should find exactly 7 open issue groups.

The same pages have planted SEO & GEO problems:

Where Planted problem Findings
/pricing.html No meta description; a JSON-LD block with a missing comma description-missing, jsonld-invalid
/about.html The same title as pricing (“Pricing: Fieldnote”), two h1s, no meta description title-duplicate, h1-multiple, description-missing
/sitemap-only.html noindex while listed in the sitemap, linked from nowhere, no meta description noindex-in-sitemap, orphan-page, description-missing
/missing.html Linked from the home page, answers 404 broken-internal-link
robots.txt Blocks GPTBot (training) and OAI-SearchBot (AI search) ai-training-blocked (FYI), ai-search-blocked
/llms.txt Doesn’t exist llms-missing

That’s exactly 13 SEO findings: title-duplicate counts once for each of its two pages. The home page has a description, an Organization block and a social image, and /clean.html and /signup.html have none of these problems.

a11y/scan.test.ts checks, with real Chromium:

  • a full crawl finds exactly the planted failures, with the right page states (done, skipped, failed);
  • the same crawl finds exactly the 13 planted SEO findings, and gives the site a site health score;
  • rechecking a page after a fix (served by the stand-in’s transform option, without touching its files) marks the issue fixed and raises the score;
  • a scan interrupted mid-way resumes where it stopped.

Warning

If you change a check, the fingerprinting or the crawl, keep the stand-in and the expected counts in step. Both smoke tests expect 7 open issues, and a11y/scan.test.ts expects the exact set of SEO findings.

Issue tracker stand-in

Tests never open issues on a real GitHub or GitLab. packages/core/src/trackers/standin.mjs is a small HTTP server that answers just the endpoints Startica calls, for both services, and keeps the issues it receives in memory:

import { startTrackerStandin, STANDIN_TOKEN } from "./standin.mjs";

const api = await startTrackerStandin(0); // a free port
// Save a tracker with base_url: api.url and token: STANDIN_TOKEN ("test-token")
const opened = [...api.issues.values()]; // { title, body, labels, state, comments }
await api.close();

Any token other than test-token gets a 401. Because the stand-in runs on localhost, an http:// server address is allowed.

Desktop unit tests (node --test)

apps/desktop is plain JavaScript, so its unit tests use Node’s built-in test runner instead of Vitest:

yarn workspace @startica/desktop test

This runs node --test "posting/*.test.mjs". posting/sites.test.mjs checks that every site’s fill-in script is valid JavaScript even with awkward text (quotes, </script>), that each site’s response parser turns a submit response into the right link (X, Reddit, Hacker News, LinkedIn, Bluesky, Mastodon, Discourse), that the right requests count as a submit (a Bluesky like doesn’t; a Discourse post held for approval comes back as pending, without a link), and that the submitted text is read from the request. The fill-in scripts themselves run in Chromium in the desktop smoke test. yarn test runs these too, and they don’t need Electron.

Browser smoke test

yarn smoke:desktop

This runs apps/desktop-web/scripts/smoke.mjs, which needs Playwright’s Chromium (yarn workspace @startica/desktop-web playwright install chromium, once). It:

  1. Creates a temporary data folder and starts the server on port 58399 with its own Next.js build folder, .next-smoke, so it can run next to your dev server on 58300.
  2. Checks that a request without the launch token gets a 401.
  3. Opens the one-time token link and waits for /welcome: a fresh data folder opens the welcome wizard.
  4. Walks the wizard: on Connect AI it picks OpenAI and checks that the step asks for an OpenAI API key, then clicks Skip for now. It enters a product with the website example.com, unticks Start building intel now so no real research runs, sets a Mastodon server in A few extras, clicks Finish and checks that Today shows the Getting started checklist with the new product.
  5. Checks the communities step: it marks the product’s intel ready and inserts three suggestions straight into the database (two subreddits and a Hacker News community), opens Review suggestions, and checks that Watch 2 communities watches exactly the two readable ones.
  6. Checks that the wizard’s Mastodon server was saved (Settings shows it), and adds a second product in Settings.
  7. Loads every page (/today, /welcome, /inbox, /content, /roadmap, /intel, /analytics, /accessibility, /accessibility/issues, /accessibility/pages, /seo, /seo/health, /security, /settings, /system) and fails on a non-200 response or an error screen.
  8. Adds the accessibility stand-in site (served on port 58395 for the whole run) on Accessibility through the UI (Add the site, ticking I own this site or am allowed to test it), clicks Scan now and waits for the scan to finish with the Playwright backend. It checks that Open issues says 7, that the header logo’s missing alt text is one issue on several pages (“probably part of a shared template”) listing each page, and that a page report lists the page’s issues with their elements. It opens SEO & GEO and checks that the AI crawlers table shows OAI-SearchBot as Blocked, and that Site health lists “Broken internal link”, “Several pages share the same title” and “No llms.txt” (saving startica-smoke-seo-health.png to the temp folder). Then it connects the issue tracker stand-in in Settings → Products → Issue tracker as the GitHub repository fieldnote/web, opens the “Images must have alternative text” issue, clicks Create GitHub issue, and checks that the issue page links to the new GitHub issue and that the stand-in received exactly one issue with the right title.
  9. Collapses the sidebar’s Marketing section, reloads, and checks that it stays collapsed and that the Accessibility section’s links still show.
  10. Saves Intel notes through a server action.
  11. Clicks “Run test job” on the System page and waits for the job table to update live. The smoke test reads no env file and never has an AI key, so the ping must fail with “No Anthropic API key”. That shows the job reached the AI client.
  12. In Settings → General → AI, opens Add API key, chooses OpenAI under Use, saves a dummy OpenAI key, and checks that the card says “Using OpenAI.” and the tasks moved to OpenAI’s models (GPT-6 Luna).
  13. In Settings → Products, opens the Product database setup, clicks Write the SQL, checks the script creates startica_reader and grants it only the view, and checks that a connection string to an unreachable database is refused before anything is saved.
  14. Fails if the browser reported any page errors, then deletes the temp folder.

Each step prints ✓ or ✗, and the command exits with code 1 if anything failed. The first run takes a while because Next.js compiles every page.

When you add a page or a key flow, add it here.

Desktop smoke test

yarn workspace @startica/desktop smoke

This runs apps/desktop/scripts/smoke.mjs, which launches the real Electron app through Playwright with port 58398, a temporary STARTICA_DATA_DIR and the build folder .next-desktop-smoke. It clears ELECTRON_RUN_AS_NODE for you. It checks that:

  • the window opens the welcome wizard (/welcome) and has the right title,
  • the desktop app offers Look around with a sample product first, and Skip setup goes to Today,
  • the Electron scanner works: it adds a product for the stand-in site (port 58395), adds the site on Accessibility, scans it in the hidden scan windows and checks for the 7 planted issues and at most two hidden windows. This runs in dev and --packaged runs, which proves axe-core ships with the packaged server,
  • saving an AI key in Settings round-trips through the OS keychain (“Anthropic API key: saved” only appears once the server can decrypt it),
  • Posting with Startica works end to end (dev runs only, below),
  • closing the window keeps the app running in the tray, and reopening brings the window back on Today,
  • demo mode works (dev runs only): it writes startica-mode.json with {"demo":true} into the same temporary data folder and launches the app again on port 58397. It checks that Today opens on Fieldnote with the demo banner, that the Inbox has no Post… button, that Accessibility shows the sample results for fieldnote.example with scanning off, and that the demo has its own demo/startica.db.

Posting against stand-ins

The desktop smoke test never touches the real sites. apps/desktop/scripts/posting-standins.mjs serves local stand-ins on port 58396 for all seven sites (X, Hacker News, Reddit, LinkedIn, Bluesky, Mastodon and Discourse) that mimic the markup each site’s fill-in looks for and each site’s response to a submit; STARTICA_POSTING_TEST_BASE points the posting window at them, and STARTICA_POSTING_PARTITION gives it a throwaway in-memory session so your real sign-ins are never touched (both ignored in packaged builds). The test seeds a drafted reply per site (and a Mastodon server, mastodon.example, in the posting settings), clicks Post… in the Inbox, and checks per site that:

  • the reply was filled into the right box (a text box’s value, or a rich editor’s text for LinkedIn, Bluesky and Discourse; on Mastodon, after the "@author " mention),
  • nothing was sent before the (simulated) user pressed the site’s button,
  • after the press, the post was recorded with the right link,
  • text edited on the page before the press is what gets saved.

It does the same for three Content Studio new posts: a Hacker News post (title and text filled in, link recorded), a Bluesky post (typed into a compose box that starts empty, link recorded) and a Discourse new topic on a forum that holds new topics for a moderator (filled in, then saved as posted without a link). New posts on X, LinkedIn and Mastodon are covered only by the unit tests above, not by the smoke test. --packaged runs skip these checks.

Warning

If you change a site’s selectors or response parsing in apps/desktop/posting/sites.mjs, change its stand-in in posting-standins.mjs in the same commit, so the smoke test keeps matching the real site. The stand-ins only prove the code matches them; check a change against the live site too, with a real account, before calling it done.

It saves desktop-settings.png, desktop-today.png, desktop-accessibility.png and the posting screenshots (desktop-posting-bar.png, desktop-posting-site.png, desktop-inbox-posted.png) to a folder you pass as an argument, or to the system temp folder:

yarn workspace @startica/desktop smoke ./screenshots

Run it for any change in apps/desktop, the keychain path, startup or posting.

Testing a built app

After yarn build:desktop, test the packaged app instead of the dev setup:

yarn workspace @startica/desktop smoke --packaged

It launches the unpacked app from apps/desktop/dist (mac/Startica.app, win-unpacked/Startica.exe or linux-unpacked/startica). This catches packaging problems the dev run can’t, like a file missing from the assembled server.

Note

On macOS the first keychain access may show a system prompt asking to allow access. Click Always Allow so later runs aren’t blocked.

Isolated ports and cleanup

Tests use ports 58395 to 58399: 58395 the accessibility stand-in site, 58396 the desktop smoke test’s posting stand-ins, 58397 its demo-mode launch, 58398 the desktop smoke test, 58399 the browser smoke test. They never use 58300, which belongs to the app you may be running. If a run is interrupted and leaves a server behind, stop it by port; Next.js renames its process to next-server, so killing by name doesn’t work:

lsof -tiTCP:58399 | xargs kill

A leftover Electron instance blocks new launches through the single-instance lock. Find it with pgrep -fl Startica.

CI

GitHub Actions (.github/workflows/ci.yml) runs on every pull request and every push to main, on ubuntu-latest and macos-latest with Node 24. It installs dependencies with yarn install --immutable, installs Playwright’s Chromium, then runs yarn typecheck, yarn lint, yarn test and yarn smoke:desktop. It skips the Electron binary download, because none of these checks start Electron.

The desktop smoke test isn’t part of CI, because it needs a display and an OS keychain. Run it locally when you change apps/desktop.

The smoke test never has an AI key, locally or in CI, so its ping job is expected to fail with “No Anthropic API key”. If CI fails but your local run passes, check whether your change updated yarn.lock (the install is --immutable), and whether a test depends on your local data, time zone or network.

Search the docs