Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- main
- dev
pull_request:
branches:
- main
- dev

permissions:
contents: write
Expand All @@ -16,8 +20,96 @@ concurrency:
cancel-in-progress: false

jobs:
verify:
name: Verify (lint, unit, build, e2e)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false

# Required because src/stores/profile.ts imports types from
# ../../../dashboard-app/frontend/types/database.type β€” see CLAUDE.md.
- name: Checkout sibling dashboard-app
run: git clone --depth 1 https://github.com/codebridger/subturtle-dashboard-app.git ../dashboard-app

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
playwright-${{ runner.os }}-

- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps

- name: Install Playwright system deps (cache hit path)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium

- name: Type check
run: yarn typecheck

- name: Unit tests (Vitest)
run: yarn test

# dotenv-webpack is configured with `safe: true`, so the build needs
# every key in .env.example to exist at build time. We write
# non-empty placeholders rather than copying the empty .env.example
# β€” `mixpanel.init("")` throws synchronously during the content-script
# import chain, which silently halts every Vue mount and was the root
# cause of e2e tests failing on `#subturtle-{nibble,console-crane}-root`
# never appearing. SUBTURTLE_API_URL points at the local fixtures
# server so any auth/translate calls 404 instead of escaping to the
# real backend.
- name: Stub .env.production for verify build
run: |
cat > .env.production <<'EOF'
MIXPANEL_PROJECT_TOKEN=ci_e2e_stub_token
MIXPANEL_API_HOST=http://localhost:4173/_mixpanel_stub
GOOGLE_TRANSLATE_KEY=ci_e2e_stub_key
GOOGLE_TRANSLATE_PROXY_URL=http://localhost:4173/_translate_proxy_stub
UNINSTALL_FORM_URL=http://localhost:4173/_uninstall_stub
SUBTURTLE_API_URL=http://localhost:4173
SUBTURTLE_DASHBOARD_URL=http://localhost:4173/_dashboard_stub
GOOGLE_OAUTH_CLIENT_ID=ci_e2e_stub_oauth_client
EOF

- name: Build extension
run: yarn build

- name: E2E tests (Playwright)
run: yarn test:e2e

- name: Upload Playwright report
# Run on both success and failure (anything except job cancel) so
# the HTML report is downloadable for green runs too. The
# hashFiles guard skips silently when typecheck or unit tests
# failed before Playwright produced any output.
if: ${{ !cancelled() && hashFiles('playwright-report/**') != '' }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

release:
name: Release
needs: verify
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
steps:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ dist
static/key-file.json
*.zip
.npmrc
/.claude
/.claude
/playwright-report
/test-results
140 changes: 127 additions & 13 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@
"dev": "webpack --watch",
"build": "NODE_ENV=production webpack --mode=production",
"zip": "cd dist && zip -r subturtle.zip . && mv subturtle.zip ../subturtle.zip",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"typecheck": "node scripts/typecheck.mjs",
"release": "semantic-release",
"release:dry": "semantic-release --dry-run --no-ci"
},
"devDependencies": {
"@egoist/tailwindcss-icons": "1.7.1",
"@iconify/json": "2.2.165",
"@pinia/testing": "^1",
"@playwright/test": "^1.49",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@types/chrome": "0.0.193",
"@types/mixpanel-browser": "2.38.0",
"@vitejs/plugin-vue": "^5",
"@vue/test-utils": "^2",
"copy-webpack-plugin": "11.0.0",
"css-loader": "6.7.1",
"dotenv-webpack": "8.0.1",
"happy-dom": "^15",
"json-loader": "0.5.7",
"postcss": "8.4.16",
"postcss-loader": "7.0.1",
Expand All @@ -32,6 +41,7 @@
"tailwindcss": "3",
"ts-loader": "9.5.1",
"typescript": "5.4.5",
"vitest": "^2",
"vue-loader": "17.4.2",
"vue-style-loader": "4.1.3",
"vue-template-compiler": "2.7.16",
Expand Down
34 changes: 34 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineConfig } from "@playwright/test";

// E2E config β€” runs only against the Playwright specs in tests/e2e/.
// Vitest is configured to exclude this directory so the two suites don't
// fight over file ownership.
//
// `dist/` must be present before tests run; the e2e suite asserts artifacts
// and loads the extension into Chromium. CI runs `yarn build` before
// `yarn test:e2e` (see CLAUDE.md release pipeline notes).
export default defineConfig({
testDir: "./tests/e2e",
testMatch: ["**/*.spec.ts"],
fullyParallel: false,
workers: 1,
// Always emit the HTML report so CI failures have an artifact we can
// upload and inspect (the verify workflow uploads playwright-report/
// when a step fails). The list reporter stays so terminal output
// remains readable.
reporter: [["list"], ["html", { open: "never" }]],

use: {
baseURL: "http://localhost:4173",
trace: "retain-on-failure",
},

webServer: {
command: "node tests/e2e/server.mjs",
url: "http://localhost:4173/",
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
timeout: 30_000,
},
});
60 changes: 60 additions & 0 deletions scripts/typecheck.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Wrapper around `tsc --noEmit` that filters out two classes of upstream
// errors we can't fix from this repo:
//
// 1. node_modules/pilotui/* β€” pilotui's package.json points `exports.types`
// at raw TS source, so tsc follows into pilotui/src/vue.ts which has a
// mismatched plugin signature against vue3-perfect-scrollbar.
//
// 2. ../dashboard-app/* β€” src/stores/profile.ts imports types from the
// sibling dashboard-app repo (see CLAUDE.md). dashboard-app's frontend
// types re-export from server-side TS that depends on mongoose / stripe
// / @modular-rest/server β€” installed in dashboard-app's own
// node_modules but not in ours. CI clones dashboard-app without
// installing its deps; locally maintainers usually have them. Either
// way, those errors aren't actionable from here.
//
// On clean runs we print only a short summary so GitHub's log parser
// doesn't surface the suppressed errors as red `Error:` annotations.
// Real errors in our own code still print the full tsc output and fail.
import { spawnSync } from "node:child_process";

const SUPPRESSED_PATH_FRAGMENTS = [
"node_modules/pilotui/",
"dashboard-app/",
];

const FILE_AT_ERROR = /([^\s:]+\.(?:ts|tsx|d\.ts|vue))\(\d+,\d+\):\s*error\s+TS\d+/;

function isSuppressed(line) {
const m = line.match(FILE_AT_ERROR);
if (!m) return false;
return SUPPRESSED_PATH_FRAGMENTS.some((frag) => m[1].includes(frag));
}

const r = spawnSync("npx", ["tsc", "--noEmit"], { encoding: "utf8" });
const output = (r.stdout || "") + (r.stderr || "");

const allErrorLines = output.split("\n").filter((l) => /error TS\d+/.test(l));
const realErrorLines = allErrorLines.filter((l) => !isSuppressed(l));
const suppressedCount = allErrorLines.length - realErrorLines.length;

if (realErrorLines.length > 0) {
// Print full tsc output so the user sees error context, then a summary.
console.error(output);
console.error(
`\n${realErrorLines.length} type error(s) in our code. Fix above.`
);
if (suppressedCount > 0) {
console.error(
`(${suppressedCount} additional error(s) suppressed from pilotui / dashboard-app β€” see scripts/typecheck.mjs.)`
);
}
process.exit(1);
}

const suffix =
suppressedCount > 0
? ` (${suppressedCount} upstream error(s) from pilotui / dashboard-app suppressed β€” see scripts/typecheck.mjs)`
: "";
console.log(`typecheck clean.${suffix}`);
process.exit(0);
10 changes: 6 additions & 4 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ function broadcastSettings(settings: SettingsObject) {
// Tabs without our content script (chrome://, web store, freshly
// installed pre-extension tabs, etc.) reject with "Receiving end does
// not exist". That's expected for a fire-and-forget broadcast.
chrome.tabs
.sendMessage(tab.id, {
// Cast: @types/chrome@0.0.193 still types tabs.sendMessage as void;
// in MV3 the no-callback overload returns a Promise.
(
chrome.tabs.sendMessage(tab.id, {
type: MESSAGE_TYPE.SYNC_SETTINGS,
settings,
})
.catch(() => {});
}) as unknown as Promise<unknown>
).catch(() => {});
}
});
}
Expand Down
11 changes: 9 additions & 2 deletions src/console-crane/route-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
// `btoa` only accepts Latin1 β€” any non-Latin1 character (e.g. accented Latin,
// Persian, Chinese, emoji) throws InvalidCharacterError. We encode via
// TextEncoder so route params can carry any text.
//
// Undefined is a legitimate input β€” `toggleConsoleCrane(page)` calls this
// without explicit params for routes like "empty" / "settings". We round-trip
// it as an empty string so JSON.parse never sees an empty payload.
export function encodeRouteParams(params: any): string {
const bytes = new TextEncoder().encode(JSON.stringify(params));
const json = JSON.stringify(params);
if (json === undefined) return "";
const bytes = new TextEncoder().encode(json);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}

export function decodeRouteParams<T = any>(data: string): T {
export function decodeRouteParams<T = any>(data: string): T | undefined {
if (data === "") return undefined;
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
Expand Down
11 changes: 8 additions & 3 deletions src/vue-shim.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// Vue 3 SFC ambient declaration. The previous shim used Vue 2's default
// export shape, which made TS infer the bare `vue` module namespace at every
// .vue import site β€” see the 7 errors in src/console-crane/router.ts and
// src/popup/router.ts.
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
Loading
Loading