Skip to content
Draft
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
12 changes: 12 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
wb-mqtt-homeui (2.250.0) stable; urgency=medium

* Rules editor: imports are typed against the controller's own module
files. The language service resolves a rule's import specifiers through
the engine (Editor.ResolveModule RPC) - relative ones next to the file,
bare ones from the module directories - fetches the modules (transitively,
bounded) into its virtual file system and re-lints when an import added
while typing arrives, so exported types, signatures and completions of
imported modules are real instead of `any`; firmware without the RPC keeps
the wildcard fallback. Compiler options follow the engine: module
"preserve", allowImportingTsExtensions, a paths map for bare specifiers.
wb-rules.d.ts re-synced (import.meta). The explicit-format rule file
extensions the engine now loads (.mjs/.mts ES modules, .cjs/.cts classic
scripts) are editable: language mode, rename and copy keep them.
* Rules editor: TypeScript support. .ts rule files can be created and
edited with an in-browser TypeScript language service: live error
squiggles while typing, hover type info and type-aware completions,
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/pages/rules/[rule]/edit-rule-resolve-module.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
// The import resolver handed to the language service: it goes through
// Editor.ResolveModule only when the controller advertises the method, and
// the advertisement check never delays the service itself (a negative
// answer takes the full advertisement timeout - firmware with GetTypes but
// without ResolveModule is the firmware in the field).
import { act, render, waitFor } from '@testing-library/react';
import EditRulePage from './edit-rule';

const { rulesMock, paramsMock, getExtensionsMock, loadTsSupportMock } = vi.hoisted(() => ({
rulesMock: {
rule: {
name: 'test-rule.js',
initName: 'test-rule.js',
content: 'import { x } from "mod";',
enabled: true,
error: null as any,
},
load: vi.fn(async () => {}),
save: vi.fn(async () => 'test-rule.js'),
rename: vi.fn(async () => 'renamed.js'),
resetRule: vi.fn(),
setRule: vi.fn(),
setRuleName: vi.fn(),
checkIsNameUnique: vi.fn(async () => true),
tsCheckDiags: [],
checkTsFile: vi.fn(async () => {}),
clearTsCheck: vi.fn(),
},
paramsMock: { '*': 'test-rule.js' } as Record<string, string | undefined>,
getExtensionsMock: vi.fn(() => [] as any[]),
loadTsSupportMock: vi.fn(),
}));

vi.mock('@/services', () => import('@/test/mocks/services'));
vi.mock('@/stores/rules', () => ({ rulesStore: rulesMock }));
vi.mock('@/stores/rules/autocomplete', () => ({ getExtensions: getExtensionsMock }));
vi.mock('@/stores/rules/autocomplete/ts-language-service', () => ({
loadTsEditorSupport: loadTsSupportMock,
}));
vi.mock('@/stores/auth', () => ({
authStore: { hasRights: vi.fn(() => true) },
UserRole: { Admin: 'admin' },
}));
vi.mock('@/stores/devices', () => ({ devicesStore: {} }));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<any>('react-router-dom');
return {
...actual,
useParams: () => paramsMock,
useNavigate: () => vi.fn(),
};
});
vi.mock('@/common/links', () => ({
documentation: { en: { rule: '#rule-docs' } },
}));
vi.mock('@/utils/prevent-page-leave', () => ({
usePreventLeavePage: () => ({ setIsDirty: vi.fn() }),
}));
vi.mock('@/components/button', () => ({
Button: ({ label, onClick }: any) => <button onClick={onClick}>{label}</button>,
}));
vi.mock('@/components/code-editor', () => ({
CodeEditor: () => <div data-testid="code-editor" />,
}));
vi.mock('@/components/tag', () => ({
Tag: ({ children }: any) => <span>{children}</span>,
}));
vi.mock('@/layouts/page', () => ({
PageLayout: ({ children, actions }: any) => <div>{actions}{children}</div>,
}));

const { editorProxyMock } = await import('@/test/mocks/services');

// the resolver argument of the last loadTsEditorSupport call
const lastResolver = () => loadTsSupportMock.mock.calls.at(-1)![4] as (f: string, s: string) => Promise<unknown>;

beforeEach(() => {
vi.clearAllMocks();
paramsMock['*'] = 'test-rule.js';
rulesMock.load.mockResolvedValue(undefined);
getExtensionsMock.mockReturnValue([]);
editorProxyMock.GetTypes.mockResolvedValue({ content: 'declare const t: 1;' });
loadTsSupportMock.mockResolvedValue({
extensions: [],
completionSource: () => null,
getDiagnostics: () => [],
reseed: () => {},
refreshImports: async () => false,
});
});

describe('the import resolver passed to the language service', () => {
test('GetTypes without ResolveModule: the service builds without waiting, the resolver answers null', async () => {
let answerResolveModule: (has: boolean) => void = () => {};
editorProxyMock.hasMethod.mockImplementation((m?: string) => (m === 'ResolveModule'
? new Promise<boolean>((resolve) => {
answerResolveModule = resolve;
})
: Promise.resolve(true)));
render(<EditRulePage />);
// the service is built while the ResolveModule advertisement is still unanswered
await waitFor(() => expect(loadTsSupportMock).toHaveBeenCalled());
const pending = lastResolver()('test-rule.js', 'mod');
answerResolveModule(false);
expect(await pending).toBeNull();
expect(editorProxyMock.ResolveModule).not.toHaveBeenCalled();
});

test('advertising firmware: the resolver calls Editor.ResolveModule and maps a failure to null', async () => {
editorProxyMock.hasMethod.mockResolvedValue(true);
editorProxyMock.ResolveModule.mockResolvedValueOnce({ path: '/etc/wb-rules-modules/mod.js', content: 'export {}' });
render(<EditRulePage />);
await waitFor(() => expect(loadTsSupportMock).toHaveBeenCalled());
await act(async () => {});
expect(await lastResolver()('test-rule.js', 'mod'))
.toEqual({ path: '/etc/wb-rules-modules/mod.js', content: 'export {}' });
expect(editorProxyMock.ResolveModule).toHaveBeenCalledWith({ from: 'test-rule.js', specifier: 'mod' });
editorProxyMock.ResolveModule.mockRejectedValueOnce({ code: 1003, message: 'cannot find module' });
expect(await lastResolver()('test-rule.js', 'nope')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('language service gating on Editor.GetTypes', () => {
'defineRule("test", {})',
'declare const controllerTypes: 1;',
expect.any(String),
expect.any(Function), // the import resolver (Editor.ResolveModule)
);
});

Expand All @@ -115,6 +116,7 @@ describe('language service gating on Editor.GetTypes', () => {
'defineRule("test", {})',
undefined,
expect.any(String),
expect.any(Function),
);
});

Expand Down
18 changes: 16 additions & 2 deletions frontend/src/pages/rules/[rule]/edit-rule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { controllerDiagnostics } from '@/stores/rules/autocomplete/controller-di
import { loadErrorDiagnostics } from '@/stores/rules/autocomplete/load-error';
import { buildControlsRegistry } from '@/stores/rules/autocomplete/registry';
import { runtimeErrorDiagnostics } from '@/stores/rules/autocomplete/runtime-errors';
import { TS_RULE_FILE_EXTENSION_RX } from '@/stores/rules/rule-file-extension';
import { useAsyncAction } from '@/utils/async-action';
import { usePreventLeavePage } from '@/utils/prevent-page-leave';
import './styles.css';
Expand All @@ -38,7 +39,7 @@ const EditRulePage = observer(() => {
const [problems, setProblems] = useState<DiagnosticCounts>({ errors: 0, warnings: 0, total: 0 });
const editorViewRef = useRef<EditorView | null>(null);
const ruleFileName = params['*'] || rule.name || '';
const isTypeScript = ruleFileName.endsWith('.ts');
const isTypeScript = TS_RULE_FILE_EXTENSION_RX.test(ruleFileName);
const [tsSupport, setTsSupport] = useState<TsEditorSupport | null>(null);
// a stable placeholder for an unsaved rule, so typing a title does not rebuild the service
const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js');
Expand All @@ -65,6 +66,19 @@ const EditRulePage = observer(() => {
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 5000)),
]);
const registryDts = buildControlsRegistry(devicesStore);
// Imports are typed against the controller's own module files
// (Editor.ResolveModule); firmware without the method keeps the
// wildcard `any` for every import. The advertisement check is folded
// into the resolver rather than awaited up front: a negative answer
// takes the full advertisement timeout, which must not delay the
// language service of a file with no imports (the prefetch's own
// deadline bounds it for a file with some).
const hasResolveModule = Promise.resolve()
.then(() => editorProxy.hasMethod('ResolveModule'))
.catch(() => true);
const resolveModule = (from: string, specifier: string) => hasResolveModule.then((has) => (has
? editorProxy.ResolveModule({ from, specifier }).then((r) => r ?? null, () => null)
: null));
Promise.all([
// the heavy TS chunk loads concurrently with the GetTypes reply
hasGetTypes.then((has) => (has
Expand All @@ -73,7 +87,7 @@ const EditRulePage = observer(() => {
controllerTypes,
])
.then(([m, typesDts]) => (m && typesDts !== null
? m.loadTsEditorSupport(servicePath, rule.content, typesDts, registryDts)
? m.loadTsEditorSupport(servicePath, rule.content, typesDts, registryDts, resolveModule)
: null))
.then(
(support) => alive && setTsSupport(support),
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/services/editor-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ interface EditorProxyMethods {
Rename: (params: { path: string; new_path: string }) => Promise<void>;
Check: (params: { path: string }) => Promise<TsCheckResult>;
GetTypes: () => Promise<{ content: string }>;
// the engine's own import resolution: `from` is a rule's virtual path or
// a module's absolute path, the reply the resolved module's path + source
ResolveModule: (params: { from: string; specifier: string }) => Promise<{ path: string; content: string }>;
}

export const editorProxy = createRpcProxy<EditorProxyMethods>(
'wbrules/Editor',
['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes'],
['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes', 'ResolveModule'],
);
102 changes: 102 additions & 0 deletions frontend/src/stores/rules/autocomplete/import-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { linter } from '@codemirror/lint';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { importRefreshPlugin } from './import-refresh';
import { lintRefresher } from './lint-refresh';

// The plugin that keeps imports typed while typing: after a document change
// (debounced) it asks refreshImports for the current text; when a module
// arrived it re-runs the lint pass from the outside (a lint source with the
// refresher's needsRefresh). It runs once on view creation too.

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function until(cond: () => boolean, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cond()) return;
await wait(25);
}
throw new Error('condition not met in time');
}

function makeView(
refresh: (source: string) => Promise<boolean>,
enabled: boolean,
onLint: () => void,
) {
const refresher = lintRefresher();
return new EditorView({
state: EditorState.create({
doc: 'log(1);',
extensions: [
linter(() => {
onLint();
return [];
}, { delay: 50, needsRefresh: refresher.needsRefresh }),
importRefreshPlugin(refresh, refresher, enabled),
],
}),
parent: document.body,
});
}

describe('importRefreshPlugin', () => {
it('runs once on creation, then after a debounced edit, and re-lints only when a module arrived', async () => {
const asked: string[] = [];
let lints = 0;
const view = makeView(async (src) => {
asked.push(src);
return src.includes('import');
}, true, () => {
lints++;
});
try {
// creation: one refresh with the initial text, nothing arrived - the
// lint pass count stays at the initial pass
await until(() => asked.length === 1);
expect(asked[0]).toBe('log(1);');
await wait(300);
const lintsAfterInit = lints;
// typing: one debounced refresh for the final text, not one per keystroke
view.dispatch({ changes: { from: 0, insert: 'import' } });
await wait(100);
view.dispatch({ changes: { from: 6, insert: ' "m";\n' } });
await until(() => asked.length === 2);
expect(asked[1]).toBe('import "m";\nlog(1);');
// a module arrived: a lint pass runs without a further document change
await until(() => lints > lintsAfterInit + 1);
} finally {
view.destroy();
}
});

it('does nothing without a resolver (legacy firmware)', async () => {
const refresh = vi.fn(async () => true);
const view = makeView(refresh, false, () => {});
try {
view.dispatch({ changes: { from: 0, insert: 'import "m";' } });
await wait(700);
expect(refresh).not.toHaveBeenCalled();
} finally {
view.destroy();
}
});

it('a refresh landing after the view is destroyed does not touch it', async () => {
let settle: (v: boolean) => void = () => {};
let lints = 0;
const view = makeView(() => new Promise<boolean>((resolve) => {
settle = resolve;
}), true, () => {
lints++;
});
await wait(600); // the creation refresh is in flight
view.destroy();
const lintsAtDestroy = lints;
settle(true);
await wait(300);
expect(lints).toBe(lintsAtDestroy);
});
});
46 changes: 46 additions & 0 deletions frontend/src/stores/rules/autocomplete/import-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ViewPlugin, type EditorView } from '@codemirror/view';
import type { LintRefresher } from './types';

// Imports typed while you type. A specifier added in the editor is fetched
// from the controller (debounced) and the module dropped into the language
// service's environment by `refreshImports`; the lint pass is then re-run
// from the outside (see lint-refresh.ts), since the fetch completes after
// the keystroke's own pass. The same runs once when a view opens on a
// reused environment, so a rule reopened with new imports on disk catches
// up. `enabled` is false without a resolver (legacy firmware): the plugin
// then does nothing.
export function importRefreshPlugin(
refreshImports: (source: string) => Promise<boolean>,
refresher: LintRefresher,
enabled: boolean,
) {
return ViewPlugin.define((view: EditorView) => {
let timer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const run = () => {
timer = null;
refreshImports(view.state.doc.toString()).then(
(changed) => {
// never synchronously inside an update; and not after the view is
// gone (the page navigated away while the RPC was in flight)
if (changed && !disposed) setTimeout(() => !disposed && refresher.refresh(view), 0);
},
() => {},
);
};
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(run, 400);
};
if (enabled) schedule();
return {
update: (update) => {
if (update.docChanged && enabled) schedule();
},
destroy: () => {
disposed = true;
if (timer) clearTimeout(timer);
},
};
});
}
Loading