diff --git a/docs/guides/javascript/react/testing.md b/docs/guides/javascript/react/testing.md
index 302a79739..374c72a6f 100644
--- a/docs/guides/javascript/react/testing.md
+++ b/docs/guides/javascript/react/testing.md
@@ -43,34 +43,53 @@ npm test -- --coverage
Test files must match the glob `**/esm/tests/**/*.test.{ts,tsx}`. Place them alongside the source they test:
-```
-public
-└── lib
- └── js
- └── esm
- ├── src
- │ └── output
- │ └── ExampleComponent.tsx
- └── tests
- └── output
- └── ExampleComponent.test.ts
-```
+import FileTree from '@site/src/components/FileTree';
+
+
The same convention applies to plugin components:
-```
-public
-└── mod
- └── forum
- └── js
- └── esm
- ├── src
- │ └── output
- │ └── ExampleComponent.tsx
- └── tests
- └── output
- └── ExampleComponent.test.ts
-```
+
## Writing a test
@@ -88,7 +107,47 @@ describe('getString', () => {
});
```
-## Mocking AMD modules
+## Mocking
+
+Mocking of class and module dependencies of your unit under test is strongly encouraged. In some cases it is mandatory.
+
+Some functionality, such as the ability to mock strings and AMD modules, is provided as part of the Moodle core and these mocks are reset between tests.
+
+You should not need to clean mocks up manually. Each test starts with a fresh state.
+
+You can clear up any additional test state within your test file using:
+
+- `beforeEach()`
+- `afterEach()`
+
+See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information.
+
+### Mocking strings
+
+Because of the way in which Moodle's string module fetches strings using a web service, all strings are mocked to a standard value of:
+
+```
+[identifier, component]
+```
+
+For cases where your tests expect a specific string value, you can mock values using the global `mockString` method:
+
+```typescript
+describe('@moodle/lms/mod_example/Example', () => {
+ beforeEach(() => {
+ mockString('dofabuluousthings', 'more_example', 'Do something fabulous!!');
+ });
+ it('Renders an Example component', () => {
+ await act(async() => {
+ render();
+ });
+
+ expect(screen.getByText('Do something fabulous!!!')).toBeInTheDocument();
+ });
+});
+```
+
+### Mocking AMD modules
AMD modules (anything loaded via `requirejs`) **cannot** run inside Jest. The Jest module system and the AMD loader are completely separate environments, so `requirejs`, `M`, jQuery, and other Moodle globals are not available.
@@ -111,9 +170,9 @@ describe('my component', () => {
});
```
-:::note
+:::danger[Unmocked AMD modules]
-If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered via `mockAmdModule`, the test will throw:
+If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered using `mockAmdModule` then the test will throw an error:
```
Error: Unexpected call to requireAsync with module name: core/notification
@@ -123,37 +182,40 @@ This is intentional: missing mocks produce a hard failure rather than silent wro
:::
-### Registrations reset between tests
-
-Mocks and test fixtures, such as the AMD module map, and the string map, are cleared between each test using the `afterEach` notation.
-
-You do not need to clean up manually. Each test starts with a fresh state.
-
-You can clear up any additional test state within your test file using:
+## Handling redirects
-- `beforeEach()`
-- `afterEach()`
+If your code causes the page to redirect, then it must use the `@moodle/lms/core/location` module's `redirect` method, for example:
-See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information.
+```typescript
+import {redirect} from '@moodle/lms/core/location';
-## Mocking language strings
+export default function () {
+ redirect('https://example.com');
+}
+```
-`mockString(identifier, component, resolved)` registers a resolved value for a specific `(identifier, component)` pair. This delegates to the default `core/str` mock that is already registered in `.jest/globalSetup.ts`.
+Moodle automatically mocks the `redirect` function and allows you to specify the expected value before calling your method using the `expectRedirect()` helper:
```typescript
-mockString('submit', 'core', 'Submit');
-mockString('cancel', 'core', 'Cancel');
+import Example from '@moodle/lms/mod_example/Example';
+
+describe('@moodle/lms/mod_example/Example', () => {
+ it('Redirects to the user documentation', () => {
+ expectRedirect({urlContains: 'example.com'});
-await expect(getString('submit', 'core')).resolves.toBe('Submit');
+ Example();
+ });
+});
```
-For any string that was not registered, the default mock returns `[identifier, component]`:
+The `expectRedirect` method accepts:
-```typescript
-await expect(getString('other', 'core')).resolves.toBe('[other, core]');
-```
+- a `url` parameter with an exact matching URL; or
+- a `urlContains` parameter with a partial match.
+
+If a redirect occurs without an `expectRedirect()` call, an Error will be thrown.
-This default is useful for snapshot tests and assertions that only care whether a string key was requested, not its exact value.
+Expected redirects are reset between tests.
## Module path aliases
diff --git a/src/components/FileTree/index.tsx b/src/components/FileTree/index.tsx
new file mode 100644
index 000000000..4a240d774
--- /dev/null
+++ b/src/components/FileTree/index.tsx
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) Moodle Pty Ltd.
+ *
+ * Moodle is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Moodle is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Moodle. If not, see .
+ */
+
+import React from 'react';
+import CodeBlock from '@theme/CodeBlock';
+
+function parseObjectTree(tree: DirectoryStructure, prefix = ''): string {
+ const keys = Object.keys(tree);
+ let result = '';
+
+ keys.forEach((key, index) => {
+ const isLast = index === keys.length - 1;
+ const pointer = isLast ? '└── ' : '├── ';
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
+
+ const value = tree[key];
+
+ let comment = '';
+ let children: DirectoryStructure | undefined;
+
+ // Determine the format at runtime
+ if (value !== null && typeof value === 'object') {
+ if ('_comment' in value || '_children' in value) {
+ // It's using the expanded object format
+ const expanded = value as ExpandedItem;
+ comment = expanded._comment ? ` # ${expanded._comment}` : ''; // eslint-disable-line no-underscore-dangle
+ children = expanded._children; // eslint-disable-line no-underscore-dangle
+ } else {
+ // It's using the simple nested directory format
+ children = value as DirectoryStructure;
+ }
+ } else if (typeof value === 'string') {
+ // Inline shorthand string comment support: "filename": "comment string"
+ comment = ` # ${value}`;
+ }
+
+ // 1. Append the node line
+ result += `${prefix}${pointer}${key}${comment}\n`;
+
+ // 2. Recurse into children if they exist
+ if (children) {
+ result += parseObjectTree(children, nextPrefix);
+ }
+ });
+
+ return result;
+}
+
+type FileValue = string | null;
+
+export interface ExpandedItem {
+ _comment?: string;
+ _children?: DirectoryStructure;
+}
+
+// A node can now be a primitive file, a simple nested directory, or an expanded item
+export type TreeItem = FileValue | DirectoryStructure | ExpandedItem;
+
+export interface DirectoryStructure {
+ [nodeName: string]: TreeItem;
+}
+
+export type FileTreeProps = {
+ structure: DirectoryStructure;
+};
+
+export default function FileTree({ structure }: FileTreeProps): JSX.Element {
+ return (
+
+ {parseObjectTree(structure)}
+
+ );
+}