testLib.Validator.read_write_prop (property)
+ "testLib.Validator.read_write_prop (property)
The read-write property.
diff --git a/src/documentation/common/DocumentationContent.test.tsx b/src/documentation/common/DocumentationContent.test.tsx
index 719c62e19..cbacaf281 100644
--- a/src/documentation/common/DocumentationContent.test.tsx
+++ b/src/documentation/common/DocumentationContent.test.tsx
@@ -3,16 +3,16 @@
*
* SPDX-License-Identifier: MIT
*
- * @jest-environment ./src/testing/custom-browser-env
+ * @vitest-environment jsdom
*/
import { ImageProps } from "@chakra-ui/react";
import { render } from "@testing-library/react";
+import { vi } from "vitest";
import { PortableText } from "../../common/sanity";
import DocumentationContent from "./DocumentationContent";
-jest.mock("@chakra-ui/image", () => ({
+vi.mock("@chakra-ui/image", () => ({
Image: ({ src, w, h }: ImageProps) => (
- // eslint-disable-next-line jsx-a11y/alt-text
),
}));
@@ -49,7 +49,7 @@ describe("DocumentationContent", () => {
];
const view = render(
);
expect(view.container.innerHTML).toMatchInlineSnapshot(
- `"
"`
+ `"
"`
);
});
@@ -67,7 +67,7 @@ describe("DocumentationContent", () => {
const view = render(
);
// This relies on the mock above because Chakra UI's images have the src added later.
expect(view.container.innerHTML).toMatchInlineSnapshot(
- `"
"`
+ `"
"`
);
});
});
diff --git a/src/documentation/common/DocumentationTopLevelItem.tsx b/src/documentation/common/DocumentationTopLevelItem.tsx
index 5926744fd..d6c4f945c 100644
--- a/src/documentation/common/DocumentationTopLevelItem.tsx
+++ b/src/documentation/common/DocumentationTopLevelItem.tsx
@@ -41,8 +41,8 @@ const DocumentationTopLevelItem = ({
type,
}: DocumentationTopLevelItemProps) => {
const intl = useIntl();
- const [isShortWindow] = useMediaQuery(heightMd);
- const [isWideScreen] = useMediaQuery(widthXl);
+ const [isShortWindow] = useMediaQuery(heightMd, { ssr: false });
+ const [isWideScreen] = useMediaQuery(widthXl, { ssr: false });
return (
{
- const [isShortWindow] = useMediaQuery(heightMd);
- const [isWideScreen] = useMediaQuery(widthXl);
+ const [isShortWindow] = useMediaQuery(heightMd, { ssr: false });
+ const [isWideScreen] = useMediaQuery(widthXl, { ssr: false });
const my =
type === "reference"
? isShortWindow || !isWideScreen
diff --git a/src/documentation/common/collapse-util.test.ts b/src/documentation/common/collapse-util.test.ts
index 9b5324dc2..62f1b8aec 100644
--- a/src/documentation/common/collapse-util.test.ts
+++ b/src/documentation/common/collapse-util.test.ts
@@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: MIT
*
- * @jest-environment ./src/testing/custom-browser-env
+ * @vitest-environment jsdom
*/
import { PortableText } from "../../common/sanity";
import { decorateWithCollapseNodes } from "./collapse-util";
diff --git a/src/documentation/documentation-hooks.tsx b/src/documentation/documentation-hooks.tsx
index e6807516e..03071dd83 100644
--- a/src/documentation/documentation-hooks.tsx
+++ b/src/documentation/documentation-hooks.tsx
@@ -13,7 +13,7 @@ import {
useRef,
useState,
} from "react";
-import { apiDocs, ApiDocsResponse } from "../language-server/apidocs";
+import { apiDocs, ApiDocsContent } from "../language-server/apidocs";
import { useLanguageServerClient } from "../language-server/language-server-hooks";
import { useLogging } from "../logging/logging-hooks";
import { useSettings } from "../settings/settings";
@@ -26,7 +26,7 @@ import { fetchReferenceToolkit } from "./reference/content";
import { Toolkit } from "./reference/model";
export type ContentState =
- | { status: "ok"; content: T }
+ | { status: "ok"; content: T; languageId: string }
| { status: "error" }
| { status: "loading" };
@@ -44,7 +44,7 @@ const useContent = (
try {
const content = await fetchContent(languageId);
if (!ignore) {
- setState({ status: "ok", content });
+ setState({ status: "ok", content, languageId });
}
} catch (e) {
logging.error(e);
@@ -63,9 +63,9 @@ const useContent = (
return state;
};
-const useApiDocumentation = (): ApiDocsResponse | undefined => {
+const useApiDocumentation = (): ApiDocsContent | undefined => {
const client = useLanguageServerClient();
- const [apidocs, setApiDocs] = useState();
+ const [apidocs, setApiDocs] = useState();
useEffect(() => {
let ignore = false;
const load = async () => {
@@ -86,7 +86,7 @@ const useApiDocumentation = (): ApiDocsResponse | undefined => {
};
export interface DocumentationContextValue {
- api: ApiDocsResponse | undefined;
+ api: ApiDocsContent | undefined;
ideas: ContentState;
reference: ContentState;
apiReferenceMap: ContentState;
diff --git a/src/documentation/reference/Highlight.tsx b/src/documentation/reference/Highlight.tsx
index 5a321d42d..2c5a369b8 100644
--- a/src/documentation/reference/Highlight.tsx
+++ b/src/documentation/reference/Highlight.tsx
@@ -17,7 +17,7 @@ import { useScrollablePanelAncestor } from "../../common/ScrollablePanel";
interface HighlightProps extends BoxProps {
anchor?: Anchor;
id: string;
- active: Boolean | undefined;
+ active: boolean | undefined;
disclosure: UseDisclosureReturn;
}
diff --git a/src/documentation/search/SearchDialog.tsx b/src/documentation/search/SearchDialog.tsx
index e2c3d132a..682f2aff9 100644
--- a/src/documentation/search/SearchDialog.tsx
+++ b/src/documentation/search/SearchDialog.tsx
@@ -51,10 +51,9 @@ const SearchDialog = ({
- }
- />
+
+
+
void> = [];
- constructor() {
- this.worker = new Worker(new URL("./search.worker.ts", import.meta.url));
+ constructor(public language: string) {
+ this.worker = workerForLanguage(language);
}
index(reference: Toolkit, api: ApiDocsResponse) {
const message: IndexMessage = {
@@ -48,4 +47,55 @@ export class WorkerSearch implements Search {
});
return promise;
}
+
+ dispose() {
+ // We just ask nicely so it can respond to any in flight requests
+ this.worker.postMessage({
+ kind: "shutdown",
+ });
+ }
}
+
+const workerForLanguage = (language: string) => {
+ // See also convertLangToLunrParam
+
+ // Enumerated for code splitting as Vite doesn't support dynamic strings here
+ // We use a worker per language because Vite doesn't support using dynamic
+ // import in a iife Worker and Safari doesn't support module workers.
+ switch (language.toLowerCase()) {
+ case "de": {
+ return new Worker(new URL(`./search.worker.de.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ case "fr": {
+ return new Worker(new URL(`./search.worker.fr.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ case "es-es": {
+ return new Worker(new URL(`./search.worker.es.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ case "ja": {
+ return new Worker(new URL(`./search.worker.ja.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ case "ko": {
+ return new Worker(new URL(`./search.worker.ko.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ case "nl": {
+ return new Worker(new URL(`./search.worker.nl.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+ default:
+ return new Worker(new URL(`./search.worker.en.ts`, import.meta.url), {
+ type: "module",
+ });
+ }
+};
diff --git a/src/documentation/search/search-hooks.tsx b/src/documentation/search/search-hooks.tsx
index 802881c6c..d482c44cc 100644
--- a/src/documentation/search/search-hooks.tsx
+++ b/src/documentation/search/search-hooks.tsx
@@ -10,17 +10,16 @@ import {
useContext,
useEffect,
useMemo,
+ useRef,
useState,
} from "react";
import useIsUnmounted from "../../common/use-is-unmounted";
import { useLogging } from "../../logging/logging-hooks";
import { useSettings } from "../../settings/settings";
import { useDocumentation } from "../documentation-hooks";
-import { Search, SearchResults } from "./common";
+import { SearchResults } from "./common";
import { WorkerSearch } from "./search-client";
-const search: Search = new WorkerSearch();
-
type UseSearch = {
results: SearchResults | undefined;
query: string;
@@ -43,19 +42,34 @@ const SearchProvider = ({ children }: { children: ReactNode }) => {
const [results, setResults] = useState();
const isUnmounted = useIsUnmounted();
const logging = useLogging();
+ const [{ languageId }] = useSettings();
+ const search = useRef();
+
useEffect(() => {
- // Wait for both, no reason to index with just one then redo with both.
- if (reference.status === "ok" && api) {
- search.index(reference.content, api);
+ if (languageId !== search.current?.language) {
+ search.current?.dispose();
+ search.current = new WorkerSearch(languageId);
+ setQuery("");
+ }
+ // Wait for everything to be loaded and in the right language
+ if (
+ reference.status === "ok" &&
+ reference.languageId === languageId &&
+ api?.languageId === languageId
+ ) {
+ search.current.index(reference.content, api.content);
}
- }, [reference, api]);
+ }, [languageId, reference, api]);
const debouncedSearch = useMemo(
() =>
debounce(async (newQuery: string) => {
+ if (!search.current) {
+ return;
+ }
const trimmedQuery = newQuery.trim();
if (trimmedQuery) {
- const results = await search.search(trimmedQuery);
+ const results = await search.current.search(trimmedQuery);
if (!isUnmounted()) {
setResults((prevResults) => {
if (!prevResults) {
@@ -71,11 +85,6 @@ const SearchProvider = ({ children }: { children: ReactNode }) => {
[setResults, isUnmounted, logging]
);
- const [{ languageId }] = useSettings();
- useEffect(() => {
- setQuery("");
- }, [languageId]);
-
useEffect(() => {
debouncedSearch(query);
}, [debouncedSearch, query]);
diff --git a/src/documentation/search/search.ts b/src/documentation/search/search.ts
deleted file mode 100644
index 019faa558..000000000
--- a/src/documentation/search/search.ts
+++ /dev/null
@@ -1,408 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-import lunr from "lunr";
-import multi from "@microbit/lunr-languages/lunr.multi";
-import stemmerSupport from "@microbit/lunr-languages/lunr.stemmer.support";
-import tinyseg from "@microbit/lunr-languages/tinyseg";
-import { retryAsyncLoad } from "../../common/chunk-util";
-import { splitDocString } from "../../editor/codemirror/language-server/docstrings";
-import type {
- ApiDocsEntry,
- ApiDocsResponse,
-} from "../../language-server/apidocs";
-import type { Toolkit, ToolkitTopic } from "../reference/model";
-import { blocksToText } from "./blocks-to-text";
-import {
- Extracts,
- IndexMessage,
- QueryMessage,
- Result,
- SearchResults,
-} from "./common";
-import { contextExtracts, fullStringExtracts, Position } from "./extracts";
-
-export const supportedSearchLanguages = [
- "de",
- "en",
- "es-es",
- "fr",
- "nl",
- "ja",
- "ko",
-];
-
-// Supress warning issued when changing languages.
-const lunrWarn = lunr.utils.warn;
-lunr.utils.warn = (message: string) => {
- if (!message.includes("Overwriting existing registered function")) {
- lunrWarn(message);
- }
-};
-
-stemmerSupport(lunr);
-multi(lunr);
-// Required for Ja stemming support.
-tinyseg(lunr);
-
-const ignoredPythonStopWords = new Set([
- // Sorted.
- "and",
- "else",
- "for",
- "if",
- "not",
- "or",
- "while",
-]);
-const originalStopWordFilter = lunr.stopWordFilter;
-lunr.stopWordFilter = (token) => {
- if (ignoredPythonStopWords.has(token.toString())) {
- return token;
- }
- return originalStopWordFilter(token);
-};
-lunr.Pipeline.registerFunction(lunr.stopWordFilter, "pythonStopWordFilter");
-
-interface Metadata {
- [match: string]: MatchMetadata;
-}
-interface MatchMetadata {
- [field: string]: { position: Position[] };
-}
-
-export class SearchIndex {
- constructor(
- private contentByRef: Map,
- public index: lunr.Index,
- private tokenizer: TokenizerFunction,
- private tab: "reference" | "api"
- ) {}
-
- search(text: string): Result[] {
- const results = this.index.query((builder) => {
- this.tokenizer(text).forEach((token) => {
- builder.term(token.toString(), {});
- });
- });
- return results.map((result) => {
- const content = this.contentByRef.get(result.ref);
- if (!content) {
- throw new Error("Missing content");
- }
- // eslint-disable-next-line
- const matchMetadata = result.matchData.metadata as Metadata;
- const extracts = getExtracts(matchMetadata, content);
- return {
- id: content.id,
- title: content.title,
- containerTitle: content.containerTitle,
- navigation: {
- tab: this.tab,
- slug: { id: content.id },
- },
- extract: extracts,
- };
- });
- }
-}
-
-const getExtracts = (
- matchMetadata: Metadata,
- content: SearchableContent
-): Extracts => {
- const allContentPositions: Position[] = [];
- const allTitlePositions: Position[] = [];
-
- for (const match of Object.values(matchMetadata)) {
- if (match.title) {
- match.title.position.forEach((p) => {
- allTitlePositions.push(p);
- });
- }
- if (match.content) {
- match.content.position.forEach((p) => {
- allContentPositions.push(p);
- });
- }
- }
-
- return {
- title: fullStringExtracts(allTitlePositions, content.title),
- // TODO: consider a fallback if only text in the title is matched.
- content: contextExtracts(allContentPositions, content.content),
- };
-};
-
-export class LunrSearch {
- constructor(private reference: SearchIndex, private api: SearchIndex) {}
-
- search(text: string): SearchResults {
- return {
- reference: this.reference.search(text),
- api: this.api.search(text),
- };
- }
-}
-
-export interface SearchableContent {
- id: string;
- /**
- * The API module or Reference topic.
- */
- containerTitle: string;
- title: string;
- content: string;
-}
-
-const defaultString = (string: string | undefined): string => {
- return string || "";
-};
-
-const referenceSearchableContent = (
- reference: Toolkit
-): SearchableContent[] => {
- const content: SearchableContent[] = [];
- reference.contents?.forEach((t) => {
- if (!isSingletonTopic(t)) {
- content.push({
- id: t.slug.current,
- title: t.name,
- containerTitle: t.name,
- content: t.subtitle + ". " + blocksToText(t.introduction),
- });
- }
- t.contents?.forEach((e) => {
- content.push({
- id: e.slug.current,
- title: e.name,
- containerTitle: t.name,
- content: [
- blocksToText(e.content),
- blocksToText(e.detailContent),
- defaultString(e.alternativesLabel),
- defaultString(e.alternatives?.map((a) => a.name).join(", ")),
- ].join(" "),
- });
- });
- });
- return content;
-};
-
-const apiSearchableContent = (
- toolkit: ApiDocsResponse
-): SearchableContent[] => {
- const content: SearchableContent[] = [];
- const addNestedDocs = (
- moduleName: string,
- entries: ApiDocsEntry[] | undefined
- ): void => {
- entries?.forEach((c) => {
- content.push({
- id: c.id,
- title: c.fullName.substring(moduleName.length + 1),
- containerTitle: moduleName,
- content: splitDocString(defaultString(c.docString)).summary,
- });
- addNestedDocs(moduleName, c.children);
- });
- };
- for (const module of Object.values(toolkit)) {
- content.push({
- id: module.id,
- title: module.fullName,
- containerTitle: module.fullName,
- content: splitDocString(defaultString(module.docString)).summary,
- });
- addNestedDocs(module.fullName, module.children);
- }
- return content;
-};
-
-type TokenizerFunction = {
- (obj?: string | object | object[] | null | undefined): lunr.Token[];
- separator: RegExp;
-};
-
-export const buildSearchIndex = (
- searchableContent: SearchableContent[],
- tab: "reference" | "api",
- language: LunrLanguage | undefined,
- languagePlugin: lunr.Builder.Plugin,
- ...plugins: lunr.Builder.Plugin[]
-): SearchIndex => {
- let customTokenizer: TokenizerFunction | undefined;
- const index = lunr(function () {
- this.ref("id");
- this.field("title", { boost: 10 });
- this.field("content");
- this.use(languagePlugin);
- plugins.forEach((p) => this.use(p));
-
- // If the language defines a tokenizer then we need to us it alongside the
- // English one. We stash the tokenizer in customTokenizer so we can pass it
- // to the index for use at query time.
- const languageTokenizer = language ? lunr[language].tokenizer : undefined;
- customTokenizer = Object.assign(
- (obj?: string | object | object[] | null | undefined) => {
- const tokens = lunr.tokenizer(obj);
- if (!languageTokenizer) {
- return tokens;
- }
- return tokens.concat(languageTokenizer(obj));
- },
- { separator: lunr.tokenizer.separator }
- );
- this.tokenizer = customTokenizer;
-
- this.metadataWhitelist = ["position"];
- for (const doc of searchableContent) {
- this.add(doc);
- }
- });
- const contentByRef = new Map(searchableContent.map((c) => [c.id, c]));
- return new SearchIndex(contentByRef, index, customTokenizer!, tab);
-};
-
-// Exposed for testing.
-export const buildReferenceIndex = async (
- reference: Toolkit,
- api: ApiDocsResponse
-): Promise => {
- const language = convertLangToLunrParam(reference.language);
- const languageSupport = await retryAsyncLoad(() =>
- loadLunrLanguageSupport(language)
- );
- const plugins: lunr.Builder.Plugin[] = [];
- if (languageSupport && language) {
- // Loading plugin for fr makes lunr.fr available but we don't model this in the types.
- // Avoid repeatedly initializing them when switching back and forth.
- if (!lunr[language]) {
- languageSupport(lunr);
- }
- plugins.push(lunr[language]);
- }
-
- // There is always some degree of English content.
- const multiLanguages = ["en"];
- if (language) {
- multiLanguages.push(language);
- }
- const languagePlugin = lunr.multiLanguage(...multiLanguages);
-
- return new LunrSearch(
- buildSearchIndex(
- referenceSearchableContent(reference),
- "reference",
- language,
- languagePlugin,
- ...plugins
- ),
- buildSearchIndex(
- apiSearchableContent(api),
- "api",
- language,
- languagePlugin,
- ...plugins
- )
- );
-};
-
-async function loadLunrLanguageSupport(
- language: LunrLanguage | undefined
-): Promise void)> {
- if (!language) {
- // English.
- return undefined;
- }
- // Enumerated for code splitting.
- switch (language.toLowerCase()) {
- case "de":
- return (await import("@microbit/lunr-languages/lunr.de")).default;
- case "fr":
- return (await import("@microbit/lunr-languages/lunr.fr")).default;
- case "es":
- return (await import("@microbit/lunr-languages/lunr.es")).default;
- case "ja":
- return (await import("@microbit/lunr-languages/lunr.ja")).default;
- case "ko":
- return (await import("@microbit/lunr-languages/lunr.ko")).default;
- case "nl":
- return (await import("@microbit/lunr-languages/lunr.nl")).default;
- default:
- // No search support for the language, default to lunr's built-in English support.
- return undefined;
- }
-}
-
-type LunrLanguage = "de" | "es" | "fr" | "ja" | "nl" | "ko";
-
-function convertLangToLunrParam(language: string): LunrLanguage | undefined {
- switch (language.toLowerCase()) {
- case "de":
- return "de";
- case "fr":
- return "fr";
- case "es-es":
- return "es";
- case "ja":
- return "ja";
- case "ko":
- return "ko";
- case "nl":
- return "nl";
- default:
- // No search support for the language, default to lunr's built-in English support.
- return undefined;
- }
-}
-
-export class SearchWorker {
- private search: LunrSearch | undefined;
- // We block queries on indexing.
- private recordInitialization: (() => void) | undefined;
- private initialized: Promise;
-
- constructor(private ctx: Worker) {
- // We return Promises here just to allow for easy testing.
- this.ctx.onmessage = async (event: MessageEvent) => {
- const data = event.data;
- if (data.kind === "query") {
- return this.query(data as QueryMessage);
- } else if (data.kind === "index") {
- return this.index(data as IndexMessage);
- } else {
- console.error("Unexpected worker message", event);
- }
- };
- this.initialized = new Promise((resolve) => {
- // Later, in response to the index message.
- this.recordInitialization = resolve;
- });
- }
-
- private async index(message: IndexMessage) {
- this.search = await buildReferenceIndex(message.reference, message.api);
- this.recordInitialization!();
- }
-
- private async query(message: QueryMessage) {
- const search = await this.initializedIndex();
- this.ctx.postMessage({
- kind: "queryResponse",
- ...search.search(message.query),
- });
- }
-
- private async initializedIndex(): Promise {
- await this.initialized;
- return this.search!;
- }
-}
-
-// We have some topics that contain a single item with the same id.
-// There's no sense indexing the topic itself in those cases.
-const isSingletonTopic = (t: ToolkitTopic): boolean =>
- t.contents?.length === 1 && t.contents[0].slug.current === t.slug.current;
diff --git a/src/documentation/search/search.worker.de.ts b/src/documentation/search/search.worker.de.ts
new file mode 100644
index 000000000..a2779e9a1
--- /dev/null
+++ b/src/documentation/search/search.worker.de.ts
@@ -0,0 +1,9 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.de";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, "de", languageSupport);
diff --git a/src/documentation/search/search.worker.en.ts b/src/documentation/search/search.worker.en.ts
new file mode 100644
index 000000000..3ff2c4dd2
--- /dev/null
+++ b/src/documentation/search/search.worker.en.ts
@@ -0,0 +1,8 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, undefined, undefined);
diff --git a/src/documentation/search/search.worker.es.ts b/src/documentation/search/search.worker.es.ts
new file mode 100644
index 000000000..325a5b97e
--- /dev/null
+++ b/src/documentation/search/search.worker.es.ts
@@ -0,0 +1,10 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.es";
+
+// Note the language code is different to the app
+new SearchWorker(self as DedicatedWorkerGlobalScope, "es", languageSupport);
diff --git a/src/documentation/search/search.worker.fr.ts b/src/documentation/search/search.worker.fr.ts
new file mode 100644
index 000000000..f64a62395
--- /dev/null
+++ b/src/documentation/search/search.worker.fr.ts
@@ -0,0 +1,9 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.fr";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, "fr", languageSupport);
diff --git a/src/documentation/search/search.worker.ja.ts b/src/documentation/search/search.worker.ja.ts
new file mode 100644
index 000000000..2be8e15b8
--- /dev/null
+++ b/src/documentation/search/search.worker.ja.ts
@@ -0,0 +1,9 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.ja";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, "ja", languageSupport);
diff --git a/src/documentation/search/search.worker.ko.ts b/src/documentation/search/search.worker.ko.ts
new file mode 100644
index 000000000..b33007236
--- /dev/null
+++ b/src/documentation/search/search.worker.ko.ts
@@ -0,0 +1,9 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.ko";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, "ko", languageSupport);
diff --git a/src/documentation/search/search.worker.nl.ts b/src/documentation/search/search.worker.nl.ts
new file mode 100644
index 000000000..9050d06bb
--- /dev/null
+++ b/src/documentation/search/search.worker.nl.ts
@@ -0,0 +1,9 @@
+/**
+ * (c) 2022, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SearchWorker } from "./search.worker";
+import languageSupport from "@microbit/lunr-languages/lunr.nl";
+
+new SearchWorker(self as DedicatedWorkerGlobalScope, "nl", languageSupport);
diff --git a/src/documentation/search/search.test.ts b/src/documentation/search/search.worker.test.ts
similarity index 89%
rename from src/documentation/search/search.test.ts
rename to src/documentation/search/search.worker.test.ts
index 3d8630c21..107a75a05 100644
--- a/src/documentation/search/search.test.ts
+++ b/src/documentation/search/search.worker.test.ts
@@ -9,11 +9,13 @@ import { Toolkit } from "../reference/model";
import { IndexMessage } from "./common";
import lunrJa from "@microbit/lunr-languages/lunr.ja";
import {
- buildReferenceIndex,
+ buildIndex,
buildSearchIndex,
SearchableContent,
SearchWorker,
-} from "./search";
+} from "./search.worker";
+import { vi } from "vitest";
+import frLanguageSupport from "@microbit/lunr-languages/lunr.fr";
const searchableReferenceContent: SearchableContent[] = [
{
@@ -98,7 +100,9 @@ describe("Search", () => {
});
describe("buildReferenceIndex", () => {
- it("uses language from the toolkit for the Reference index", async () => {
+ it("uses language support provided", async () => {
+ // We used to derive this from the index and dynamically load the right language support
+ // inside the worker, but switched to a worker per language when movign to Vite
const api: ApiDocsResponse = {};
const referenceEn: Toolkit = {
id: "reference",
@@ -118,12 +122,12 @@ describe("buildReferenceIndex", () => {
...referenceEn,
language: "fr",
};
- const enIndex = await buildReferenceIndex(referenceEn, api);
+ const enIndex = await buildIndex(referenceEn, api, undefined, undefined);
expect(enIndex.search("topic").reference.length).toEqual(1);
// "that" is an English stopword
expect(enIndex.search("that").reference.length).toEqual(0);
- const frIndex = await buildReferenceIndex(referenceFr, api);
+ const frIndex = await buildIndex(referenceFr, api, "fr", frLanguageSupport);
expect(frIndex.search("topic").reference.length).toEqual(1);
// "that" is not a French stopword
expect(frIndex.search("that").reference.length).toEqual(1);
@@ -132,12 +136,12 @@ describe("buildReferenceIndex", () => {
describe("SearchWorker", () => {
it("blocks queries on initialization", async () => {
- const postMessage = jest.fn();
+ const postMessage = vi.fn();
const ctx = {
postMessage,
- } as unknown as Worker;
+ } as unknown as DedicatedWorkerGlobalScope;
- new SearchWorker(ctx);
+ new SearchWorker(ctx, undefined, undefined);
ctx.onmessage!(
new MessageEvent("message", {
@@ -175,12 +179,12 @@ describe("SearchWorker", () => {
});
it("reindexes", async () => {
- const postMessage = jest.fn();
+ const postMessage = vi.fn();
const ctx = {
postMessage,
- } as unknown as Worker;
+ } as unknown as DedicatedWorkerGlobalScope;
- new SearchWorker(ctx);
+ new SearchWorker(ctx, undefined, undefined);
const emptyIndex: IndexMessage = {
kind: "index",
diff --git a/src/documentation/search/search.worker.ts b/src/documentation/search/search.worker.ts
index 1bd89b9a3..bf51579b6 100644
--- a/src/documentation/search/search.worker.ts
+++ b/src/documentation/search/search.worker.ts
@@ -1,9 +1,364 @@
/**
- * (c) 2022, Micro:bit Educational Foundation and contributors
+ * (c) 2021, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
-import { SearchWorker } from "./search";
+import multi from "@microbit/lunr-languages/lunr.multi";
+import stemmerSupport from "@microbit/lunr-languages/lunr.stemmer.support";
+import tinyseg from "@microbit/lunr-languages/tinyseg";
+import lunr from "lunr";
+import { splitDocString } from "../../editor/codemirror/language-server/docstrings";
+import type {
+ ApiDocsEntry,
+ ApiDocsResponse,
+} from "../../language-server/apidocs";
+import type { Toolkit, ToolkitTopic } from "../reference/model";
+import { blocksToText } from "./blocks-to-text";
+import {
+ Extracts,
+ IndexMessage,
+ QueryMessage,
+ Result,
+ SearchResults,
+} from "./common";
+import { Position, contextExtracts, fullStringExtracts } from "./extracts";
-// eslint-disable-next-line
-new SearchWorker(self as any);
+export const supportedSearchLanguages = [
+ "de",
+ "en",
+ "es-es",
+ "fr",
+ "nl",
+ "ja",
+ "ko",
+];
+
+// Supress warning issued when changing languages.
+const lunrWarn = lunr.utils.warn;
+lunr.utils.warn = (message: string) => {
+ if (!message.includes("Overwriting existing registered function")) {
+ lunrWarn(message);
+ }
+};
+
+stemmerSupport(lunr);
+multi(lunr);
+// Required for Ja stemming support.
+tinyseg(lunr);
+
+const ignoredPythonStopWords = new Set([
+ // Sorted.
+ "and",
+ "else",
+ "for",
+ "if",
+ "not",
+ "or",
+ "while",
+]);
+const originalStopWordFilter = lunr.stopWordFilter;
+lunr.stopWordFilter = (token) => {
+ if (ignoredPythonStopWords.has(token.toString())) {
+ return token;
+ }
+ return originalStopWordFilter(token);
+};
+lunr.Pipeline.registerFunction(lunr.stopWordFilter, "pythonStopWordFilter");
+
+interface Metadata {
+ [match: string]: MatchMetadata;
+}
+interface MatchMetadata {
+ [field: string]: { position: Position[] };
+}
+
+export class SearchIndex {
+ constructor(
+ private contentByRef: Map,
+ public index: lunr.Index,
+ private tokenizer: TokenizerFunction,
+ private tab: "reference" | "api"
+ ) {}
+
+ search(text: string): Result[] {
+ const results = this.index.query((builder) => {
+ this.tokenizer(text).forEach((token) => {
+ builder.term(token.toString(), {});
+ });
+ });
+ return results.map((result) => {
+ const content = this.contentByRef.get(result.ref);
+ if (!content) {
+ throw new Error("Missing content");
+ }
+ const matchMetadata = result.matchData.metadata as Metadata;
+ const extracts = getExtracts(matchMetadata, content);
+ return {
+ id: content.id,
+ title: content.title,
+ containerTitle: content.containerTitle,
+ navigation: {
+ tab: this.tab,
+ slug: { id: content.id },
+ },
+ extract: extracts,
+ };
+ });
+ }
+}
+
+const getExtracts = (
+ matchMetadata: Metadata,
+ content: SearchableContent
+): Extracts => {
+ const allContentPositions: Position[] = [];
+ const allTitlePositions: Position[] = [];
+
+ for (const match of Object.values(matchMetadata)) {
+ if (match.title) {
+ match.title.position.forEach((p) => {
+ allTitlePositions.push(p);
+ });
+ }
+ if (match.content) {
+ match.content.position.forEach((p) => {
+ allContentPositions.push(p);
+ });
+ }
+ }
+
+ return {
+ title: fullStringExtracts(allTitlePositions, content.title),
+ // TODO: consider a fallback if only text in the title is matched.
+ content: contextExtracts(allContentPositions, content.content),
+ };
+};
+
+export class LunrSearch {
+ constructor(private reference: SearchIndex, private api: SearchIndex) {}
+
+ search(text: string): SearchResults {
+ return {
+ reference: this.reference.search(text),
+ api: this.api.search(text),
+ };
+ }
+}
+
+export interface SearchableContent {
+ id: string;
+ /**
+ * The API module or Reference topic.
+ */
+ containerTitle: string;
+ title: string;
+ content: string;
+}
+
+const defaultString = (string: string | undefined): string => {
+ return string || "";
+};
+
+const referenceSearchableContent = (
+ reference: Toolkit
+): SearchableContent[] => {
+ const content: SearchableContent[] = [];
+ reference.contents?.forEach((t) => {
+ if (!isSingletonTopic(t)) {
+ content.push({
+ id: t.slug.current,
+ title: t.name,
+ containerTitle: t.name,
+ content: t.subtitle + ". " + blocksToText(t.introduction),
+ });
+ }
+ t.contents?.forEach((e) => {
+ content.push({
+ id: e.slug.current,
+ title: e.name,
+ containerTitle: t.name,
+ content: [
+ blocksToText(e.content),
+ blocksToText(e.detailContent),
+ defaultString(e.alternativesLabel),
+ defaultString(e.alternatives?.map((a) => a.name).join(", ")),
+ ].join(" "),
+ });
+ });
+ });
+ return content;
+};
+
+const apiSearchableContent = (
+ toolkit: ApiDocsResponse
+): SearchableContent[] => {
+ const content: SearchableContent[] = [];
+ const addNestedDocs = (
+ moduleName: string,
+ entries: ApiDocsEntry[] | undefined
+ ): void => {
+ entries?.forEach((c) => {
+ content.push({
+ id: c.id,
+ title: c.fullName.substring(moduleName.length + 1),
+ containerTitle: moduleName,
+ content: splitDocString(defaultString(c.docString)).summary,
+ });
+ addNestedDocs(moduleName, c.children);
+ });
+ };
+ for (const module of Object.values(toolkit)) {
+ content.push({
+ id: module.id,
+ title: module.fullName,
+ containerTitle: module.fullName,
+ content: splitDocString(defaultString(module.docString)).summary,
+ });
+ addNestedDocs(module.fullName, module.children);
+ }
+ return content;
+};
+
+type TokenizerFunction = {
+ (obj?: string | object | object[] | null | undefined): lunr.Token[];
+ separator: RegExp;
+};
+
+export const buildSearchIndex = (
+ searchableContent: SearchableContent[],
+ tab: "reference" | "api",
+ language: LunrLanguage | undefined,
+ languagePlugin: lunr.Builder.Plugin,
+ ...plugins: lunr.Builder.Plugin[]
+): SearchIndex => {
+ let customTokenizer: TokenizerFunction | undefined;
+ const index = lunr(function () {
+ this.ref("id");
+ this.field("title", { boost: 10 });
+ this.field("content");
+ this.use(languagePlugin);
+ plugins.forEach((p) => this.use(p));
+
+ // If the language defines a tokenizer then we need to us it alongside the
+ // English one. We stash the tokenizer in customTokenizer so we can pass it
+ // to the index for use at query time.
+ const languageTokenizer = language ? lunr[language].tokenizer : undefined;
+ customTokenizer = Object.assign(
+ (obj?: string | object | object[] | null | undefined) => {
+ const tokens = lunr.tokenizer(obj);
+ if (!languageTokenizer) {
+ return tokens;
+ }
+ return tokens.concat(languageTokenizer(obj));
+ },
+ { separator: lunr.tokenizer.separator }
+ );
+ this.tokenizer = customTokenizer;
+
+ this.metadataWhitelist = ["position"];
+ for (const doc of searchableContent) {
+ this.add(doc);
+ }
+ });
+ const contentByRef = new Map(searchableContent.map((c) => [c.id, c]));
+ return new SearchIndex(contentByRef, index, customTokenizer!, tab);
+};
+
+// Exposed for testing.
+export const buildIndex = async (
+ reference: Toolkit,
+ api: ApiDocsResponse,
+ lunrLanguage: LunrLanguage | undefined,
+ languageSupport: ((l: typeof lunr) => void) | undefined
+): Promise => {
+ const plugins: lunr.Builder.Plugin[] = [];
+ if (languageSupport && lunrLanguage) {
+ languageSupport(lunr);
+ plugins.push(lunr[lunrLanguage]);
+ }
+
+ // There is always some degree of English content.
+ const multiLanguages = ["en"];
+ if (lunrLanguage) {
+ multiLanguages.push(lunrLanguage);
+ }
+ const languagePlugin = lunr.multiLanguage(...multiLanguages);
+
+ return new LunrSearch(
+ buildSearchIndex(
+ referenceSearchableContent(reference),
+ "reference",
+ lunrLanguage,
+ languagePlugin,
+ ...plugins
+ ),
+ buildSearchIndex(
+ apiSearchableContent(api),
+ "api",
+ lunrLanguage,
+ languagePlugin,
+ ...plugins
+ )
+ );
+};
+
+type LunrLanguage = "de" | "es" | "fr" | "ja" | "nl" | "ko";
+
+export class SearchWorker {
+ private search: LunrSearch | undefined;
+ // We block queries on indexing.
+ private recordInitialization: (() => void) | undefined;
+ private initialized: Promise;
+
+ constructor(
+ private ctx: DedicatedWorkerGlobalScope,
+ private languageId: LunrLanguage | undefined,
+ private languageSupport: ((l: typeof lunr) => void) | undefined
+ ) {
+ // We return Promises here just to allow for easy testing.
+ this.ctx.onmessage = async (event: MessageEvent) => {
+ const data = event.data;
+ if (data.kind === "query") {
+ return this.query(data as QueryMessage);
+ } else if (data.kind === "index") {
+ return this.index(data as IndexMessage);
+ } else if (data.kind === "shutdown") {
+ this.ctx.close();
+ } else {
+ console.error("Unexpected worker message", event);
+ }
+ };
+ this.initialized = new Promise((resolve) => {
+ // Later, in response to the index message.
+ this.recordInitialization = resolve;
+ });
+ }
+
+ private async index(message: IndexMessage) {
+ this.search = await buildIndex(
+ message.reference,
+ message.api,
+ this.languageId,
+ this.languageSupport
+ );
+ this.recordInitialization!();
+ }
+
+ private async query(message: QueryMessage) {
+ const search = await this.initializedIndex();
+ this.ctx.postMessage({
+ kind: "queryResponse",
+ ...search.search(message.query),
+ });
+ }
+
+ private async initializedIndex(): Promise {
+ await this.initialized;
+ return this.search!;
+ }
+}
+
+// We have some topics that contain a single item with the same id.
+// There's no sense indexing the topic itself in those cases.
+const isSingletonTopic = (t: ToolkitTopic): boolean =>
+ t.contents?.length === 1 && t.contents[0].slug.current === t.slug.current;
diff --git a/src/e2e/app.ts b/src/e2e/app.ts
index 2fc971992..796962071 100644
--- a/src/e2e/app.ts
+++ b/src/e2e/app.ts
@@ -34,7 +34,7 @@ export interface BrowserDownload {
data: Buffer;
}
-const defaultWaitForOptions = { timeout: 5_000 };
+const defaultWaitForOptions = { timeout: 10_000 };
const baseUrl = "http://localhost:3000";
const reportsPath = "reports/e2e/";
@@ -109,8 +109,9 @@ export class App {
}
return (
baseUrl +
- // We don't use PUBLIC_URL here as CRA seems to set it to "" before running jest.
- (process.env.E2E_PUBLIC_URL ?? "/") +
+ // We didn't use BASE_URL here as CRA seems to set it to "" before running jest.
+ // Maybe can be changed since the Vite upgrade.
+ (process.env.E2E_BASE_URL ?? "/") +
"?" +
new URLSearchParams(params) +
(options.fragment ?? "")
@@ -508,7 +509,7 @@ export class App {
},
{
...defaultWaitForOptions,
- onTimeout: (e) =>
+ onTimeout: (_e) =>
new Error(
`Timeout waiting for ${match} but content was:\n${lastText}}\n\nJSON version:\n${JSON.stringify(
lastText
@@ -941,6 +942,8 @@ export class App {
this.page = this.createPage();
page = await this.page;
await page.goto(this.url);
+ // Wait for side bar to load
+ await page.waitForSelector('[data-testid="scrollable-panel"]');
}
/**
@@ -1031,7 +1034,10 @@ export class App {
const button = await document.findByRole("link", {
name: linkName,
});
- return button.click();
+ await button.click();
+
+ // Wait for side bar to load
+ await document.waitForSelector('[data-testid="scrollable-panel"]');
}
/**
@@ -1105,7 +1111,7 @@ export class App {
return (
reportsPath +
// GH actions has character restrictions
- expect.getState().currentTestName.replace(/[^0-9a-zA-Z]+/g, "-") +
+ (expect.getState().currentTestName || "").replace(/[^0-9a-zA-Z]+/g, "-") +
"." +
extension
);
@@ -1215,6 +1221,7 @@ export class App {
await triggerDownload();
const startTime = performance.now();
+ // eslint-disable-next-line no-constant-condition
while (true) {
const after = await listDir();
before.forEach((x) => after.delete(x));
@@ -1247,15 +1254,6 @@ export class App {
await keyboard.press(key);
}
- private async getElementByRoleAndLabel(
- role: string,
- name: string
- ): Promise> {
- return (await this.document()).findByRole(role, {
- name,
- });
- }
-
private async getElementByQuerySelector(
query: string
): Promise> {
diff --git a/src/e2e/documentation.test.ts b/src/e2e/documentation.test.ts
index 697a45472..db7274bff 100644
--- a/src/e2e/documentation.test.ts
+++ b/src/e2e/documentation.test.ts
@@ -5,7 +5,7 @@
*/
import { App } from "./app";
-describe("documentaion", () => {
+describe("documentation", () => {
const app = new App();
beforeEach(app.reset.bind(app));
afterEach(app.screenshot.bind(app));
diff --git a/src/e2e/edits.test.ts b/src/e2e/edits.test.ts
index ede3894ec..834a1dc2c 100644
--- a/src/e2e/edits.test.ts
+++ b/src/e2e/edits.test.ts
@@ -37,8 +37,6 @@ describe("edits", () => {
await app.reloadPage();
- await app.findVisibleEditorContents(/A change/, {
- timeout: 2_000,
- });
+ await app.findVisibleEditorContents(/A change/);
});
});
diff --git a/src/editor/ActiveFileInfo.tsx b/src/editor/ActiveFileInfo.tsx
index 53c026e81..10d5b0203 100644
--- a/src/editor/ActiveFileInfo.tsx
+++ b/src/editor/ActiveFileInfo.tsx
@@ -21,7 +21,6 @@ interface ActiveFileInfoProps extends BoxProps {
const ActiveFileInfo = ({
filename,
onSelectedFileChanged,
- ...props
}: ActiveFileInfoProps) => {
return (
diff --git a/src/editor/EditorArea.tsx b/src/editor/EditorArea.tsx
index 539852ae0..700741c41 100644
--- a/src/editor/EditorArea.tsx
+++ b/src/editor/EditorArea.tsx
@@ -39,7 +39,7 @@ const EditorArea = React.forwardRef(
simulatorButtonRef: ForwardedRef
) => {
const intl = useIntl();
- const [isWideScreen] = useMediaQuery(widthXl);
+ const [isWideScreen] = useMediaQuery(widthXl, { ssr: false });
return (
{
it("null case", () => {
@@ -79,7 +80,7 @@ describe("dndDecorations", () => {
// ...and later dispatches timeout effect
// (we've reduced the delay to 0 but it's still async)
- const mockDispatch = view.dispatch as unknown as jest.MockedFunction<
+ const mockDispatch = view.dispatch as unknown as MockedFunction<
(t: Transaction) => void
>;
expect(mockDispatch.mock.calls.length).toEqual(0);
@@ -182,11 +183,12 @@ const decorationDetails = (plugin: DndDecorationsViewPlugin) => {
};
const createView = (doc: Text = Text.of([""])): EditorView => {
- return {
+ const view: Partial = {
visibleRanges: [{ from: 0, to: doc.length - 1 }],
state: EditorState.create({ doc }),
- dispatch: jest.fn(),
- } as Partial as unknown as EditorView;
+ dispatch: vi.fn() as any,
+ };
+ return view as unknown as EditorView;
};
/**
diff --git a/src/editor/codemirror/dnd-decorations.ts b/src/editor/codemirror/dnd-decorations.ts
index fb480ea11..f51926cb6 100644
--- a/src/editor/codemirror/dnd-decorations.ts
+++ b/src/editor/codemirror/dnd-decorations.ts
@@ -12,7 +12,7 @@ import {
ViewUpdate,
} from "@codemirror/view";
-export const timeoutEffect = StateEffect.define<{}>({});
+export const timeoutEffect = StateEffect.define>({});
// Exported for unit testing.
export class DndDecorationsViewPlugin {
diff --git a/src/editor/codemirror/language-server/documentation.test.ts b/src/editor/codemirror/language-server/documentation.test.ts
index 0123c27c0..6cb4f2601 100644
--- a/src/editor/codemirror/language-server/documentation.test.ts
+++ b/src/editor/codemirror/language-server/documentation.test.ts
@@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: MIT
*/
-/* eslint-disable testing-library/render-result-naming-convention */
import { renderDocumentation, renderMarkdown } from "./documentation";
describe("renderDocumentation", () => {
diff --git a/src/editor/codemirror/lint/lint.ts b/src/editor/codemirror/lint/lint.ts
index b633b5d58..d3b1f3ac7 100644
--- a/src/editor/codemirror/lint/lint.ts
+++ b/src/editor/codemirror/lint/lint.ts
@@ -39,7 +39,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
-/* eslint-disable */
import {EditorView, ViewPlugin, Decoration, DecorationSet,
WidgetType, ViewUpdate, Command, logException, KeyBinding,
hoverTooltip, Tooltip, showTooltip, gutter, GutterMarker,
diff --git a/src/editor/codemirror/structure-highlighting/view.ts b/src/editor/codemirror/structure-highlighting/view.ts
index 8484b3b8f..38dd8efef 100644
--- a/src/editor/codemirror/structure-highlighting/view.ts
+++ b/src/editor/codemirror/structure-highlighting/view.ts
@@ -69,7 +69,7 @@ export const codeStructureView = (option: "full" | "simple") =>
view.requestMeasure(this.measureReq);
}
- update(update: ViewUpdate) {
+ update(_update: ViewUpdate) {
// We can probably limit this but we need to know when the language state has changed as parsing has occurred.
this.view.requestMeasure(this.measureReq);
}
diff --git a/src/environment.ts b/src/environment.ts
index bb78ce9f6..92ad85ded 100644
--- a/src/environment.ts
+++ b/src/environment.ts
@@ -3,8 +3,8 @@
*
* SPDX-License-Identifier: MIT
*/
-export const version = process.env.REACT_APP_VERSION || "local";
+export const version = import.meta.env.VITE_VERSION || "local";
export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION";
-export const stage = (process.env.REACT_APP_STAGE || "local") as Stage;
+export const stage = (import.meta.env.VITE_STAGE || "local") as Stage;
diff --git a/src/fs/fs.ts b/src/fs/fs.ts
index 0862a5aeb..159ce94d5 100644
--- a/src/fs/fs.ts
+++ b/src/fs/fs.ts
@@ -162,7 +162,7 @@ export class FileSystem extends EventEmitter implements FlashDataSource {
project: Project;
constructor(
- private logging: Logging,
+ logging: Logging,
private host: Host,
private microPythonSource: MicroPythonSource
) {
@@ -193,7 +193,7 @@ export class FileSystem extends EventEmitter implements FlashDataSource {
async initializeInBackground() {
// It's been observed that this can be slow after the fetch on low-end devices,
// so it might be good to move the FS work to a worker if we can't make it fast.
- this.initialize().catch((e) => {
+ this.initialize().catch((_) => {
this.initializing = undefined;
});
}
diff --git a/src/fs/host-default.test.ts b/src/fs/host-default.test.ts
new file mode 100644
index 000000000..2853c2e27
--- /dev/null
+++ b/src/fs/host-default.test.ts
@@ -0,0 +1,31 @@
+/**
+ * @vitest-environment jsdom
+ * @vitest-environment-options { "url": "http://localhost:3000" }
+ */
+import { fromByteArray } from "base64-js";
+import { MAIN_FILE } from "./fs";
+import { DefaultHost } from "./host";
+import { defaultInitialProject } from "./initial-project";
+import { testMigrationUrl } from "./migration-test-data";
+
+describe("DefaultHost", () => {
+ it("uses migration if available", async () => {
+ const project = await new DefaultHost(
+ testMigrationUrl
+ ).createInitialProject();
+ expect(project).toEqual({
+ files: {
+ [MAIN_FILE]: fromByteArray(
+ new TextEncoder().encode(
+ "from microbit import *\r\ndisplay.show(Image.HEART)"
+ )
+ ),
+ },
+ projectName: "Hearts",
+ });
+ });
+ it("otherwise uses defaults", async () => {
+ const project = await new DefaultHost("").createInitialProject();
+ expect(project).toEqual(defaultInitialProject);
+ });
+});
diff --git a/src/fs/host.test.ts b/src/fs/host-iframe.test.ts
similarity index 62%
rename from src/fs/host.test.ts
rename to src/fs/host-iframe.test.ts
index eba13bda4..aea454d1a 100644
--- a/src/fs/host.test.ts
+++ b/src/fs/host-iframe.test.ts
@@ -1,15 +1,16 @@
/**
- * @jest-environment ./src/testing/custom-browser-env
+ * @vitest-environment jsdom
+ * @vitest-environment-options { "url": "http://localhost:3000?controller=1" }
*/
import { fromByteArray } from "base64-js";
-import { VersionAction, MAIN_FILE } from "./fs";
-import { DefaultHost, IframeHost } from "./host";
-import { defaultInitialProject } from "./initial-project";
-import { testMigrationUrl } from "./migration.test";
+import { vi } from "vitest";
+import { MAIN_FILE, VersionAction } from "./fs";
+import { IframeHost } from "./host";
+import { waitFor } from "@testing-library/react";
describe("IframeHost", () => {
- const mockWrite = jest.fn();
- const mockAddListener = jest.fn();
+ const mockWrite = vi.fn();
+ const mockAddListener = vi.fn();
const fs = {
read: () => new TextEncoder().encode("Code read!"),
write: mockWrite,
@@ -17,35 +18,17 @@ describe("IframeHost", () => {
getPythonProject: () => "",
} as any;
- const mockPostMessage = jest.fn();
+ const mockPostMessage = vi.fn();
const parentWindow = { postMessage: mockPostMessage } as any;
delete (window as any).parent;
(window as any).parent = parentWindow;
- delete (window as any).location;
- window.location = new URL("https://localhost:3000?controller=1") as any;
-
- const spinEventLoop = async (check: () => void) => {
- let error: any;
- for (let i = 0; i < 100; ++i) {
- try {
- check();
- return;
- } catch (e) {
- error = e;
- }
- await new Promise((resolve) => setTimeout(resolve, 0));
- }
- throw error;
- };
const expectSendsMessageToParent = async (expected: any) =>
- spinEventLoop(() =>
- expect(mockPostMessage.mock.calls).toContainEqual(expected)
- );
+ waitFor(() => expect(mockPostMessage.mock.calls).toContainEqual(expected));
const expectCodeWrite = async (expected: any) =>
- spinEventLoop(() => expect(mockWrite.mock.calls).toContainEqual(expected));
+ waitFor(() => expect(mockWrite.mock.calls).toContainEqual(expected));
it("exchanges sync messages", async () => {
const host = new IframeHost(parentWindow, window);
@@ -122,25 +105,3 @@ describe("IframeHost", () => {
]);
});
});
-
-describe("DefaultHost", () => {
- it("uses migration if available", async () => {
- const project = await new DefaultHost(
- testMigrationUrl
- ).createInitialProject();
- expect(project).toEqual({
- files: {
- [MAIN_FILE]: fromByteArray(
- new TextEncoder().encode(
- "from microbit import *\r\ndisplay.show(Image.HEART)"
- )
- ),
- },
- projectName: "Hearts",
- });
- });
- it("otherwise uses defaults", async () => {
- const project = await new DefaultHost("").createInitialProject();
- expect(project).toEqual(defaultInitialProject);
- });
-});
diff --git a/src/fs/host.ts b/src/fs/host.ts
index b062a9374..6b58fe9e7 100644
--- a/src/fs/host.ts
+++ b/src/fs/host.ts
@@ -87,18 +87,22 @@ export class IframeHost implements Host {
private window: Window,
private debounceDelay: number = 1_000
) {}
- createStorage(logging: Logging): FSStorage {
+ createStorage(_logging: Logging): FSStorage {
return new InMemoryFSStorage(undefined);
}
- async shouldReinitializeProject(storage: FSStorage): Promise {
+ async shouldReinitializeProject(_storage: FSStorage): Promise {
// If there is persistence then it is the embedder's problem.
return true;
}
createInitialProject(): Promise {
return new Promise((resolve) => {
- this.window.addEventListener("load", () =>
- notifyWorkspaceSync(this.parent)
- );
+ if (this.window.document.readyState === "complete") {
+ notifyWorkspaceSync(this.parent);
+ } else {
+ this.window.addEventListener("load", () =>
+ notifyWorkspaceSync(this.parent)
+ );
+ }
this.window.addEventListener("message", (event) => {
if (
event?.data.type === messages.type &&
diff --git a/src/fs/lzma.d.ts b/src/fs/lzma.d.ts
index 5a935b309..1767b2067 100644
--- a/src/fs/lzma.d.ts
+++ b/src/fs/lzma.d.ts
@@ -1,6 +1 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-declare module "lzma/src/lzma-d-min";
+declare module "lzma/src/lzma-d";
diff --git a/src/fs/migration-test-data.ts b/src/fs/migration-test-data.ts
new file mode 100644
index 000000000..134a6e561
--- /dev/null
+++ b/src/fs/migration-test-data.ts
@@ -0,0 +1,4 @@
+// The heart project.
+export const testMigrationUrl =
+ // origin needs to match jest's testUrl
+ "http://localhost:3000/#import:#project:XQAAgACRAAAAAAAAAAA9iImmlGSt1R++5LD+ZJ36cRz46B+lhYtNRoWF0nijpaVyZlK7ACfSpeoQpgfk21st4ty06R4PEOM4sSAXBT95G3en+tghrYmE+YJp6EiYgzA9ThKkyShWq2UdvmCzqxoNfYc1wlmTqlNv/Piaz3WoSe3flvr/ItyLl0aolQlEpv4LA8A=";
diff --git a/src/fs/migration.test.ts b/src/fs/migration.test.ts
index de05edffa..ac2c23d9d 100644
--- a/src/fs/migration.test.ts
+++ b/src/fs/migration.test.ts
@@ -4,11 +4,7 @@
* SPDX-License-Identifier: MIT
*/
import { isMigration, parseMigrationFromUrl } from "./migration";
-
-// The heart project.
-export const testMigrationUrl =
- // origin needs to match jest's testUrl
- "http://localhost/#import:#project:XQAAgACRAAAAAAAAAAA9iImmlGSt1R++5LD+ZJ36cRz46B+lhYtNRoWF0nijpaVyZlK7ACfSpeoQpgfk21st4ty06R4PEOM4sSAXBT95G3en+tghrYmE+YJp6EiYgzA9ThKkyShWq2UdvmCzqxoNfYc1wlmTqlNv/Piaz3WoSe3flvr/ItyLl0aolQlEpv4LA8A=";
+import { testMigrationUrl } from "./migration-test-data";
describe("parseMigrationFromUrl", () => {
it("parses valid URL", () => {
@@ -23,7 +19,7 @@ describe("parseMigrationFromUrl", () => {
},
source: `from microbit import *\r\ndisplay.show(Image.HEART)`,
},
- postMigrationUrl: "http://localhost/",
+ postMigrationUrl: "http://localhost:3000/",
});
});
it("undefined for nonsense", () => {
diff --git a/src/fs/migration.ts b/src/fs/migration.ts
index caf79265c..c09c7797f 100644
--- a/src/fs/migration.ts
+++ b/src/fs/migration.ts
@@ -5,7 +5,14 @@
*/
import { toByteArray } from "base64-js";
-import { LZMA } from "lzma/src/lzma-d-min";
+import lzma from "lzma/src/lzma-d";
+
+// LZMA isn't a proper module.
+// When bundled it assigns to window. At dev time it works via the above import.
+const LZMA =
+ typeof window !== "undefined" && (window as any).LZMA
+ ? (window as any).LZMA
+ : lzma.LZMA;
// There are other fields that we don't use.
export interface Migration {
diff --git a/src/fs/storage.ts b/src/fs/storage.ts
index 91d106466..8c99fd04c 100644
--- a/src/fs/storage.ts
+++ b/src/fs/storage.ts
@@ -71,7 +71,7 @@ export class InMemoryFSStorage implements FSStorage {
}
async remove(name: string): Promise {
- if (!this.exists(name)) {
+ if (!(await this.exists(name))) {
throw new Error(`No such file ${name}`);
}
this._data.delete(name);
@@ -159,7 +159,7 @@ export class SessionStorageFSStorage implements FSStorage {
}
async remove(name: string): Promise {
- if (!this.exists(name)) {
+ if (!(await this.exists(name))) {
throw new Error(`No such file ${name}`);
}
this.storage.removeItem(fsFilesPrefix + name);
diff --git a/src/index.tsx b/src/index.tsx
index 4bc6fd46d..48edb877e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: MIT
*/
-import React, { StrictMode } from "react";
+import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
diff --git a/src/language-server/apidocs.ts b/src/language-server/apidocs.ts
index f220ea1be..1579f954a 100644
--- a/src/language-server/apidocs.ts
+++ b/src/language-server/apidocs.ts
@@ -44,6 +44,11 @@ export interface ApiDocsEntry {
params?: ApiDocsFunctionParameter[];
}
+export interface ApiDocsContent {
+ languageId: string;
+ content: ApiDocsResponse;
+}
+
export interface ApiDocsResponse extends Record {}
export const apiDocsRequestType = new ProtocolRequestType<
@@ -56,10 +61,10 @@ export const apiDocsRequestType = new ProtocolRequestType<
export const apiDocs = async (
client: LanguageServerClient
-): Promise => {
+): Promise => {
// This is a non-standard LSP call that we've added support for to Pyright.
try {
- const result = await client.connection.sendRequest(apiDocsRequestType, {
+ const content = await client.connection.sendRequest(apiDocsRequestType, {
path: client.rootUri,
documentationFormat: [MarkupKind.Markdown],
modules: [
@@ -84,10 +89,10 @@ export const apiDocs = async (
"time",
],
});
- return result;
+ return { content, languageId: client.locale };
} catch (e) {
if (isErrorDueToDispose(e)) {
- return {};
+ return { content: {}, languageId: client.locale };
}
throw e;
}
diff --git a/src/language-server/client.ts b/src/language-server/client.ts
index efc3455b8..83eb5c685 100644
--- a/src/language-server/client.ts
+++ b/src/language-server/client.ts
@@ -57,7 +57,7 @@ export class LanguageServerClient extends EventEmitter {
constructor(
public connection: MessageConnection,
- private locale: string,
+ public locale: string,
public rootUri: string
) {
super();
@@ -191,7 +191,8 @@ export class LanguageServerClient extends EventEmitter {
return import(`../micropython/${branch}/typeshed.${this.locale}.json`);
});
return {
- files: typeshed,
+ // Shallow copy as it's an ESM that can't be serialized
+ files: { files: typeshed.files },
// Custom option in our Pyright version
diagnosticStyle: "simplified",
};
diff --git a/src/language-server/language-server-hooks.tsx b/src/language-server/language-server-hooks.tsx
index c03fe4f76..29e568a0d 100644
--- a/src/language-server/language-server-hooks.tsx
+++ b/src/language-server/language-server-hooks.tsx
@@ -58,6 +58,7 @@ export const LanguageServerClientProvider = ({
removeTrackFsChangesListener(fs, listener);
}
ignore = true;
+ // We don't dispose the client here as it's cached for reuse.
};
}, [fs, languageId]);
return (
diff --git a/src/language-server/pyright.ts b/src/language-server/pyright.ts
index 8a3622eb7..ef9c370d5 100644
--- a/src/language-server/pyright.ts
+++ b/src/language-server/pyright.ts
@@ -16,8 +16,12 @@ const workerScriptName = "pyright-main-46e9f54371eb3b42b37c.worker.js";
// Very simple cache to avoid React re-creating pointlessly in development.
let counter = 0;
-let cached: LanguageServerClient | undefined;
-let cachedLang: string | undefined;
+let cache:
+ | {
+ client: LanguageServerClient;
+ language: string;
+ }
+ | undefined;
/**
* Creates Pyright workers and corresponding client.
@@ -27,15 +31,21 @@ let cachedLang: string | undefined;
export const pyright = async (
language: string
): Promise => {
- // For jest.
+ // For jsdom.
if (!window.Worker) {
return undefined;
}
- if (cached && cachedLang === language) {
- return cached;
+ if (cache) {
+ // This is safe to call if already initialized.
+ await cache.client.initialize();
+ if (cache.language === language) {
+ return cache.client;
+ } else {
+ // Dispose it, we'll create a new one.
+ cache?.client.dispose();
+ cache = undefined;
+ }
}
- // Dispose it, we'll create a new one.
- cached?.dispose();
const idSuffix = counter++;
// Needed to support review branches that use a path location.
@@ -81,8 +91,14 @@ export const pyright = async (
});
connection.listen();
- cached = new LanguageServerClient(connection, language, createUri(""));
- await cached.initialize();
- cachedLang = language;
- return cached;
+ const client = new LanguageServerClient(connection, language, createUri(""));
+ // Must assign before any async step so we reuse or dispose this client
+ // if another call to pyright is made (language change or React 18 dev mode
+ // in practice).
+ cache = {
+ client,
+ language,
+ };
+ await client.initialize();
+ return cache?.client;
};
diff --git a/src/mocks/worker.js b/src/mocks/worker.js
index d4f4c3447..3d13e0aed 100644
--- a/src/mocks/worker.js
+++ b/src/mocks/worker.js
@@ -1,4 +1,4 @@
-// WebWorker stub for jest.
+// WebWorker stub for jsdom.
class MockWorker {}
diff --git a/src/project/ChooseMainScriptQuestion.test.tsx b/src/project/ChooseMainScriptQuestion.test.tsx
index 03470657f..0323c37cc 100644
--- a/src/project/ChooseMainScriptQuestion.test.tsx
+++ b/src/project/ChooseMainScriptQuestion.test.tsx
@@ -12,15 +12,16 @@ import { MainScriptChoice } from "./project-actions";
import { stubIntl as intl } from "../messages/testing";
import FixedTranslationProvider from "../messages/FixedTranslationProvider";
import { InputValidationResult } from "../common/InputDialog";
+import { MockedFunction, vi } from "vitest";
describe("ChooseMainScriptQuestion", () => {
const data = () => Promise.resolve(new Uint8Array([0]));
describe("component", () => {
- const setValue = jest.fn() as jest.MockedFunction<
+ const setValue = vi.fn() as MockedFunction<
(x: MainScriptChoice | undefined) => void
>;
- const setValidationResult = jest.fn() as jest.MockedFunction<
+ const setValidationResult = vi.fn() as MockedFunction<
(x: InputValidationResult) => void
>;
const currentFiles = new Set(["main.py", "magic.py"]);
diff --git a/src/project/OpenButton.tsx b/src/project/OpenButton.tsx
index fb03d31ff..16a4e54dd 100644
--- a/src/project/OpenButton.tsx
+++ b/src/project/OpenButton.tsx
@@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: MIT
*/
-import React from "react";
import { RiFolderOpenLine } from "react-icons/ri";
import { useIntl } from "react-intl";
import { CollapsibleButtonComposableProps } from "../common/CollapsibleButton";
diff --git a/src/project/ProjectActionBar.tsx b/src/project/ProjectActionBar.tsx
index 98b19cd66..b8ea31873 100644
--- a/src/project/ProjectActionBar.tsx
+++ b/src/project/ProjectActionBar.tsx
@@ -19,7 +19,7 @@ const ProjectActionBar = React.forwardRef(
{ sendButtonRef, ...props }: ProjectActionBarProps,
ref: ForwardedRef
) => {
- const [isWideScreen] = useMediaQuery(widthXl);
+ const [isWideScreen] = useMediaQuery(widthXl, { ssr: false });
const size = "lg";
return (
) {
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
deleted file mode 100644
index bd78f6dd8..000000000
--- a/src/react-app-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-///
diff --git a/src/serial/XTerm.tsx b/src/serial/XTerm.tsx
index c30f1c304..269ee0b2c 100644
--- a/src/serial/XTerm.tsx
+++ b/src/serial/XTerm.tsx
@@ -74,7 +74,7 @@ const useManagedTermimal = (
},
});
- const tracebackLinkHandler = (e: MouseEvent, traceLine: string) => {
+ const tracebackLinkHandler = (_e: MouseEvent, traceLine: string) => {
const { file, line } = parseTraceLine(traceLine);
if (file) {
setSelection({ file, location: { line } });
diff --git a/src/settings/SettingsArea.tsx b/src/settings/SettingsArea.tsx
index 78cc26529..528d064d5 100644
--- a/src/settings/SettingsArea.tsx
+++ b/src/settings/SettingsArea.tsx
@@ -15,7 +15,7 @@ import {
NumberInputStepper,
VStack,
} from "@chakra-ui/react";
-import React, { useCallback, useMemo } from "react";
+import { useCallback, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import SelectFormControl, { createOptions } from "./SelectFormControl";
import {
diff --git a/src/setupTests.ts b/src/setupTests.ts
index daa1b15fe..65e7faa14 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -4,26 +4,29 @@
* SPDX-License-Identifier: MIT
*/
-// jest-dom adds custom jest matchers for asserting on DOM nodes.
-// allows you to do things like:
-// expect(element).toHaveTextContent(/react/i)
-// learn more: https://github.com/testing-library/jest-dom
-import "@testing-library/jest-dom";
+import { vi, expect, afterEach } from "vitest";
+import { cleanup } from "@testing-library/react";
+import matchers from "@testing-library/jest-dom/matchers";
+expect.extend(matchers);
+
+afterEach(() => {
+ cleanup();
+});
global.matchMedia =
global.matchMedia ||
function () {
return {
matches: false,
- addListener: jest.fn(),
- removeListener: jest.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
};
};
class MockResizeObserver {
- observe = jest.fn();
- unobserve = jest.fn();
- disconnect = jest.fn();
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
}
global.ResizeObserver = global.ResizeObserver || MockResizeObserver;
diff --git a/src/simulator/CompassModule.tsx b/src/simulator/CompassModule.tsx
index 26676fb3d..ec06c711a 100644
--- a/src/simulator/CompassModule.tsx
+++ b/src/simulator/CompassModule.tsx
@@ -12,7 +12,7 @@ import {
SimulatorState,
} from "../device/simulator";
import Axis from "./Axis";
-import { ReactComponent as CompassHeadingIcon } from "./icons/compass-heading.svg";
+import CompassHeadingIcon from "./icons/compass-heading.svg?react";
import RangeSensor from "./RangeSensor";
interface CompassModuleProps {
diff --git a/src/simulator/RadioModule.tsx b/src/simulator/RadioModule.tsx
index c5a66e224..3ca570d4a 100644
--- a/src/simulator/RadioModule.tsx
+++ b/src/simulator/RadioModule.tsx
@@ -18,7 +18,7 @@ import {
import { FormEvent, ReactNode, useCallback, useState } from "react";
import { RiSendPlane2Line } from "react-icons/ri";
import { FormattedMessage, useIntl } from "react-intl";
-import { ReactComponent as MessageIcon } from "./icons/microbit-face-icon.svg";
+import MessageIcon from "./icons/microbit-face-icon.svg?react";
import { RadioChatItem, useRadioChatItems } from "./radio-hooks";
import { useAutoScrollToBottom } from "./scroll-hooks";
diff --git a/src/simulator/SimulatorModules.tsx b/src/simulator/SimulatorModules.tsx
index 384055d4b..130b7a0bd 100644
--- a/src/simulator/SimulatorModules.tsx
+++ b/src/simulator/SimulatorModules.tsx
@@ -30,13 +30,13 @@ import ButtonsModule from "./ButtonsModule";
import CompassModule from "./CompassModule";
import { DataLogProvider } from "./data-logging-hooks";
import DataLoggingModule from "./DataLoggingModule";
-import { ReactComponent as AccelerometerIcon } from "./icons/accelerometer.svg";
-import { ReactComponent as ButtonPressIcon } from "./icons/button-press.svg";
-import { ReactComponent as CompassIcon } from "./icons/compass.svg";
-import { ReactComponent as DataLoggingIcon } from "./icons/data-logging.svg";
-import { ReactComponent as MicrophoneIcon } from "./icons/microphone.svg";
-import { ReactComponent as PinsIcon } from "./icons/pins.svg";
-import { ReactComponent as RadioIcon } from "./icons/radio.svg";
+import AccelerometerIcon from "./icons/accelerometer.svg?react";
+import ButtonPressIcon from "./icons/button-press.svg?react";
+import CompassIcon from "./icons/compass.svg?react";
+import DataLoggingIcon from "./icons/data-logging.svg?react";
+import MicrophoneIcon from "./icons/microphone.svg?react";
+import PinsIcon from "./icons/pins.svg?react";
+import RadioIcon from "./icons/radio.svg?react";
import PinsModule from "./PinsModule";
import { RadioChatProvider } from "./radio-hooks";
import RadioModule from "./RadioModule";
diff --git a/src/simulator/scroll-hooks.tsx b/src/simulator/scroll-hooks.tsx
index 48beaafdd..68f02df48 100644
--- a/src/simulator/scroll-hooks.tsx
+++ b/src/simulator/scroll-hooks.tsx
@@ -13,7 +13,7 @@ export const useAutoScrollToBottom = (
const [enabled, setEnabled] = useState(true);
const ref = useRef(null);
const handleScroll = useCallback(
- (e: React.UIEvent) => {
+ (_: React.UIEvent) => {
const element = ref.current!;
const isAtBottom =
element.scrollHeight - element.scrollTop === element.clientHeight;
diff --git a/src/testing/custom-browser-env.js b/src/testing/custom-browser-env.js
deleted file mode 100644
index 00acec0f8..000000000
--- a/src/testing/custom-browser-env.js
+++ /dev/null
@@ -1,15 +0,0 @@
-const Environment = require("jest-environment-jsdom");
-
-/**
- * A custom environment to make TextEncoder/TextDecoder available.
- */
-module.exports = class CustomBrowserEnvironment extends Environment {
- async setup() {
- await super.setup();
- if (this.global.TextEncoder) {
- throw new Error("Workaround environment no longer required.");
- }
- const { TextEncoder, TextDecoder } = require("util");
- Object.assign(this.global, { TextEncoder, TextDecoder });
- }
-};
diff --git a/src/vite.d.ts b/src/vite.d.ts
new file mode 100644
index 000000000..91d3b1708
--- /dev/null
+++ b/src/vite.d.ts
@@ -0,0 +1,11 @@
+///
+///
+
+interface ImportMetaEnv {
+ readonly VITE_VERSION: string;
+ readonly VITE_STAGE: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/src/workbench/AboutDialog/AboutDialog.tsx b/src/workbench/AboutDialog/AboutDialog.tsx
index d6119d200..4e5eebe07 100644
--- a/src/workbench/AboutDialog/AboutDialog.tsx
+++ b/src/workbench/AboutDialog/AboutDialog.tsx
@@ -50,7 +50,7 @@ import pythonPoweredLogo from "./python-powered.png";
const versionInfo = [
{
name: "Editor",
- value: process.env.REACT_APP_VERSION,
+ value: import.meta.env.VITE_VERSION,
href: "https://github.com/microbit-foundation/python-editor-v3",
},
...microPythonConfig.versions.map((mpy) => ({
@@ -266,7 +266,7 @@ const MicroPythonSection = (props: BoxProps) => {
{chunks}
),
- linkV2: (chunks: ReactNode) => (
+ linkV2: (_: ReactNode) => (
{
const width = "5rem";
const ref = useRef(null);
diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx
index bd797d132..e5b6a903f 100644
--- a/src/workbench/Workbench.tsx
+++ b/src/workbench/Workbench.tsx
@@ -96,7 +96,9 @@ const Workbench = () => {
handleSidebarCollapse();
}
}, [handleSidebarCollapse]);
- const [hideSideBarMediaQueryValue] = useMediaQuery(hideSidebarMediaQuery);
+ const [hideSideBarMediaQueryValue] = useMediaQuery(hideSidebarMediaQuery, {
+ ssr: false,
+ });
useEffect(() => {
if (hideSideBarMediaQueryValue) {
handleSidebarCollapse();
diff --git a/src/workbench/connect-dialogs/ConnectHelpDialog.tsx b/src/workbench/connect-dialogs/ConnectHelpDialog.tsx
index ae7a35599..7ac0280c0 100644
--- a/src/workbench/connect-dialogs/ConnectHelpDialog.tsx
+++ b/src/workbench/connect-dialogs/ConnectHelpDialog.tsx
@@ -19,7 +19,7 @@ import selectMicrobit from "./select-microbit.png";
const ConnectHelpDialogBody = () => {
const intl = useIntl();
- const [isDesktop] = useMediaQuery("(min-width: 768px)");
+ const [isDesktop] = useMediaQuery("(min-width: 768px)", { ssr: false });
return (
({
+ name: "ejs",
+ transformIndexHtml: {
+ order: "pre",
+ handler: (
+ html: string,
+ _ctx: IndexHtmlTransformContext
+ ): IndexHtmlTransformResult => ejs.render(html, data),
+ },
+});
+
+export default defineConfig(({ mode }) => {
+ const unitTest: UserConfig["test"] = {
+ globals: true,
+ exclude: [...configDefaults.exclude, "**/e2e/**"],
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.ts",
+ mockReset: true,
+ };
+ const e2eTest: UserConfig["test"] = {
+ globals: true,
+ include: ["src/e2e/**/*.test.ts"],
+ environment: "jsdom",
+ testTimeout: 60_000,
+ hookTimeout: 30_000,
+ };
+ const config: UserConfig = {
+ base: process.env.BASE_URL ?? "/",
+ build: {
+ outDir: "build",
+ sourcemap: true,
+ },
+ server: {
+ port: 3000,
+ },
+ assetsInclude: ["**/*.hex"],
+ plugins: [
+ viteEjsPlugin({
+ data: loadEnv(mode, process.cwd(), "VITE_"),
+ }),
+ react(),
+ svgr(),
+ ],
+ test: mode === "e2e" ? e2eTest : unitTest,
+ resolve: {
+ alias: {
+ "theme-package": fs.existsSync(external)
+ ? theme
+ : path.resolve(__dirname, internal),
+ },
+ },
+ };
+ return config;
+});