diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 4e286db..ed63b3e 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -75,9 +75,10 @@ export function getExtensionInfo( /** * Fallback code block extractor for HTML extension example files without sentinels. Requires * exactly one inline script block. Extracts the `initJsPsych` call and all trial variables - * (camel/snake case) whose object literal contains an `extensions` field. For each matched - * trial, its direct local dependencies (one level of indirection) are also included. - * The initJsPsych variable itself is never treated as a dependency. + * (any variable name that matches the "trial" pattern in camel/snake case) OR whose object + * literal contains an `extensions` field. For each matched trial, its direct local + * dependencies (one level of indirection) are also included. The initJsPsych variable itself + * is never treated as a dependency. */ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; @@ -97,8 +98,24 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; let initStatement: ts.VariableStatement | undefined; let initJsPsychVarName: string | undefined; + let initJsPsychHasExtensions = false; const trialNodes: ts.VariableDeclaration[] = []; + function hasExtensionsProperty(node: ts.Node): boolean { + if (ts.isCallExpression(node)) return false; + if ( + ts.isObjectLiteralExpression(node) && + node.properties.some( + (p) => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === "extensions", + ) + ) + return true; + return ts.forEachChild(node, hasExtensionsProperty) ?? false; + } + function visitNodes(node: ts.Node) { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { const init = node.initializer; @@ -113,17 +130,10 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (ts.isVariableStatement(stmt)) { initStatement = stmt; initJsPsychVarName = node.name.text; + initJsPsychHasExtensions = init.arguments.some(hasExtensionsProperty); } - } - - if (trialPattern.test(node.name.text) && init && ts.isObjectLiteralExpression(init)) { - const hasExtensions = init.properties.some( - (p) => - ts.isPropertyAssignment(p) && - ts.isIdentifier(p.name) && - p.name.text === "extensions", - ); - if (hasExtensions) trialNodes.push(node); + } else if (init && (trialPattern.test(node.name.text) || hasExtensionsProperty(init))) { + trialNodes.push(node); } } ts.forEachChild(node, visitNodes); @@ -137,7 +147,9 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (trialNodes.length === 0) throw new Error( - `${sourcePath}: no trial variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, + initJsPsychHasExtensions + ? `${sourcePath}: extension found in initJsPsych but no trial variables found that use the extension — use jspsych-autodoc:start/end sentinels instead` + : `${sourcePath}: no variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, ); // build map of all local variable declarations, excluding trial nodes themselves diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html new file mode 100644 index 0000000..52b5320 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html @@ -0,0 +1,22 @@ + + + + init only extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html new file mode 100644 index 0000000..d544400 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html @@ -0,0 +1,24 @@ + + + + mixed detection example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html new file mode 100644 index 0000000..573ad97 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html @@ -0,0 +1,18 @@ + + + + no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html new file mode 100644 index 0000000..bf221a6 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + non trial name example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html new file mode 100644 index 0000000..54093a4 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html new file mode 100644 index 0000000..0e1c836 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html @@ -0,0 +1,20 @@ + + + + trial name no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html new file mode 100644 index 0000000..7a213c0 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html @@ -0,0 +1,15 @@ + + + + trial name non object example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html new file mode 100644 index 0000000..1a6c2a1 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + trial name example + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 2230122..6916dd4 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -93,6 +93,90 @@ describe('getExtensionInfo', () => { }); }); +describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => { + const inferTestsDir = path.resolve(__dirname, '../fixtures/extension/infer-tests'); + let classNode: ts.ClassDeclaration; + + beforeAll(() => { + classNode = identifyPackageType(fixtureSource).mainNode as ts.ClassDeclaration; + }); + + it('throws when no extension is found anywhere and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'no-trial-name-or-extension.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('no variables with an "extensions" field found'); + }); + + it('throws when extension is found only in initJsPsych and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'extension-in-init-only.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('extension found in initJsPsych but no trial variables found that use the extension'); + }); + + it('succeeds when trial object has extension and name does not match trial name regex', () => { + const filePath = path.join(inferTestsDir, 'non-trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['non trial name example']).toBeDefined(); + const code = info.examples['non trial name example'].code; + expect(code).toContain('myBlock'); + expect(code).toContain('initJsPsych'); + }); + + it('succeeds when trial object has extension and uses trial name regex and does not duplicate', () => { + const filePath = path.join(inferTestsDir, 'trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name example']).toBeDefined(); + const code = info.examples['trial name example'].code; + expect(code).toContain('const trial'); + expect((code.match(/const trial\b/g) ?? []).length).toBe(1); + }); + + it('succeeds when trial name matches regex but trial object has no extensions property', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name no extension example']).toBeDefined(); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('initJsPsych'); + }); + + it('collects local dependencies of a trial found by name (no extensions property)', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const stimulus'); + expect(code.indexOf('const stimulus')).toBeLessThan(code.indexOf('const trial')); + }); + + it('includes variables detected by both name and extensions in the same example', () => { + const filePath = path.join(inferTestsDir, 'mixed-detection.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['mixed detection example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('const myBlock'); + }); + + it('includes a trial-named variable even when its initializer is not an object', () => { + const filePath = path.join(inferTestsDir, 'trial-name-non-object.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['trial name non object example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('"experiment stimulus text"'); + }); + + it('does not throw when start/end sentinels are used even if inferCodeBlock would fail', () => { + const filePath = path.join(inferTestsDir, 'sentinel-bypass.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['sentinel bypass example']).toBeDefined(); + }); +}); + describe('getExtensionInfoAndExamples', () => { const examplesDir = path.resolve(__dirname, '../fixtures/extension/examples');