Add ESLint gate, actions/redact unit tests; fix README release drift #9
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1145
-3
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user