Repository: sjbarag/brs
Pull request #436 (2020-06-08 20:47:26)

feat(stdlib): implement ContentNode
# Change Summary
This implements `ContentNode`.

`ContentNode` is a bit unique in how it manages its fields. Like most components, it has a set of built-in fields that it supports. Unlike most other components, these fields are all "hidden" from the node by default. Then, when a field is accessed in any way, it becomes "visible". The unit tests show this behavior as well, but for clarity, here's an example:
```
node = createObject("RoSGNode", "ContentNode")
print node
' Notice that even though ContentNode has dozens of fields, none of them display.
'
' <Component: roSGNode:ContentNode> =
' {
'    change: <UNINITIALIZED>
'    focusable: false
'    focusedchild: invalid
'    id: 
'}

' Getting a field makes it show up (in all caps)
print node.title
print node.count()  ' => 5 (the node now includes title)
print node
' ...
' TITLE: ""
' ...

' Setting a field makes it show up (in all caps)
node.description = "some description"
print node.count()  ' => 6 (the node now includes description)
print node
' ...
' TITLE: ""
' DESCRIPTION: "some description"
' ...
```

Because of this unique behavior, `ContentNode` had to override some functions from `RoSGNode`, along with a few other changes to support the overrides.

sjbarag: I'm pretty sure I know how to resolve those conflicts now that #438 , but would you mind taking a look when you get a second?
lkipke: > I'm pretty sure I know how to resolve those conflicts now that #438 , but would you mind taking a look when you get a second?

done!

Patch:
diff --git a/src/brsTypes/components/BrsComponent.ts b/src/brsTypes/components/BrsComponent.ts
index c4cba43..ebbbdc4 100644
--- a/src/brsTypes/components/BrsComponent.ts
+++ b/src/brsTypes/components/BrsComponent.ts
@@ -4,7 +4,7 @@ import { Callable } from "../Callable";
 import { BrsInterface } from "../BrsInterface";
 
 export class BrsComponent {
-    private methods: Map<string, Callable> = new Map();
+    private methods: Map<string, Callable> = new Map<string, Callable>();
     private readonly componentName: string;
 
     readonly interfaces = new Map<string, BrsInterface>();
@@ -22,12 +22,11 @@ export class BrsComponent {
     }
 
     protected registerMethods(interfaces: Record<string, Callable[]>) {
-        this.methods = new Map<string, Callable>();
         Object.entries(interfaces).forEach(([interfaceName, methods]) => {
-            this.interfaces.set(
-                interfaceName.toLowerCase(),
-                new BrsInterface(interfaceName, methods)
-            );
+            let interfaceKey = interfaceName.toLowerCase();
+            if (!this.interfaces.has(interfaceKey)) {
+                this.interfaces.set(interfaceKey, new BrsInterface(interfaceName, methods));
+            }
 
             this.appendMethods(methods);
         });
diff --git a/src/brsTypes/components/ComponentFactory.ts b/src/brsTypes/components/ComponentFactory.ts
index df9fcaf..61dd181 100644
--- a/src/brsTypes/components/ComponentFactory.ts
+++ b/src/brsTypes/components/ComponentFactory.ts
@@ -8,6 +8,7 @@ import {
     Poster,
     ArrayGrid,
     MarkupGrid,
+    ContentNode,
 } from "..";
 
 export enum BrsComponentName {
@@ -20,6 +21,7 @@ export enum BrsComponentName {
     Poster = "Poster",
     ArrayGrid = "ArrayGrid",
     MarkupGrid = "MarkupGrid",
+    ContentNode = "ContentNode",
 }
 
 // TODO: update with more components as they're implemented.
@@ -48,6 +50,8 @@ export class ComponentFactory {
                 return new ArrayGrid([], name);
             case BrsComponentName.MarkupGrid:
                 return new MarkupGrid([], name);
+            case BrsComponentName.ContentNode:
+                return new ContentNode(name);
             default:
                 return;
         }
diff --git a/src/brsTypes/components/ContentNode.ts b/src/brsTypes/components/ContentNode.ts
new file mode 100644
index 0000000..969a2a1
--- /dev/null
+++ b/src/brsTypes/components/ContentNode.ts
@@ -0,0 +1,245 @@
+import { FieldModel, Field, RoSGNode } from "./RoSGNode";
+import { BrsType } from "..";
+import { ValueKind, BrsString, BrsBoolean } from "../BrsType";
+import { Interpreter } from "../../interpreter";
+import { Int32 } from "../Int32";
+import { Callable, StdlibArgument } from "../Callable";
+import { RoArray } from "./RoArray";
+import { RoAssociativeArray } from "./RoAssociativeArray";
+
+export class ContentNode extends RoSGNode {
+    readonly defaultFields: FieldModel[] = [
+        { name: "ContentType", type: "string", hidden: true },
+        { name: "Title", type: "string", hidden: true },
+        { name: "TitleSeason", type: "string", hidden: true },
+        { name: "Description", type: "string", hidden: true },
+        { name: "SDPosterUrl", type: "string", hidden: true },
+        { name: "HDPosterUrl", type: "string", hidden: true },
+        { name: "FHDPosterUrl", type: "string", hidden: true },
+        { name: "ReleaseDate", type: "string", hidden: true },
+        { name: "Rating", type: "string", hidden: true },
+        { name: "StarRating", type: "integer", hidden: true },
+        { name: "UserStarRating", type: "integer", hidden: true },
+        { name: "ShortDescriptionLine1", type: "string", hidden: true },
+        { name: "ShortDescriptionLine2", type: "string", hidden: true },
+        { name: "EpisodeNumber", type: "string", hidden: true },
+        { name: "NumEpisodes", type: "integer", hidden: true },
+        { name: "Actors", type: "array", hidden: true },
+        { name: "Actors", type: "string", hidden: true },
+        { name: "Directors", type: "array", hidden: true },
+        { name: "Director", type: "string", hidden: true },
+        { name: "Categories", type: "array", hidden: true },
+        { name: "Categories", type: "string", hidden: true },
+        { name: "Album", type: "string", hidden: true },
+        { name: "Artist", type: "string", hidden: true },
+        { name: "TextOverlayUL", type: "string", hidden: true },
+        { name: "TextOverlayUR", type: "string", hidden: true },
+        { name: "TextOverlayBody", type: "string", hidden: true },
+        { name: "appData", type: "string", value: "", hidden: true },
+        { name: "encodingKey", type: "string", value: "", hidden: true },
+        { name: "encodingType", type: "string", value: "", hidden: true },
+        { name: "KeySystem", type: "string", value: "", hidden: true },
+        { name: "licenseRenewURL", type: "string", value: "", hidden: true },
+        { name: "licenseServerURL", type: "string", value: "", hidden: true },
+        { name: "serializationURL", type: "string", value: "", hidden: true },
+        { name: "serviceCert", type: "string", value: "", hidden: true },
+        { name: "Live", type: "boolean", hidden: true },
+        { name: "Url", type: "string", hidden: true },
+        { name: "SDBifUrl", type: "string", hidden: true },
+        { name: "HDBifUrl", type: "string", hidden: true },
+        { name: "FHDBifUrl", type: "string", hidden: true },
+        { name: "Stream", type: "assocarray", hidden: true },
+        { name: "Streams", type: "assocarray", hidden: true },
+        { name: "StreamBitrates", type: "array", hidden: true },
+        { name: "StreamUrls", type: "array", hidden: true },
+        { name: "StreamQualities", type: "array", hidden: true },
+        { name: "StreamContentIDs", type: "array", hidden: true },
+        { name: "StreamStickyHttpRedirects", type: "array", hidden: true },
+        { name: "StreamStartTimeOffset", type: "integer", hidden: true },
+        { name: "StreamFormat", type: "string", hidden: true },
+        { name: "Length", type: "integer", hidden: true },
+        { name: "BookmarkPosition", type: "integer", hidden: true },
+        { name: "PlayStart", type: "integer", hidden: true },
+        { name: "PlayDuration", type: "integer", hidden: true },
+        { name: "ClosedCaptions", type: "boolean", hidden: true },
+        { name: "HDBranded", type: "boolean", hidden: true },
+        { name: "IsHD", type: "boolean", hidden: true },
+        { name: "SubtitleColor", type: "string", hidden: true },
+        { name: "SubtitleConfig", type: "assocarray", hidden: true },
+        { name: "SubtitleTracks", type: "assocarray", hidden: true },
+        { name: "SubtitleUrl", type: "string", hidden: true },
+        { name: "VideoDisableUI", type: "boolean", hidden: true },
+        { name: "EncodingType", type: "string", hidden: true },
+        { name: "EncodingKey", type: "string", hidden: true },
+        { name: "SwitchingStrategy", type: "string", hidden: true },
+        { name: "Watched", type: "boolean", hidden: true },
+        { name: "ForwardQueryStringParams", type: "boolean", hidden: true },
+        { name: "IgnoreStreamErrors", type: "boolean", hidden: true },
+        { name: "AdaptiveMinStartBitrate", type: "integer", hidden: true },
+        { name: "AdaptiveMaxStartBitrate", type: "integer", hidden: true },
+        { name: "filterCodecProfiles", type: "boolean", hidden: true },
+        { name: "LiveBoundsPauseBehavior", type: "string", hidden: true },
+        { name: "ClipStart", type: "float", hidden: true },
+        { name: "ClipEnd", type: "float", hidden: true },
+        { name: "preferredaudiocodec", type: "string", hidden: true },
+        { name: "CdnConfig", type: "array", hidden: true },
+        { name: "HttpCertificatesFile", type: "string", hidden: true },
+        { name: "HttpCookies", type: "array", hidden: true },
+        { name: "HttpHeaders", type: "array", hidden: true },
+        { name: "HttpSendClientCertificate", type: "boolean", hidden: true },
+        { name: "MinBandwidth", type: "integer", hidden: true },
+        { name: "MaxBandwidth", type: "integer", hidden: true },
+        { name: "AudioPIDPref", type: "integer", hidden: true },
+        { name: "FullHD", type: "boolean", hidden: true },
+        { name: "FrameRate", type: "integer", hidden: true },
+        { name: "TrackIDAudio", type: "string", hidden: true },
+        { name: "TrackIDVideo", type: "string", hidden: true },
+        { name: "TrackIDSubtitle", type: "string", hidden: true },
+        { name: "AudioFormat", type: "string", hidden: true },
+        { name: "AudioLanguageSelected", type: "string", hidden: true },
+        { name: "SDBackgroundImageUrl", type: "string", hidden: true },
+        { name: "HDBackgroundImageUrl", type: "string", hidden: true },
+        { name: "SourceRect", type: "assocarray", hidden: true },
+        { name: "TargetRect", type: "assocarray", hidden: true },
+        { name: "TargetTranslation", type: "assocarray", hidden: true },
+        { name: "TargetRotation", type: "float", hidden: true },
+        { name: "CompositionMode", type: "string", hidden: true },
+        { name: "Text", type: "string", hidden: true },
+        { name: "TextAttrs", type: "assocarray", hidden: true },
+    ];
+
+    /**
+     * This creates an easy way to track whether a field is a metadata field or not.
+     * The reason to keep track is because metadata fields should print out in all caps.
+     */
+    private metaDataFields = new Set<string>(
+        this.defaultFields.map(field => field.name.toLowerCase())
+    );
+
+    constructor(readonly name: string = "ContentNode") {
+        super([], name);
+
+        this.registerDefaultFields(this.defaultFields);
+        this.registerMethods({
+            ifAssociativeArray: [this.count, this.keys, this.items],
+            ifSGNodeField: [this.hasfield],
+        });
+    }
+
+    private getVisibleFields() {
+        let fields = this.getFields();
+        return Array.from(fields).filter(([key, value]) => !value.isHidden());
+    }
+
+    /** @override */
+    toString(parent?: BrsType): string {
+        let componentName = "roSGNode:" + this.nodeSubtype;
+
+        if (parent) {
+            return `<Component: ${componentName}>`;
+        }
+
+        let fields = this.getVisibleFields();
+        return [
+            `<Component: ${componentName}> =`,
+            "{",
+            ...fields.map(([key, value]) => {
+                // If it's a meta-data field, it should print out in all caps.
+                key = this.metaDataFields.has(key.toLowerCase()) ? key.toUpperCase() : key;
+                return `    ${key}: ${value.toString(this)}`;
+            }),
+            "}",
+        ].join("\n");
+    }
+
+    /** @override */
+    getElements() {
+        return this.getVisibleFields()
+            .map(([key, value]) => key)
+            .sort()
+            .map(key => new BrsString(key));
+    }
+
+    /** @override */
+    getValues() {
+        return this.getVisibleFields()
+            .map(([key, value]) => value)
+            .sort()
+            .map((field: Field) => field.getValue());
+    }
+
+    /**
+     * @override
+     * Returns the number of visible fields in the node.
+     */
+    protected count = new Callable("count", {
+        signature: {
+            args: [],
+            returns: ValueKind.Int32,
+        },
+        impl: (interpreter: Interpreter) => {
+            return new Int32(this.getVisibleFields().length);
+        },
+    });
+
+    /**
+     * @override
+     * Returns an array of visible keys from the node in lexicographical order.
+     */
+    protected keys = new Callable("keys", {
+        signature: {
+            args: [],
+            returns: ValueKind.Object,
+        },
+        impl: (interpreter: Interpreter) => {
+            return new RoArray(this.getElements());
+        },
+    });
+
+    /**
+     * @override
+     * Returns an array of visible values from the node in lexicographical order.
+     */
+    protected items = new Callable("items", {
+        signature: {
+            args: [],
+            returns: ValueKind.Object,
+        },
+        impl: (interpreter: Interpreter) => {
+            return new RoArray(
+                this.getElements().map((key: BrsString) => {
+                    return new RoAssociativeArray([
+                        {
+                            name: new BrsString("key"),
+                            value: key,
+                        },
+                        {
+                            name: new BrsString("value"),
+                            value: this.get(key),
+                        },
+                    ]);
+                })
+            );
+        },
+    });
+
+    /**
+     * @override
+     * Returns true if the field exists. Marks the field as not hidden.
+     */
+    protected hasfield = new Callable("hasfield", {
+        signature: {
+            args: [new StdlibArgument("fieldname", ValueKind.String)],
+            returns: ValueKind.Boolean,
+        },
+        impl: (interpreter: Interpreter, fieldname: BrsString) => {
+            let field = this.getFields().get(fieldname.value.toLowerCase());
+            if (field) {
+                field.setHidden(false);
+                return BrsBoolean.True;
+            } else {
+                return BrsBoolean.False;
+            }
+        },
+    });
+}
diff --git a/src/brsTypes/components/RoSGNode.ts b/src/brsTypes/components/RoSGNode.ts
index 4c3ffd9..7846ccf 100644
--- a/src/brsTypes/components/RoSGNode.ts
+++ b/src/brsTypes/components/RoSGNode.ts
@@ -30,14 +30,14 @@ interface BrsCallback {
     };
 }
 
-export type FieldModel = { name: string; type: string; value?: string };
+export type FieldModel = { name: string; type: string; value?: string; hidden?: boolean };
 
 export class Field {
     private type: string;
     private value: BrsType;
     private observers: BrsCallback[] = [];
 
-    constructor(value: BrsType, private alwaysNotify: boolean) {
+    constructor(value: BrsType, private alwaysNotify: boolean, private hidden: boolean = false) {
         this.type = ValueKind.toString(value.kind);
         this.value = value;
     }
@@ -46,15 +46,36 @@ export class Field {
         return this.value.toString(parent);
     }
 
+    /**
+     * Returns whether or not the field is "hidden".
+     *
+     * The reason for this is that some fields (content metadata fields) are
+     * by default "hidden". This means they are accessible on the
+     * node without an access error, but they don't show up when you print the node.
+     */
+    isHidden() {
+        return this.hidden;
+    }
+
+    setHidden(isHidden: boolean) {
+        this.hidden = isHidden;
+    }
+
     getType(): string {
         return this.type;
     }
 
     getValue(): BrsType {
+        // Once a field is accessed, it is no longer hidden.
+        this.hidden = false;
+
         return this.value;
     }
 
     setValue(value: BrsType) {
+        // Once a field is set, it is no longer hidden.
+        this.hidden = false;
+
         let oldValue = this.value;
         this.value = value;
         if (this.alwaysNotify || oldValue !== value) {
@@ -68,6 +89,9 @@ export class Field {
         node: RoSGNode,
         fieldName: BrsString
     ) {
+        // Once a field is accessed, it is no longer hidden.
+        this.hidden = false;
+
         let brsCallback: BrsCallback = {
             interpreter,
             environment: interpreter.environment,
@@ -429,7 +453,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
     });
 
     /** Returns the number of items in the node */
-    private count = new Callable("count", {
+    protected count = new Callable("count", {
         signature: {
             args: [],
             returns: ValueKind.Int32,
@@ -472,7 +496,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
     });
 
     /** Returns an array of keys from the node in lexicographical order */
-    private keys = new Callable("keys", {
+    protected keys = new Callable("keys", {
         signature: {
             args: [],
             returns: ValueKind.Object,
@@ -482,14 +506,27 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
         },
     });
 
-    /** Returns an array of values from the node in lexicographical order */
-    private items = new Callable("items", {
+    /** Returns an array of key/value pairs in lexicographical order of key. */
+    protected items = new Callable("items", {
         signature: {
             args: [],
             returns: ValueKind.Object,
         },
         impl: (interpreter: Interpreter) => {
-            return new RoArray(this.getValues());
+            return new RoArray(
+                this.getElements().map((key: BrsString) => {
+                    return new RoAssociativeArray([
+                        {
+                            name: new BrsString("key"),
+                            value: key,
+                        },
+                        {
+                            name: new BrsString("value"),
+                            value: this.get(key),
+                        },
+                    ]);
+                })
+            );
         },
     });
 
@@ -565,7 +602,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
     });
 
     /** Returns true if the field exists */
-    private hasfield = new Callable("hasfield", {
+    protected hasfield = new Callable("hasfield", {
         signature: {
             args: [new StdlibArgument("fieldname", ValueKind.String)],
             returns: ValueKind.Boolean,
@@ -1149,7 +1186,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
         fields.forEach(field => {
             this.fields.set(
                 field.name.toLowerCase(),
-                new Field(getBrsValueFromFieldType(field.type, field.value), false)
+                new Field(getBrsValueFromFieldType(field.type, field.value), false, field.hidden)
             );
         });
     }
diff --git a/src/brsTypes/index.ts b/src/brsTypes/index.ts
index ecf718d..e490a4d 100644
--- a/src/brsTypes/index.ts
+++ b/src/brsTypes/index.ts
@@ -51,6 +51,7 @@ export * from "./components/Font";
 export * from "./components/Poster";
 export * from "./components/ArrayGrid";
 export * from "./components/MarkupGrid";
+export * from "./components/ContentNode";
 
 /**
  * Determines whether or not the given value is a number.


Test patch:
diff --git a/test/brsTypes/components/ContentNode.test.js b/test/brsTypes/components/ContentNode.test.js
new file mode 100644
index 0000000..3c2ffac
--- /dev/null
+++ b/test/brsTypes/components/ContentNode.test.js
@@ -0,0 +1,254 @@
+const brs = require("brs");
+const { ContentNode, BrsString, RoAssociativeArray } = brs.types;
+const { Interpreter } = require("../../../lib/interpreter");
+
+describe("ContentNode.js", () => {
+    describe("stringification", () => {
+        it("inits a new ContentNode component", () => {
+            let node = new ContentNode();
+
+            expect(node.toString()).toEqual(
+                `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+}`
+            );
+        });
+    });
+
+    describe("hidden fields", () => {
+        let interpreter;
+        beforeAll(() => {
+            interpreter = new Interpreter();
+        });
+
+        describe("are initially hidden from", () => {
+            it("'count'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("count");
+                let result = func.call(interpreter);
+
+                expect(result.value).toEqual(4);
+            });
+
+            it("'keys'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("keys");
+                let result = func.call(interpreter);
+
+                expect(result.getElements().length).toEqual(4);
+            });
+
+            it("'items'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("items");
+                let result = func.call(interpreter);
+
+                expect(result.getElements().length).toEqual(4);
+            });
+        });
+
+        describe("can be accessed from", () => {
+            it("'getField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("getField");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                expect(result.value).toEqual("");
+            });
+
+            it("'hasField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("hasField");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                expect(result.value).toEqual(true);
+            });
+
+            it("'setField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("setField");
+                let result = func.call(
+                    interpreter,
+                    new BrsString("description"),
+                    new BrsString("new value")
+                );
+
+                expect(result.value).toEqual(true);
+
+                let getField = node.getMethod("getField");
+                let fieldVal = getField.call(interpreter, new BrsString("description"));
+
+                expect(fieldVal.value).toEqual("new value");
+            });
+
+            it("'update'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("update");
+                let result = func.call(
+                    interpreter,
+                    new RoAssociativeArray([
+                        { name: new BrsString("description"), value: new BrsString("new value") },
+                    ])
+                );
+
+                let getField = node.getMethod("getField");
+                let fieldVal = getField.call(interpreter, new BrsString("description"));
+
+                expect(fieldVal.value).toEqual("new value");
+            });
+
+            it("'doesExist'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("doesExist");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                expect(result.value).toEqual(true);
+            });
+
+            it("'lookup'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("lookup");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                expect(result.value).toEqual("");
+            });
+        });
+
+        describe("are treated like normal fields after being accessed from", () => {
+            function validateOtherMemberFuncs(node) {
+                let count = node.getMethod("count");
+                let countResult = count.call(interpreter);
+                expect(countResult.value).toEqual(5);
+
+                let keys = node.getMethod("keys");
+                let keysResult = keys.call(interpreter);
+                expect(keysResult.getElements().length).toEqual(5);
+
+                let items = node.getMethod("items");
+                let itemsResult = items.call(interpreter);
+                expect(itemsResult.getElements().length).toEqual(5);
+            }
+
+            it("'getField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("getField");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: 
+}`
+                );
+            });
+
+            it("'hasField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("hasField");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: 
+}`
+                );
+            });
+
+            it("'setField'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("setField");
+                let result = func.call(
+                    interpreter,
+                    new BrsString("description"),
+                    new BrsString("new value")
+                );
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: new value
+}`
+                );
+            });
+
+            it("'update'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("update");
+                let result = func.call(
+                    interpreter,
+                    new RoAssociativeArray([
+                        { name: new BrsString("description"), value: new BrsString("new value") },
+                    ])
+                );
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: new value
+}`
+                );
+            });
+
+            it("'doesExist'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("doesExist");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: 
+}`
+                );
+            });
+
+            it("'lookup'", () => {
+                let node = new ContentNode();
+                let func = node.getMethod("lookup");
+                let result = func.call(interpreter, new BrsString("description"));
+
+                validateOtherMemberFuncs(node);
+                expect(node.toString()).toEqual(
+                    `<Component: roSGNode:ContentNode> =
+{
+    change: <UNINITIALIZED>
+    focusable: false
+    focusedchild: invalid
+    id: 
+    DESCRIPTION: 
+}`
+                );
+            });
+        });
+    });
+});
diff --git a/test/brsTypes/components/RoSGNode.test.js b/test/brsTypes/components/RoSGNode.test.js
index c41ea0d..8c73bc0 100644
--- a/test/brsTypes/components/RoSGNode.test.js
+++ b/test/brsTypes/components/RoSGNode.test.js
@@ -272,10 +272,9 @@ describe("RoSGNode", () => {
         });
 
         describe("items", () => {
-            it("returns an array of values from the associative array in lexicographical order", () => {
-                let change = BrsBoolean.False;
-                let focusable = BrsInvalid.Instance;
-                let focusedChild = Uninitialized.Instance;
+            it("returns an array of key-value pairs from the associative array in lexicographical order", () => {
+                let focusable = BrsBoolean.False;
+                let focusedChild = BrsInvalid.Instance;
                 let id = new BrsString("");
                 let cletter = new BrsString("c");
                 let letter1 = new BrsString("a");
@@ -287,33 +286,56 @@ describe("RoSGNode", () => {
                     { name: new BrsString("letter2"), value: letter2 },
                 ]);
 
+                let change = node
+                    .getFields()
+                    .get("change")
+                    .getValue();
+
                 let items = node.getMethod("items");
                 expect(items).toBeTruthy();
+
                 let result = items.call(interpreter);
-                expect(result.getElements()).toEqual([
-                    id,
+                let keyValPairs = result.getElements();
+                let expectedValues = [
+                    change,
+                    cletter,
+                    focusable,
                     focusedChild,
+                    id,
                     letter1,
                     letter2,
-                    cletter,
-                    change,
-                    focusable,
-                ]);
+                ];
+
+                expect(keyValPairs.length).toEqual(expectedValues.length);
+                expectedValues.forEach((expectedValue, index) => {
+                    let actualValue = keyValPairs[index].get(new BrsString("value"));
+                    expect(actualValue).toEqual(expectedValue);
+                });
             });
 
             it("returns an empty array from an empty associative array", () => {
-                let change = BrsBoolean.False;
-                let focusable = BrsInvalid.Instance;
-                let focusedChild = Uninitialized.Instance;
+                let focusable = BrsBoolean.False;
+                let focusedChild = BrsInvalid.Instance;
                 let id = new BrsString("");
 
                 let node = new RoSGNode([]);
+                let change = node
+                    .getFields()
+                    .get("change")
+                    .getValue();
 
                 let items = node.getMethod("items");
                 expect(items).toBeTruthy();
 
                 let result = items.call(interpreter);
-                expect(result.getElements()).toEqual([id, focusedChild, change, focusable]);
+                let keyValPairs = result.getElements();
+                let expectedValues = [change, focusable, focusedChild, id];
+
+                expect(keyValPairs.length).toEqual(expectedValues.length);
+                expectedValues.forEach((expectedValue, index) => {
+                    let actualValue = keyValPairs[index].get(new BrsString("value"));
+                    expect(actualValue).toEqual(expectedValue);
+                });
             });
         });
     });
diff --git a/test/e2e/BrsComponents.test.js b/test/e2e/BrsComponents.test.js
index eaf0ff9..21584f5 100644
--- a/test/e2e/BrsComponents.test.js
+++ b/test/e2e/BrsComponents.test.js
@@ -605,4 +605,23 @@ describe("end to end brightscript functions", () => {
             "Warning calling function in CallFuncComponent: no function interface specified for componentPrivateFunction",
         ]);
     });
+
+    test("components/ContentNode.brs", async () => {
+        await execute([resourceFile("components", "scripts", "ContentNode.brs")], outputStreams);
+
+        expect(allArgs(outputStreams.stdout.write).filter(arg => arg !== "\n")).toEqual([
+            "contentnode node type:",
+            "Node",
+            "contentnode node subtype:",
+            "ContentNode",
+            "contentnode.ContentType:",
+            "",
+            "contentnode.TargetRotation:",
+            "0",
+            "contentnodeAsChild.episodeNumber:",
+            "10",
+            "contentnodeAsChild.subtitleUrl:",
+            "subtitle.example.com",
+        ]);
+    });
 });
diff --git a/test/e2e/resources/components/ComponentsAsChildren.xml b/test/e2e/resources/components/ComponentsAsChildren.xml
index ed60f8c..8dff765 100644
--- a/test/e2e/resources/components/ComponentsAsChildren.xml
+++ b/test/e2e/resources/components/ComponentsAsChildren.xml
@@ -27,6 +27,10 @@
                 numColumns="10"
                 fixedLayout="true"
             />
+            <ContentNode id="contentnode" 
+                episodeNumber="10"
+                subtitleUrl="subtitle.example.com"
+            />
         </Group>
     </children>
 </component>
diff --git a/test/e2e/resources/components/scripts/ContentNode.brs b/test/e2e/resources/components/scripts/ContentNode.brs
new file mode 100644
index 0000000..02ab462
--- /dev/null
+++ b/test/e2e/resources/components/scripts/ContentNode.brs
@@ -0,0 +1,15 @@
+sub Main()
+    contentnode = createObject("roSGNode", "ContentNode")
+    print "contentnode node type:" type(contentnode)
+    print "contentnode node subtype:" contentnode.subtype()
+
+    ' These are initially unset + hidden, but still accessible, so printing them out verifies that
+    ' we don't throw an access error.
+    print "contentnode.ContentType:" contentnode.ContentType
+    print "contentnode.TargetRotation:" contentnode.TargetRotation
+
+    parent = createObject("roSGNode", "ComponentsAsChildren")
+    contentnodeAsChild = parent.findNode("contentnode")
+    print "contentnodeAsChild.episodeNumber:" contentnodeAsChild.episodeNumber
+    print "contentnodeAsChild.subtitleUrl:" contentnodeAsChild.subtitleUrl
+end sub