Add ESLint gate, actions/redact unit tests; fix README release drift #9

Merged
ilia merged 1 commits from chore/eslint-and-readme-accuracy into main 2026-07-26 14:55:02 -05:00
8 changed files with 1407 additions and 6 deletions
+2
View File
@@ -40,6 +40,8 @@ jobs:
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test
- name: Build
+1 -1
View File
@@ -216,7 +216,7 @@ See `docs/OUTLINE.md`.
2. Update `CHANGELOG.md` with a `## X.Y.Z` section (the release job extracts this verbatim as release notes)
3. Tag `vX.Y.Z` and push
Pushing the tag triggers `.gitea/workflows/ci.yml`'s `release` job: it re-runs typecheck/test/build, verifies the tag matches `package.json`'s `version` and that `CHANGELOG.md` documents it, then creates a Gitea release (with the `npm pack` tarball attached) via the API using a repo-scoped `RELEASE_TOKEN` Actions secret. If any check fails, no release is created — fix and re-tag. Consumers still pin the git tag (`#vX.Y.Z`); the Gitea release is for visibility/changelog, not an npm registry publish (see ROADMAP "private Gitea npm registry").
Pushing the tag triggers `.gitea/workflows/ci.yml`'s `release` job: it re-runs typecheck/test/build, verifies the tag matches `package.json`'s `version` and that `CHANGELOG.md` documents it, then (a) creates a Gitea release with the `npm pack` tarball attached (via `RELEASE_TOKEN`) and (b) **publishes the package to the Gitea npm registry** (`https://git.levkin.ca/api/packages/ilia/npm/`, via `NPM_PUBLISH_TOKEN`, falling back to `RELEASE_TOKEN`). If any check fails, nothing is released or published — fix and re-tag. Consumers install from the registry (`npm install @levkin/playkit@X.Y.Z`, see `docs/NPM_REGISTRY.md`) or pin the git tag (`#vX.Y.Z`) as a fallback.
4. **Update Outline**`python3 scripts/outline-sync-playkit.py` (checklist: `docs/OUTLINE.md`).
+31
View File
@@ -0,0 +1,31 @@
// Flat ESLint config — lint the library source (`npx eslint src`).
// Typecheck stays separate (`npm run typecheck`); this catches what tsc doesn't.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', 'playwright-report/', 'test-results/', 'selftest/demo-site/'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
// Browser globals for code evaluated inside the page (persistentSession, richText).
...globals.browser,
},
},
rules: {
// The kit wraps Playwright objects whose shapes we don't own; `any` is
// still banned by default — allow unused args prefixed with `_`.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
],
},
},
);
+1145 -3
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -48,7 +48,7 @@
"test": "vitest run",
"test:watch": "vitest",
"selftest": "playwright test -c selftest/playwright.config.ts",
"lint": "tsc --noEmit",
"lint": "eslint src",
"prepublishOnly": "npm run build",
"example:api": "tsx examples/api/health.example.ts"
},
@@ -64,11 +64,15 @@
}
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "1.61.1",
"@types/node": "^22.15.0",
"eslint": "^10.8.0",
"globals": "^17.8.0",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"typescript-eslint": "^8.65.0",
"vitest": "^3.1.0"
},
"engines": {
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest';
import type { Locator, Page } from '@playwright/test';
import type { Logger } from '../logging/logger.js';
import {
BasePage,
assertPublicHost,
click,
fill,
safeGoto,
waitForUrlHost,
} from './actions.js';
const silentLogger: Logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
child: () => silentLogger,
};
function mockLocator(overrides: Partial<Record<'waitFor' | 'click' | 'fill', unknown>> = {}) {
return {
waitFor: vi.fn(async () => undefined),
click: vi.fn(async () => undefined),
fill: vi.fn(async () => undefined),
...overrides,
} as unknown as Locator;
}
describe('click', () => {
it('waits for visibility then clicks with the given options', async () => {
const locator = mockLocator();
await click(locator, { timeout: 5_000, force: true, logger: silentLogger });
expect(locator.waitFor).toHaveBeenCalledWith({ state: 'visible', timeout: 5_000 });
expect(locator.click).toHaveBeenCalledWith({ timeout: 5_000, force: true, trial: undefined });
});
it('retries after a failed attempt and succeeds', async () => {
const clickFn = vi
.fn()
.mockRejectedValueOnce(new Error('intercepted'))
.mockResolvedValueOnce(undefined);
const locator = mockLocator({ click: clickFn });
await click(locator, { retries: 1, logger: silentLogger });
expect(clickFn).toHaveBeenCalledTimes(2);
});
it('throws the last error once retries are exhausted', async () => {
const clickFn = vi.fn().mockRejectedValue(new Error('detached'));
const locator = mockLocator({ click: clickFn });
await expect(click(locator, { retries: 1, logger: silentLogger })).rejects.toThrow('detached');
expect(clickFn).toHaveBeenCalledTimes(2);
});
});
describe('fill', () => {
it('clears the field before filling by default', async () => {
const locator = mockLocator();
await fill(locator, 'hello', { logger: silentLogger });
const fillMock = locator.fill as ReturnType<typeof vi.fn>;
expect(fillMock).toHaveBeenCalledTimes(2);
expect(fillMock.mock.calls[0]).toEqual(['']);
expect(fillMock.mock.calls[1][0]).toBe('hello');
});
it('skips the clear step when clear: false', async () => {
const locator = mockLocator();
await fill(locator, 'hello', { clear: false, logger: silentLogger });
const fillMock = locator.fill as ReturnType<typeof vi.fn>;
expect(fillMock).toHaveBeenCalledTimes(1);
expect(fillMock.mock.calls[0][0]).toBe('hello');
});
});
describe('safeGoto', () => {
it('navigates with the configured waitUntil and retries transient failures', async () => {
const goto = vi
.fn()
.mockRejectedValueOnce(new Error('net::ERR_CONNECTION_RESET'))
.mockResolvedValueOnce(undefined);
const page = { goto } as unknown as Page;
await safeGoto(page, 'https://app.levkin.ca/login', {
retries: 1,
waitUntil: 'load',
logger: silentLogger,
});
expect(goto).toHaveBeenCalledTimes(2);
expect(goto).toHaveBeenLastCalledWith('https://app.levkin.ca/login', {
timeout: 60_000,
waitUntil: 'load',
});
});
});
describe('waitForUrlHost', () => {
it('resolves when the page URL is already on the expected host', async () => {
const page = { url: () => 'https://app.levkin.ca/dashboard' } as unknown as Page;
await expect(
waitForUrlHost(page, 'app.levkin.ca', { logger: silentLogger }),
).resolves.toBeUndefined();
});
it('throws a descriptive error naming both hosts when the host never matches', async () => {
const page = { url: () => 'http://10.0.10.45:3000/dashboard' } as unknown as Page;
await expect(
waitForUrlHost(page, 'app.levkin.ca', { timeout: 0, logger: silentLogger }),
).rejects.toThrow(/Expected URL host "app\.levkin\.ca" but got "10\.0\.10\.45"/);
});
});
describe('assertPublicHost', () => {
it('accepts a public URL and a bare public hostname', () => {
expect(() => assertPublicHost('https://punimtagdev.levkin.ca/login')).not.toThrow();
expect(() => assertPublicHost('punimtagdev.levkin.ca')).not.toThrow();
});
it.each(['https://10.0.10.45:3000', 'http://localhost:3000', 'https://192.168.1.10', '127.0.0.1'])(
'rejects private host %s',
(input) => {
expect(() => assertPublicHost(input)).toThrow(/Refusing private host/);
},
);
it('allows private hosts when forbidPrivate is false (intentional LAN runs)', () => {
expect(() => assertPublicHost('http://10.0.10.45:3000', false)).not.toThrow();
});
});
describe('BasePage', () => {
function makePage(url = 'https://app.levkin.ca/') {
const goto = vi.fn(async () => undefined);
const page = { goto, url: () => url } as unknown as Page;
return { page, goto };
}
it('open() joins base URL and path without doubling slashes', async () => {
const { page, goto } = makePage();
const basePage = new BasePage(page, 'https://app.levkin.ca/', silentLogger);
await basePage.open('/users');
expect(goto).toHaveBeenCalledWith('https://app.levkin.ca/users', expect.anything());
});
it('open() adds the missing leading slash for relative paths', async () => {
const { page, goto } = makePage();
const basePage = new BasePage(page, 'https://app.levkin.ca', silentLogger);
await basePage.open('users');
expect(goto).toHaveBeenCalledWith('https://app.levkin.ca/users', expect.anything());
});
it('open() passes absolute http(s) URLs through untouched', async () => {
const { page, goto } = makePage();
const basePage = new BasePage(page, 'https://app.levkin.ca', silentLogger);
await basePage.open('https://other.levkin.ca/health');
expect(goto).toHaveBeenCalledWith('https://other.levkin.ca/health', expect.anything());
});
it('click() and fill() delegate to the retried helpers', async () => {
const { page } = makePage();
const basePage = new BasePage(page, 'https://app.levkin.ca', silentLogger);
const locator = mockLocator();
await basePage.click(locator);
await basePage.fill(locator, 'value');
expect(locator.click).toHaveBeenCalledTimes(1);
expect(locator.fill).toHaveBeenCalledWith('value', { timeout: 30_000 });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { redactSecrets } from './redact.js';
describe('redactSecrets', () => {
it('redacts common secret keys while leaving other fields intact', () => {
const input = {
user: 'ilia',
password: 'hunter2',
apiKey: 'abc123',
api_key: 'abc123',
authorization: 'token xyz',
cookie: 'session=deadbeef',
count: 3,
};
expect(redactSecrets(input)).toEqual({
user: 'ilia',
password: '[REDACTED]',
apiKey: '[REDACTED]',
api_key: '[REDACTED]',
authorization: '[REDACTED]',
cookie: '[REDACTED]',
count: 3,
});
});
it('redacts nested objects and arrays', () => {
const input = {
requests: [
{ url: '/login', headers: { Authorization: 'Bearer abc.def' } },
{ url: '/users', headers: { Accept: 'application/json' } },
],
config: { db: { passWord: 'pg-secret' } },
};
const out = redactSecrets(input);
expect(out.requests[0].headers.Authorization).toBe('[REDACTED]');
expect(out.requests[1].headers.Accept).toBe('application/json');
expect(out.config.db.passWord).toBe('[REDACTED]');
expect(out.requests[0].url).toBe('/login');
});
it('redacts Bearer tokens embedded in free-text strings', () => {
expect(redactSecrets('request sent with Bearer fake.token-value_1 attached')).toBe(
'request sent with Bearer [REDACTED] attached',
);
});
it('leaves empty-string secret values alone (nothing to leak)', () => {
expect(redactSecrets({ token: '' })).toEqual({ token: '' });
});
it('passes through null, undefined, and primitives', () => {
expect(redactSecrets(null)).toBeNull();
expect(redactSecrets(undefined)).toBeUndefined();
expect(redactSecrets(42)).toBe(42);
expect(redactSecrets('no secrets here')).toBe('no secrets here');
});
});
-1
View File
@@ -4,7 +4,6 @@
import { createLogger, type Logger } from '../logging/logger.js';
import { MailpitClient, loadMailpitConfig } from './mailpit.js';
import { MailtrapClient, loadMailtrapConfig } from './mailtrap.js';
import { extractLinks, firstLinkMatching } from './mailtrap.js';
export type MailInbox = MailpitClient | MailtrapClient;