Skip to content
Merged
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
40 changes: 26 additions & 14 deletions packages/autodoc/src/parsers/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>init only extension example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych({
extensions: [
{type: jsPsychTestExtension}
]
});

const block = {
type: jsPsychTestPlugin,
stimulus: "Hello"
};

jsPsych.run([block]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<title>mixed detection example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const trial = {
type: jsPsychTestPlugin,
stimulus: "Hello"
};

const myBlock = {
type: jsPsychTestPlugin,
stimulus: "World",
extensions: [{type: jsPsychTestExtension}]
};

jsPsych.run([trial, myBlock]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>no extension example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const block = {
type: jsPsychTestPlugin,
stimulus: "Hello"
};

jsPsych.run([block]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>non trial name example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const myBlock = {
type: jsPsychTestPlugin,
stimulus: "Hello",
extensions: [
{type: jsPsychTestExtension, params: {test: "block"}}
]
};

jsPsych.run([myBlock]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<!-- jspsych-autodoc:title sentinel bypass example -->
</head>
<body>
<script>
// This file has no initJsPsych call, which would cause inferCodeBlock to throw.
// But since start/end sentinels are present, inferCodeBlock is never called.

// jspsych-autodoc:start
const trial = {
type: jsPsychTestPlugin,
stimulus: "test",
extensions: [{type: jsPsychTestExtension}]
};
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>trial name no extension example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const stimulus = "Hello, world!";

const trial = {
type: jsPsychTestPlugin,
stimulus: stimulus
};

jsPsych.run([trial]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>trial name non object example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const trial = "experiment stimulus text";

jsPsych.run([]);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>trial name example</title>
</head>
<body>
<script>
const jsPsych = initJsPsych();

const trial = {
type: jsPsychTestPlugin,
stimulus: "Hello",
extensions: [
{type: jsPsychTestExtension, params: {test: "named trial"}}
]
};

jsPsych.run([trial]);
</script>
</body>
</html>
84 changes: 84 additions & 0 deletions packages/autodoc/tests/parsers/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading