Enforce the one join path in lint; a drive that asserts nothing fails #64
4 changed files with 94 additions and 2 deletions
|
|
@ -1,3 +1,8 @@
|
|||
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
|
||||
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
|
||||
'no-restricted-syntax'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
|
|
@ -62,6 +67,21 @@ module.exports = {
|
|||
"@typescript-eslint/no-shadow": "error"
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
...airbnbRestrictedSyntax,
|
||||
{
|
||||
selector: "CallExpression[callee.property.name='joinRoom']",
|
||||
message:
|
||||
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts'],
|
||||
rules: {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
let asserted = 0;
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
asserted += 1;
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
|
||||
exports.done = () => {
|
||||
if (fails.length) return 4;
|
||||
return asserted === 0 ? 5 : 0;
|
||||
};
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
|
|
|
|||
35
harness/estate/check.test.ts
Normal file
35
harness/estate/check.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// @vitest-environment node
|
||||
import { createRequire } from 'module';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A drive that asserts nothing proves nothing: its exit code must say so.
|
||||
const fresh = () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const path = require.resolve('./check.cjs');
|
||||
delete require.cache[path];
|
||||
return require('./check.cjs') as {
|
||||
expect: (label: string, ok: boolean, detail?: string) => void;
|
||||
done: () => number;
|
||||
};
|
||||
};
|
||||
|
||||
describe('estate drive exit code', () => {
|
||||
it('is 5 when no assertion ran', () => {
|
||||
expect(fresh().done()).toBe(5);
|
||||
});
|
||||
|
||||
it('is 0 when assertions ran and all held', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
expect(c.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('is 4 when any assertion failed', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
c.expect('breaks', false, 'detail');
|
||||
expect(c.done()).toBe(4);
|
||||
});
|
||||
});
|
||||
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ESLint } from 'eslint';
|
||||
|
||||
// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join.
|
||||
// This pins that the lint config enforces it, not just that the code happens to comply today.
|
||||
const lint = async (filePath: string, code: string) => {
|
||||
const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true });
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax');
|
||||
};
|
||||
const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n';
|
||||
|
||||
describe('the one join path is enforced by lint', () => {
|
||||
it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => {
|
||||
const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].message).toContain('joinEstateRoom');
|
||||
});
|
||||
|
||||
it('allows the call inside joinEstateRoom.ts itself', async () => {
|
||||
expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the Airbnb syntax bans in the same files', async () => {
|
||||
const found = await lint(
|
||||
'src/app/foo.ts',
|
||||
'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n'
|
||||
);
|
||||
expect(found.some((m) => m.message.includes('for..in'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue