Repository: sjbarag/brs (js)

Issue:
Custom Node that extends from Group can't instantiate child nodes?
I have a **MaskGroup** node(mock) that extends from **Group**:
```
<component name="MaskGroup" extends="Group">
    <interface>
        <field id="masksize" type="array" />
        <field id="maskOffset" type="array" />
    </interface>
    <script type="text/brightscript" uri="MaskGroup.brs" />
</component>
```
But when appending it to a another node:

**SomeNode.xml**
```
<component name="SomeNode" extends="Group">
    <script type="text/brightscript" uri="SomeNode.brs" />
    <children>
        <MaskGroup id="maskGroup">
           <Rectangle id="rect1" />
        </MaskGroup>
        <Rectangle id="rect2" />
    </children>
```
</component>

**SomeNode.brs** :
```
sub init()
    m.rect1 = m.top.findNode("rect1")
    m.rect2 = m.top.findNode("rect2")
    ?"rect1"; m.rect1
    ?"rect2:"; m.rect2
end sub
```
It seems to not find rect1. 
_Output from code above:_

```
rect1 invalid
rect2: <Component: roSGNode:Rectangle> =
{
    change: <Component: roAssociativeArray>
    focusable: false
    focusedchild: invalid
    id: rect2
    visible: true
    opacity: 1
    translation: <Component: roArray>
    rotation: 0
    scale: <Component: roArray>
    scalerotatecenter: <Component: roArray>
    childrenderorder: renderLast
    inheritparenttransform: true
    inheritparentopacity: true
    clippingrect: <Component: roArray>
    renderpass: 0
    muteaudioguide: false
    enablerendertracking: false
    rendertracking: disabled
    width: 0
    height: 0
    color: 0xFFFFFFFF
    blendingenabled: true
}

```

Also, if I do `maskGroup.getChildCount()`, it returns `0`.

Pull request description:
fix(stdlib): Create nested children correctly
# Change Summary

Fixes #485.

Patch:
diff --git a/package.json b/package.json
index 2670bd4..60057ab 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "brs",
-  "version": "0.25.1",
+  "version": "0.26.1",
   "description": "An interpreter for the BrightScript language that runs on non-Roku platforms.",
   "author": "Sean Barag <sean@barag.org>",
   "license": "MIT",
diff --git a/src/brsTypes/components/RoSGNode.ts b/src/brsTypes/components/RoSGNode.ts
index 4ed766a..fd423d1 100644
--- a/src/brsTypes/components/RoSGNode.ts
+++ b/src/brsTypes/components/RoSGNode.ts
@@ -1443,31 +1443,30 @@ function addChildren(
 
     children.forEach((child) => {
         let newChild = createNodeByType(interpreter, new BrsString(child.name));
-        if (newChild instanceof RoSGNode && appendChild) {
-            appendChild.call(interpreter, newChild);
-            let setField = newChild.getMethod("setfield");
-            if (setField) {
-                let nodeFields = newChild.getFields();
-                for (let [key, value] of Object.entries(child.fields)) {
-                    let field = nodeFields.get(key.toLowerCase());
-                    if (field) {
-                        setField.call(
-                            interpreter,
-                            new BrsString(key),
-                            // use the field type to construct the field value
-                            getBrsValueFromFieldType(field.getType(), value)
-                        );
+        if (newChild instanceof RoSGNode) {
+            if (appendChild) {
+                appendChild.call(interpreter, newChild);
+                let setField = newChild.getMethod("setfield");
+                if (setField) {
+                    let nodeFields = newChild.getFields();
+                    for (let [key, value] of Object.entries(child.fields)) {
+                        let field = nodeFields.get(key.toLowerCase());
+                        if (field) {
+                            setField.call(
+                                interpreter,
+                                new BrsString(key),
+                                // use the field type to construct the field value
+                                getBrsValueFromFieldType(field.getType(), value)
+                            );
+                        }
                     }
                 }
             }
-        }
 
-        // If there is no type definition, that means it's a built-in component.
-        // In that case, use the child's ComponentNode information to keep recursing.
-        let newTypeDef = interpreter.environment.nodeDefMap.get(child.name) || child;
-
-        if (child.children.length > 0 && newChild instanceof RoSGNode && newTypeDef) {
-            addChildren(interpreter, newChild, newTypeDef);
+            if (child.children.length > 0) {
+                // we need to add the child's own children
+                addChildren(interpreter, newChild, child);
+            }
         }
     });
 }
diff --git a/src/extensions/mockComponentPartial.ts b/src/extensions/mockComponentPartial.ts
index 3041078..6ab58cb 100644
--- a/src/extensions/mockComponentPartial.ts
+++ b/src/extensions/mockComponentPartial.ts
@@ -31,7 +31,11 @@ export const mockComponentPartial = new Callable("mockComponentPartial", {
                 .filter(([_, value]) => value.kind === ValueKind.Callable)
                 .forEach(([key, value]) => {
                     if (value instanceof Callable) {
-                        maybeComponent?.environment?.setMockFunction(key.toString(), value);
+                        maybeComponent?.environment?.setMockFunction(
+                            key.toString(),
+                            value,
+                            /* isScoped */ true
+                        );
                     }
                 });
         } else {
diff --git a/src/interpreter/Environment.ts b/src/interpreter/Environment.ts
index c6212a6..19f38af 100644
--- a/src/interpreter/Environment.ts
+++ b/src/interpreter/Environment.ts
@@ -49,9 +49,13 @@ export class Environment {
      */
     private mockObjects = new Map<string, RoAssociativeArray>();
     /**
-     * Mocked functions
+     * Unscoped mock functions. These mocks are used across all components and files.
      */
-    private mockFunctions = new Map<string, Callable>();
+    private unscopedMockFunctions = new Map<string, Callable>();
+    /**
+     * Scoped mock functions. These mocks are only used in this component.
+     */
+    private scopedMockFunctions = new Map<string, Callable>();
     /** The BrightScript `m` pointer, analogous to JavaScript's `this` pointer. */
     private mPointer = new RoAssociativeArray([]);
     private rootM: RoAssociativeArray;
@@ -249,23 +253,20 @@ export class Environment {
     public createSubEnvironment(includeModuleScope: boolean = true): Environment {
         let newEnvironment = new Environment(this.rootM);
         newEnvironment.global = new Map(this.global);
-        newEnvironment.module = includeModuleScope
-            ? new Map(this.module)
-            : new Map<string, BrsType>();
         newEnvironment.mPointer = this.mPointer;
         newEnvironment.mockObjects = this.mockObjects;
+        newEnvironment.unscopedMockFunctions = this.unscopedMockFunctions;
+        newEnvironment.focusedNode = this.focusedNode;
+        newEnvironment.nodeDefMap = this.nodeDefMap;
 
         if (includeModuleScope) {
-            newEnvironment.module = this.module;
-            newEnvironment.mockFunctions = this.mockFunctions;
+            newEnvironment.module = new Map(this.module);
+            newEnvironment.scopedMockFunctions = this.scopedMockFunctions;
         } else {
             newEnvironment.module = new Map<string, BrsType>();
-            newEnvironment.mockFunctions = new Map<string, Callable>();
+            newEnvironment.scopedMockFunctions = new Map<string, Callable>();
         }
 
-        newEnvironment.focusedNode = this.focusedNode;
-        newEnvironment.nodeDefMap = this.nodeDefMap;
-
         return newEnvironment;
     }
 
@@ -290,17 +291,31 @@ export class Environment {
      * places the mockValue function into list of mocks
      * @param functionName the function we are mocking
      * @param mockValue the mock to return
+     * @param isScoped whether this function should be scoped to this module or universally mocked.
      */
-    public setMockFunction(functionName: string, mockValue: Callable): void {
-        this.mockFunctions.set(functionName.toLowerCase(), mockValue);
+    public setMockFunction(
+        functionName: string,
+        mockValue: Callable,
+        isScoped: boolean = false
+    ): void {
+        if (isScoped) {
+            this.scopedMockFunctions.set(functionName.toLowerCase(), mockValue);
+        } else {
+            this.unscopedMockFunctions.set(functionName.toLowerCase(), mockValue);
+        }
     }
 
     /**
-     * retrieves mocked function if it exists
+     * Retrieves mocked function if it exists. If there is a scoped and unscoped function
+     * by the same name, it will return the scoped.
      * @param functionName the function to mock
      */
     public getMockFunction(functionName: string): Callable | BrsInvalid {
-        return this.mockFunctions.get(functionName) || BrsInvalid.Instance;
+        if (this.scopedMockFunctions.has(functionName)) {
+            return this.scopedMockFunctions.get(functionName)!;
+        }
+
+        return this.unscopedMockFunctions.get(functionName) || BrsInvalid.Instance;
     }
 
     /**
@@ -309,7 +324,11 @@ export class Environment {
      * @param possibleMockName the identifier/name for the mocked function
      */
     private isMockedFunction(possibleMockFunction: BrsType, possibleMockName: string): boolean {
-        return possibleMockFunction instanceof Callable && this.mockFunctions.has(possibleMockName);
+        return (
+            possibleMockFunction instanceof Callable &&
+            (this.scopedMockFunctions.has(possibleMockName) ||
+                this.unscopedMockFunctions.has(possibleMockName))
+        );
     }
 
     /**
@@ -331,7 +350,8 @@ export class Environment {
      * resets all of the mocked functions in this environment
      */
     public resetMockFunctions() {
-        this.mockFunctions.clear();
+        this.scopedMockFunctions.clear();
+        this.unscopedMockFunctions.clear();
     }
 
     /**
diff --git a/src/stdlib/File.ts b/src/stdlib/File.ts
index 9ec2639..8e62512 100644
--- a/src/stdlib/File.ts
+++ b/src/stdlib/File.ts
@@ -38,6 +38,24 @@ export function getPath(fileUri: string) {
     return new URL(fileUri).pathname;
 }
 
+/*
+ * Returns a memfs file path from a brs file uri. If the brs file uri
+ * has the "pkg" protocol, append the file path with our root directory
+ * so that we're searching the correct place.
+ *   ex. "tmp:/test/test1.txt" -> "/test/test1.txt"
+ *   ex. "pkg:/test/test1.txt" -> "/path/to/proj/test/test1.txt"
+ *
+ */
+export function getScopedPath(interpreter: Interpreter, fileUri: string) {
+    let url = new URL(fileUri);
+    let filePath = getPath(fileUri);
+    if (url.protocol === "pkg:") {
+        return path.join(interpreter.options.root, filePath);
+    }
+
+    return filePath;
+}
+
 /** Copies a file from src to dst, return true if successful */
 export const CopyFile = new Callable("CopyFile", {
     signature: {
@@ -57,8 +75,8 @@ export const CopyFile = new Callable("CopyFile", {
             return BrsBoolean.False;
         }
 
-        const srcMemfsPath = getPath(src.value);
-        const dstMemfsPath = getPath(dst.value);
+        const srcMemfsPath = getScopedPath(interpreter, src.value);
+        const dstMemfsPath = getScopedPath(interpreter, dst.value);
         try {
             let contents = srcVolume.readFileSync(srcMemfsPath);
             dstVolume.writeFileSync(dstMemfsPath, contents);
@@ -88,8 +106,8 @@ export const MoveFile = new Callable("MoveFile", {
             return BrsBoolean.False;
         }
 
-        const srcMemfsPath = getPath(src.value);
-        const dstMemfsPath = getPath(dst.value);
+        const srcMemfsPath = getScopedPath(interpreter, src.value);
+        const dstMemfsPath = getScopedPath(interpreter, dst.value);
         try {
             let contents = srcVolume.readFileSync(srcMemfsPath);
             dstVolume.writeFileSync(dstMemfsPath, contents);
@@ -113,7 +131,7 @@ export const DeleteFile = new Callable("DeleteFile", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getPath(file.value);
+        const memfsPath = getScopedPath(interpreter, file.value);
         try {
             volume.unlinkSync(memfsPath);
             return BrsBoolean.True;
@@ -135,7 +153,7 @@ export const DeleteDirectory = new Callable("DeleteDirectory", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getPath(dir.value);
+        const memfsPath = getScopedPath(interpreter, dir.value);
         try {
             volume.rmdirSync(memfsPath);
             return BrsBoolean.True;
@@ -157,7 +175,7 @@ export const CreateDirectory = new Callable("CreateDirectory", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getPath(dir.value);
+        const memfsPath = getScopedPath(interpreter, dir.value);
         try {
             volume.mkdirSync(memfsPath);
             return BrsBoolean.True;
@@ -190,15 +208,15 @@ export const ListDir = new Callable("ListDir", {
         args: [new StdlibArgument("path", ValueKind.String)],
         returns: ValueKind.Object,
     },
-    impl: (interpreter: Interpreter, path: BrsString) => {
-        const volume = getVolumeByPath(interpreter, path.value);
+    impl: (interpreter: Interpreter, pathArg: BrsString) => {
+        const volume = getVolumeByPath(interpreter, pathArg.value);
         if (volume === null) {
             return new RoArray([]);
         }
 
-        const memfsPath = getPath(path.value);
+        let localPath = getScopedPath(interpreter, pathArg.value);
         try {
-            let subPaths = volume.readdirSync(memfsPath).map((s) => new BrsString(s));
+            let subPaths = volume.readdirSync(localPath).map((s) => new BrsString(s));
             return new RoArray(subPaths);
         } catch (err) {
             return new RoArray([]);
@@ -218,7 +236,7 @@ export const ReadAsciiFile = new Callable("ReadAsciiFile", {
             return new BrsString("");
         }
 
-        const memfsPath = getPath(filepath.value);
+        const memfsPath = getScopedPath(interpreter, filepath.value);
         return new BrsString(volume.readFileSync(memfsPath).toString());
     },
 });
@@ -238,7 +256,7 @@ export const WriteAsciiFile = new Callable("WriteAsciiFile", {
             return BrsBoolean.False;
         }
 
-        const memfsPath = getPath(filepath.value);
+        const memfsPath = getScopedPath(interpreter, filepath.value);
         volume.writeFileSync(memfsPath, text.value);
         return BrsBoolean.True;
     },
@@ -260,7 +278,7 @@ export const MatchFiles = new Callable("MatchFiles", {
             return new RoArray([]);
         }
 
-        let localPath = path.join(interpreter.options.root, getPath(pathArg.value));
+        let localPath = getScopedPath(interpreter, pathArg.value);
         try {
             let knownFiles = fs.readdirSync(localPath, "utf8");
             let matchedFiles = nanomatch.match(knownFiles, patternIn.value, {


Test patch:
diff --git a/test/e2e/BrsComponents.test.js b/test/e2e/BrsComponents.test.js
index dff37a3..660468a 100644
--- a/test/e2e/BrsComponents.test.js
+++ b/test/e2e/BrsComponents.test.js
@@ -666,21 +666,4 @@ describe("end to end brightscript functions", () => {
             "Node",
         ]);
     });
-
-    test("components/scripts/MockFunctionsMain.brs", async () => {
-        await execute(
-            [resourceFile("components", "scripts", "MockFunctionsMain.brs")],
-            outputStreams
-        );
-        expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
-            "{fake:'json'}",
-            "GET status: 400",
-            "POST status: 500",
-            "true",
-            "{real: 'json'}",
-            "GET status: 200",
-            "POST status: 200",
-            "false",
-        ]);
-    });
 });
diff --git a/test/e2e/BrsMock.test.js b/test/e2e/BrsMock.test.js
index bbeb025..23609c1 100644
--- a/test/e2e/BrsMock.test.js
+++ b/test/e2e/BrsMock.test.js
@@ -86,10 +86,12 @@ describe("end to end brightscript functions", () => {
             "GET status: 400",
             "POST status: 500",
             "true",
+            "mocked correctly!",
             "{real: 'json'}",
             "GET status: 200",
             "POST status: 200",
             "false",
+            "mocked correctly!",
         ]);
     });
 
diff --git a/test/e2e/NestedInlineChildren.test.js b/test/e2e/NestedInlineChildren.test.js
new file mode 100644
index 0000000..43f8e9f
--- /dev/null
+++ b/test/e2e/NestedInlineChildren.test.js
@@ -0,0 +1,35 @@
+const { execute } = require("../../lib");
+const { createMockStreams, resourceFile, allArgs } = require("./E2ETests");
+
+describe("Nest children inline from an extended component", () => {
+    let outputStreams;
+
+    beforeAll(() => {
+        outputStreams = createMockStreams();
+        outputStreams.root = __dirname + "/resources";
+    });
+
+    afterEach(() => {
+        jest.resetAllMocks();
+    });
+
+    afterAll(() => {
+        jest.restoreAllMocks();
+    });
+
+    test("components/scripts/NestedInlineChildrenMain.brs", async () => {
+        await execute(
+            [resourceFile("components", "scripts", "NestedInlineChildrenMain.brs")],
+            outputStreams
+        );
+
+        expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
+            "rect1:",
+            "Rectangle",
+            "rect2:",
+            "Rectangle",
+            "rect3:",
+            "Rectangle",
+        ]);
+    });
+});
diff --git a/test/e2e/resources/components/ComponentMockFunctions.xml b/test/e2e/resources/components/ComponentMockFunctions.xml
index 7bdffcf..1841666 100644
--- a/test/e2e/resources/components/ComponentMockFunctions.xml
+++ b/test/e2e/resources/components/ComponentMockFunctions.xml
@@ -3,6 +3,7 @@
     <script type="text/brightscript" uri="pkg:/components/scripts/ComponentMockFunctions.brs" />
     <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-A.brs" />
     <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-B.brs" />
+    <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-C.brs" />
     <children>
     </children>
 </component>
diff --git a/test/e2e/resources/components/ComponentWithoutMockFunctions.xml b/test/e2e/resources/components/ComponentWithoutMockFunctions.xml
index 3fc79ea..8675afa 100644
--- a/test/e2e/resources/components/ComponentWithoutMockFunctions.xml
+++ b/test/e2e/resources/components/ComponentWithoutMockFunctions.xml
@@ -3,6 +3,7 @@
     <script type="text/brightscript" uri="pkg:/components/scripts/ComponentWithoutMockFunctions.brs" />
     <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-A.brs" />
     <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-B.brs" />
+    <script type="text/brightscript" uri="pkg:/components/scripts/MockFunctionsUtils-C.brs" />
     <children>
     </children>
-</component>
\ No newline at end of file
+</component>
diff --git a/test/e2e/resources/components/NestedInlineChildren.xml b/test/e2e/resources/components/NestedInlineChildren.xml
new file mode 100644
index 0000000..30566d5
--- /dev/null
+++ b/test/e2e/resources/components/NestedInlineChildren.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<component name="NestedInlineChildren">
+    <script type="text/brightscript" uri="pkg:/components/scripts/NestedInlineChildren.brs" />
+    <children>
+        <ExtendedGroup id="extendedGroup">
+            <Rectangle id="rect1">
+                <Rectangle id="rect2" />
+            </Rectangle>
+        </ExtendedGroup>
+        <Rectangle id="rect3" />
+    </children>
+</component>
diff --git a/test/e2e/resources/components/scripts/ComponentMockFunctions.brs b/test/e2e/resources/components/scripts/ComponentMockFunctions.brs
index c52d49c..91e99f7 100644
--- a/test/e2e/resources/components/scripts/ComponentMockFunctions.brs
+++ b/test/e2e/resources/components/scripts/ComponentMockFunctions.brs
@@ -4,4 +4,5 @@ sub init()
     print http_get() ' => GET status: 400
     print http_post() ' => POST status: 500
     print isValid() ' => true
+    print shouldBeMocked() ' => "mocked correctly!"
 end sub
diff --git a/test/e2e/resources/components/scripts/ComponentWithoutMockFunctions.brs b/test/e2e/resources/components/scripts/ComponentWithoutMockFunctions.brs
index 68d97f1..04f4a83 100644
--- a/test/e2e/resources/components/scripts/ComponentWithoutMockFunctions.brs
+++ b/test/e2e/resources/components/scripts/ComponentWithoutMockFunctions.brs
@@ -3,4 +3,5 @@ sub init()
     print http_get() ' => GET status: 200
     print http_post() ' => POST status: 200
     print isValid() ' => false
+    print shouldBeMocked() ' => "mocked correctly!"
 end sub
diff --git a/test/e2e/resources/components/scripts/MockFunctionsMain.brs b/test/e2e/resources/components/scripts/MockFunctionsMain.brs
index 6f1e986..b8c6047 100644
--- a/test/e2e/resources/components/scripts/MockFunctionsMain.brs
+++ b/test/e2e/resources/components/scripts/MockFunctionsMain.brs
@@ -14,6 +14,10 @@ sub main()
         end function
     })
 
+    _brs_.mockFunction("shouldBeMocked", function() as dynamic
+        return "mocked correctly!"
+    end function)
+
     m1 = createObject("roSGNode", "ComponentMockFunctions")
     real = createObject("roSGNode", "ComponentWithoutMockFunctions")
 end sub
diff --git a/test/e2e/resources/components/scripts/MockFunctionsUtils-C.brs b/test/e2e/resources/components/scripts/MockFunctionsUtils-C.brs
new file mode 100644
index 0000000..48f84f2
--- /dev/null
+++ b/test/e2e/resources/components/scripts/MockFunctionsUtils-C.brs
@@ -0,0 +1,3 @@
+function shouldBeMocked()
+    return "noooo should be mocked"
+end function
diff --git a/test/e2e/resources/components/scripts/NestedInlineChildren.brs b/test/e2e/resources/components/scripts/NestedInlineChildren.brs
new file mode 100644
index 0000000..49affae
--- /dev/null
+++ b/test/e2e/resources/components/scripts/NestedInlineChildren.brs
@@ -0,0 +1,8 @@
+sub init()
+    rect1 = m.top.findNode("rect1")
+    rect2 = m.top.findNode("rect2")
+    rect3 = m.top.findNode("rect3")
+    print "rect1:" rect1.subtype()
+    print "rect2:" rect2.subtype()
+    print "rect3:" rect3.subtype()
+end sub
diff --git a/test/e2e/resources/components/scripts/NestedInlineChildrenMain.brs b/test/e2e/resources/components/scripts/NestedInlineChildrenMain.brs
new file mode 100644
index 0000000..b60a2b0
--- /dev/null
+++ b/test/e2e/resources/components/scripts/NestedInlineChildrenMain.brs
@@ -0,0 +1,3 @@
+sub main()
+    node = createObject("RoSGNode", "NestedInlineChildren")
+end sub
diff --git a/test/stdlib/File.test.js b/test/stdlib/File.test.js
index c6be6fb..86fbca2 100644
--- a/test/stdlib/File.test.js
+++ b/test/stdlib/File.test.js
@@ -10,6 +10,7 @@ const {
     WriteAsciiFile,
     MatchFiles,
     getPath,
+    getScopedPath,
     getVolumeByPath,
 } = require("../../lib/stdlib/index");
 const { Interpreter } = require("../../lib/interpreter");
@@ -23,7 +24,9 @@ let interpreter;
 
 describe("global file I/O functions", () => {
     beforeEach(() => {
-        interpreter = new Interpreter(); // reset the file systems
+        interpreter = new Interpreter({
+            root: "hello/world",
+        }); // reset the file systems
     });
 
     describe("file I/O utility utilities", () => {
@@ -37,6 +40,11 @@ describe("global file I/O functions", () => {
             expect(getPath("tmp:/test.txt")).toEqual("/test.txt");
             expect(getPath("tmp:///test.txt")).toEqual("/test.txt");
         });
+
+        it("converts a brs path to a scoped memfs path", () => {
+            expect(getScopedPath(interpreter, "tmp:/test.txt")).toEqual("/test.txt");
+            expect(getScopedPath(interpreter, "pkg:/test.txt")).toEqual("hello/world/test.txt");
+        });
     });
 
     describe("ListDir", () => {