diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000..ad492446b --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,186 @@ +const WARN = "warn"; +const ERROR = "error"; +const OFF = "off"; + +const COMMON_CONFIG = { + plugins: ["jsdoc", "filenames", "import", "prettier"], + extends: ["prettier", "plugin:prettier/recommended"], + rules: { + "prettier/prettier": WARN, + "no-underscore-dangle": OFF, + "no-debugger": WARN, + "space-infix-ops": ERROR, + "no-console": WARN, + "wrap-iife": OFF, + "no-self-assign": ERROR, + "no-self-compare": ERROR, + "no-loop-func": OFF, + "array-callback-return": ERROR, + curly: ERROR, + "no-fallthrough": OFF, + "dot-notation": OFF, + "prefer-const": WARN, + "no-empty-function": OFF, + "no-with": ERROR, + "one-var": [ERROR, "never"], + camelcase: [WARN, { properties: "always", ignoreImports: true }], + "spaced-comment": [WARN, "always"], + "capitalized-comments": [WARN, "always", { ignorePattern: "prettier" }], + "no-useless-rename": WARN, + "jsdoc/check-alignment": WARN, + "jsdoc/check-examples": OFF, + "jsdoc/check-indentation": WARN, + "jsdoc/check-syntax": WARN, + "jsdoc/check-tag-names": WARN, + "jsdoc/check-types": WARN, + "jsdoc/implements-on-classes": WARN, + "jsdoc/match-description": OFF, + "jsdoc/newline-after-description": WARN, + "jsdoc/no-types": OFF, + "jsdoc/no-undefined-types": OFF, + "jsdoc/require-description": OFF, + "jsdoc/require-description-complete-sentence": OFF, + "jsdoc/require-example": OFF, + "jsdoc/require-hyphen-before-param-description": [WARN, "never"], + "jsdoc/require-param": WARN, + "jsdoc/require-param-name": WARN, + "jsdoc/require-returns-check": WARN, + "jsdoc/require-returns-description": WARN, + "jsdoc/valid-types": WARN, + "filenames/match-exported": WARN, + "no-useless-constructor": WARN, + "jsdoc/require-jsdoc": [ + WARN, + { + require: { + ArrowFunctionExpression: false, + FunctionDeclaration: false, + FunctionExpression: false, + ClassDeclaration: true, + MethodDefinition: true, + }, + }, + ], + "import/no-extraneous-dependencies": WARN, + }, +}; + +const TS_PARSER_FIELDS = { + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2020, + sourceType: "module", + }, +}; + +module.exports = { + env: { + es6: true, + browser: true, + node: true, + }, + overrides: [ + { + files: ["*.js", "*.jsx", "*.mjs"], + ...TS_PARSER_FIELDS, + plugins: COMMON_CONFIG.plugins, + extends: COMMON_CONFIG.extends, + rules: { + ...COMMON_CONFIG.rules, + "no-undef": ERROR, + "jsdoc/check-param-names": WARN, + "jsdoc/require-param-type": WARN, + "jsdoc/require-returns": WARN, + "jsdoc/require-param-description": WARN, + "jsdoc/require-returns-type": WARN, + "consistent-return": WARN, + }, + }, + { + files: ["*.json"], + plugins: ["json"], + extends: ["eslint:recommended", "plugin:json/recommended"], + }, + { + files: ["*.ts", "*.tsx"], + plugins: [...COMMON_CONFIG.plugins, "@typescript-eslint"], + ...TS_PARSER_FIELDS, + extends: [ + ...COMMON_CONFIG.extends, + "plugin:@typescript-eslint/recommended", + ], + rules: { + ...COMMON_CONFIG.rules, + "@typescript-eslint/explicit-member-accessibility": [ + ERROR, + { overrides: { constructors: "no-public" } }, + ], + "@typescript-eslint/no-unused-vars": OFF, // TSC is already doing this + "@typescript-eslint/ban-types": OFF, // TSC is already doing this + "no-undef": OFF, // TSC is already doing this + "@typescript-eslint/no-var-requires": OFF, + "@typescript-eslint/explicit-module-boundary-types": OFF, // TSC is already doing this + "@typescript-eslint/consistent-type-assertions": [ + WARN, + { assertionStyle: "angle-bracket" }, + ], + "@typescript-eslint/no-explicit-any": OFF, + "@typescript-eslint/no-empty-function": OFF, + "@typescript-eslint/no-use-before-define": OFF, + "@typescript-eslint/no-this-alias": OFF, + "@typescript-eslint/explicit-function-return-type": [ + ERROR, + { allowExpressions: true }, + ], + "@typescript-eslint/member-ordering": [ + WARN, + { + default: [ + "signature", + "field", + "constructor", + ["get", "set"], + "method", + ], + }, + ], + "@typescript-eslint/ban-ts-comment": OFF, + "jsdoc/no-types": WARN, + "import/named": OFF, + "import/no-named-as-default": WARN, + "import/no-extraneous-dependencies": WARN, + "import/no-absolute-path": WARN, + "@typescript-eslint/naming-convention": [ + WARN, + { + selector: "interface", + format: ["PascalCase"], + prefix: ["I"], + }, + { + selector: "enum", + format: ["PascalCase"], + suffix: ["Enum"], + }, + { + selector: "typeParameter", + format: ["PascalCase"], + }, + { + selector: "memberLike", + modifiers: ["private"], + leadingUnderscore: "allow", + format: ["camelCase"], + }, + { + selector: "class", + format: ["PascalCase"], + }, + ], + }, + }, + ], +}; diff --git a/.huskyrc.json b/.huskyrc.json index 4375e705c..6aff645db 100644 --- a/.huskyrc.json +++ b/.huskyrc.json @@ -1,6 +1,6 @@ { "hooks": { - "pre-commit": "npm run lint:fix", + "pre-commit": "npm run lint:changed", "commit-msg": "node ./bin/husky-commit-message.js --filepath=$HUSKY_GIT_PARAMS%HUSKY_GIT_PARAMS%" } } diff --git a/.lintignore b/.lintignore new file mode 100755 index 000000000..debffe068 --- /dev/null +++ b/.lintignore @@ -0,0 +1,5 @@ +**/node_modules +**/tmp +**/lib +**/cjs +**/tmp \ No newline at end of file diff --git a/bin/generate-release-notes.js b/bin/generate-release-notes.js index 6bd2b03ce..40e84336e 100644 --- a/bin/generate-release-notes.js +++ b/bin/generate-release-notes.js @@ -1,59 +1,77 @@ #!/bin/env node -'use strict'; +"use strict"; -const GitUtility = require('../build/GitUtility'); -const FS = require('fs'); -const Path = require('path'); +const GitUtility = require("../build/GitUtility"); +const FS = require("fs"); +const Path = require("path"); -process.on('unhandledRejection', error => { +/* eslint-disable no-console*/ + +process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); }); async function main() { - const nextVersion = process.argv.find(text => text.startsWith('--next-version=')).split('=').reverse()[0]; - const latestVersion = process.argv.find(text => text.startsWith('--latest-version=')).split('=').reverse()[0]; + const nextVersion = process.argv + .find((text) => text.startsWith("--next-version=")) + .split("=") + .reverse()[0]; + const latestVersion = process.argv + .find((text) => text.startsWith("--latest-version=")) + .split("=") + .reverse()[0]; - const commitMessages = await GitUtility.getCommitMessages('v' + latestVersion, 'v' + nextVersion); + const commitMessages = await GitUtility.getCommitMessages( + "v" + latestVersion, + "v" + nextVersion + ); const commits = { trivial: [], patch: [], minor: [], major: [] }; const duplicates = { trivial: [], patch: [], minor: [], major: [] }; - + for (const commitMessage of commitMessages) { const parsed = GitUtility.parseCommitMessage(commitMessage); - - if (parsed.commit && !parsed.errors.length && !duplicates[parsed.commit.versionType].includes(parsed.commit.description)) { + + if ( + parsed.commit && + !parsed.errors.length && + !duplicates[parsed.commit.versionType].includes(parsed.commit.description) + ) { commits[parsed.commit.versionType].push(parsed.commit); duplicates[parsed.commit.versionType].push(parsed.commit.description); } } - - let output = []; - - if(commits.major.length > 0) { - let notes = '### :bomb: Breaking Changes\n\n' - for(const commit of commits.major) { + + const output = []; + + if (commits.major.length > 0) { + let notes = "### :bomb: Breaking Changes\n\n"; + for (const commit of commits.major) { notes += ` - ${commit.description} (${commit.taskId})\n`; } output.push(notes); } - - if(commits.minor.length > 0) { - let notes = '### :art: Features\n\n' - for(const commit of commits.minor) { + + if (commits.minor.length > 0) { + let notes = "### :art: Features\n\n"; + for (const commit of commits.minor) { notes += ` - ${commit.description} (${commit.taskId})\n`; } output.push(notes); } - - if(commits.patch.length > 0) { - let notes = '### :construction_worker_man: Patch fixes\n\n'; - for(const commit of commits.patch) { + + if (commits.patch.length > 0) { + let notes = "### :construction_worker_man: Patch fixes\n\n"; + for (const commit of commits.patch) { notes += ` - ${commit.description} (${commit.taskId})\n`; } output.push(notes); } - - await FS.promises.writeFile(Path.resolve('RELEASE_NOTES.md'), output.join('\n\n')); + + await FS.promises.writeFile( + Path.resolve("RELEASE_NOTES.md"), + output.join("\n\n") + ); } -main(); \ No newline at end of file +main(); diff --git a/bin/husky-commit-message.js b/bin/husky-commit-message.js index 79f688a61..b7aa9b4be 100644 --- a/bin/husky-commit-message.js +++ b/bin/husky-commit-message.js @@ -1,41 +1,47 @@ #!/bin/env node -'use strict'; +"use strict"; -const Fs = require('fs'); -const Path = require('path'); -const Chalk = require('chalk'); -const GitUtility = require('../build/GitUtility'); +const Fs = require("fs"); +const Path = require("path"); +const Chalk = require("chalk"); +const GitUtility = require("../build/GitUtility"); -process.on('unhandledRejection', error => { +/* eslint-disable no-console*/ + +process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); }); async function main() { - const argument = process.argv.find(text => text.startsWith('--filepath=')); - const filepath = argument ? argument - .split(/=/)[1] - .replace('%HUSKY_GIT_PARAMS%', '') - .replace('$HUSKY_GIT_PARAMS', '') : null; + const argument = process.argv.find((text) => text.startsWith("--filepath=")); + const filepath = argument + ? argument + .split(/=/)[1] + .replace("%HUSKY_GIT_PARAMS%", "") + .replace("$HUSKY_GIT_PARAMS", "") + : null; if (!filepath) { - throw new Error('Failed to validate commit message. The argument "--filepath=" was not provided.'); + throw new Error( + 'Failed to validate commit message. The argument "--filepath=" was not provided.' + ); } const commitBuffer = await Fs.promises.readFile(Path.resolve(filepath)); const commitMessage = GitUtility.parseCommitMessage(commitBuffer.toString()); if (commitMessage.errors.length > 0) { - console.error(Chalk.red('\nCommit message validation failed:')); + console.error(Chalk.red("\nCommit message validation failed:")); for (const error of commitMessage.errors) { console.error(Chalk.red(` ✖ ${error}`)); } - console.log(''); + console.log(""); process.exit(1); } } -main(); \ No newline at end of file +main(); diff --git a/bin/print-latest-version.js b/bin/print-latest-version.js index 776802a7b..2abdc24c4 100644 --- a/bin/print-latest-version.js +++ b/bin/print-latest-version.js @@ -1,9 +1,11 @@ #!/bin/env node -'use strict'; +"use strict"; -const GitUtility = require('../build/GitUtility'); +const GitUtility = require("../build/GitUtility"); -process.on('unhandledRejection', error => { +/* eslint-disable no-console*/ + +process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); }); @@ -13,4 +15,4 @@ async function main() { process.stdout.write(latestVersion); } -main(); \ No newline at end of file +main(); diff --git a/bin/print-next-version.js b/bin/print-next-version.js index c2e5d2ea7..46e1896a2 100644 --- a/bin/print-next-version.js +++ b/bin/print-next-version.js @@ -1,9 +1,11 @@ #!/bin/env node -'use strict'; +"use strict"; -const GitUtility = require('../build/GitUtility'); +const GitUtility = require("../build/GitUtility"); -process.on('unhandledRejection', error => { +/* eslint-disable no-console*/ + +process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); }); @@ -13,4 +15,4 @@ async function main() { process.stdout.write(nextVersion); } -main(); \ No newline at end of file +main(); diff --git a/bin/set-version.js b/bin/set-version.js index da982b623..17f68a0cd 100644 --- a/bin/set-version.js +++ b/bin/set-version.js @@ -4,6 +4,8 @@ const FS = require("fs"); const Path = require("path"); +/* eslint-disable no-console*/ + process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); diff --git a/bin/validate-commit-messages.js b/bin/validate-commit-messages.js index 3a68880c8..a1ea9b518 100644 --- a/bin/validate-commit-messages.js +++ b/bin/validate-commit-messages.js @@ -1,36 +1,41 @@ #!/bin/env node -'use strict'; +"use strict"; -const GitUtility = require('../build/GitUtility'); -const Chalk = require('chalk'); +const GitUtility = require("../build/GitUtility"); +const Chalk = require("chalk"); -process.on('unhandledRejection', error => { +/* eslint-disable no-console*/ + +process.on("unhandledRejection", (error) => { console.error(error); process.exit(1); }); async function main() { - const commitMessages = await GitUtility.getCommitMessages('origin/master', 'HEAD'); + const commitMessages = await GitUtility.getCommitMessages( + "origin/master", + "HEAD" + ); let hasErrors = false; for (const commitMessage of commitMessages) { const parsed = GitUtility.parseCommitMessage(commitMessage); if (!parsed.errors.length) { - console.log(Chalk.green('✓ ' + commitMessage)); + console.log(Chalk.green("✓ " + commitMessage)); } else { - console.log(Chalk.red('✖' + commitMessage)); - for(const error of parsed.errors) { + console.log(Chalk.red("✖" + commitMessage)); + for (const error of parsed.errors) { console.log(Chalk.red(` - ${error}`)); } hasErrors = true; } } - if(hasErrors) { - console.error('Commit validation failed.'); + if (hasErrors) { + console.error("Commit validation failed."); process.exit(1); } } -main(); \ No newline at end of file +main(); diff --git a/build/GitUtility.js b/build/GitUtility.js index 1db975a46..06beb1d50 100644 --- a/build/GitUtility.js +++ b/build/GitUtility.js @@ -1,71 +1,84 @@ -const ChildProcess = require('child_process'); +const ChildProcess = require("child_process"); +const SemVer = require("semver"); const COMMIT_MESSAGE_REGEXP = /(#[0-9]+)@([^:]+): (.+)/ms; -const VERSION_TYPES = ['trivial', 'patch', 'minor', 'major']; +const VERSION_TYPES = ["trivial", "patch", "minor", "major"]; /** * Git Utility. */ class GitUtility { - /** * Returns commit messages. - * + * * @param {string} from From branch. * @param {string} to To branch. * @returns {string[]} Commit messages. */ static async getCommitMessages(from, to) { const commits = await this.__getDiffCommits(from, to); - return await Promise.all(commits.map(commit => { - return new Promise((resolve, reject) => { - ChildProcess.exec(`git show -s --format=%B ${commit}`, (error, stdout) => { - if(error) { - reject(error); - } else { - resolve(stdout.replace(/[\n\r]/gm, '').trim()); - } + return await Promise.all( + commits.map((commit) => { + return new Promise((resolve, reject) => { + ChildProcess.exec( + `git show -s --format=%B ${commit}`, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout.replace(/[\n\r]/gm, "").trim()); + } + } + ); }); - }); - })); + }) + ); } /** * Parses a commit message. - * + * * @param {string} commitMessage Commit message. * @returns {{ errors: string[]; commit: { taskId: string; versionType: string; description: string; } }} Parsed commit information. */ static parseCommitMessage(commitMessage) { - if(commitMessage.startsWith('Merge')) { - return { - errors: [], - commit: null - }; - } + if (commitMessage.startsWith("Merge")) { + return { + errors: [], + commit: null, + }; + } const match = commitMessage.match(COMMIT_MESSAGE_REGEXP); const errors = []; - if(!match) { + if (!match) { return { - errors: ['Invalid format. Expected format to be: "#{taskId}@{trivial|patch|minor|major}: {description}."'], - commit: null + errors: [ + 'Invalid format. Expected format to be: "#{taskId}@{trivial|patch|minor|major}: {description}."', + ], + commit: null, }; } const description = match[3].trim(); - if(!VERSION_TYPES.includes(match[2])) { - errors.push(`Invalid version type. Valid version types: ${VERSION_TYPES.join(', ')}`); + if (!VERSION_TYPES.includes(match[2])) { + errors.push( + `Invalid version type. Valid version types: ${VERSION_TYPES.join(", ")}` + ); } - if(description[0] !== description[0].toUpperCase()) { - errors.push(`Invalid description. Expected description to start with a capital letter`); + if (description[0] !== description[0].toUpperCase()) { + errors.push( + `Invalid description. Expected description to start with a capital letter` + ); } - if(!description.endsWith('.')) { - errors.push(`Invalid description. Expected description to end with a period (".")`); + if (!description.endsWith(".")) { + errors.push( + `Invalid description. Expected description to end with a period (".")` + ); } return { @@ -73,36 +86,36 @@ class GitUtility { commit: { taskId: match[1], versionType: match[2], - description - } + description, + }, }; } /** * Returns the next version. - * + * * @returns {string} Next version (e.g. "v1.2.3"). */ static async getNextVersion() { const latest = await this.getLatestVersion(); - const versionType = await this.__getVersionType('v' + latest, 'HEAD'); - return require('semver').inc(latest, versionType); + const versionType = await this.__getVersionType("v" + latest, "HEAD"); + return SemVer.inc(latest, versionType); } /** * Returns the latest version. - * + * * @returns {string} Latest version (e.g. "v1.2.3"). */ static getLatestVersion() { return new Promise((resolve, reject) => { ChildProcess.exec(`git tag -l --sort=v:refname`, (error, stdout) => { - if(error) { + if (error) { reject(error); } else { - const gitTags = stdout.trim().split('\n'); - gitTags.sort(require('semver').compare); - resolve(gitTags[gitTags.length - 1].replace('v', '')); + const gitTags = stdout.trim().split("\n"); + gitTags.sort(SemVer.compare); + resolve(gitTags[gitTags.length - 1].replace("v", "")); } }); }); @@ -116,21 +129,21 @@ class GitUtility { static async __getVersionType(from, to) { const commitMessages = await this.getCommitMessages(from, to); let isMinor = false; - + for (const commitMessage of commitMessages) { const parsed = this.parseCommitMessage(commitMessage); - + if (parsed.commit && parsed.commit.versionType) { - if(parsed.commit.versionType === 'major') { - return 'major'; + if (parsed.commit.versionType === "major") { + return "major"; } - if(parsed.commit.versionType === 'minor') { + if (parsed.commit.versionType === "minor") { isMinor = true; } } } - return isMinor ? 'minor' : 'patch'; + return isMinor ? "minor" : "patch"; } /** @@ -142,16 +155,21 @@ class GitUtility { */ static __getDiffCommits(from, to) { return new Promise((resolve, reject) => { - ChildProcess.exec(`git log ${from}..${to} --pretty=format:"%h"`, (error, stdout) => { - if(error) { - reject(error); - } else { - resolve(stdout - .trim() - .split('\n') - .filter(str => str !== '')); + ChildProcess.exec( + `git log ${from}..${to} --pretty=format:"%h"`, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolve( + stdout + .trim() + .split("\n") + .filter((str) => str !== "") + ); + } } - }); + ); }); } } diff --git a/package-lock.json b/package-lock.json index bb5256f88..abad5394c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "husky": "2.3.0", "prettier": "^2.6.0", "semver": "^7.3.5", - "turbo": "^1.7.3", + "turbo": "^1.11.3", "typescript": "^5.0.4", "vitest": "^0.32.4" }, @@ -5313,30 +5313,6 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-plugin-jest": { - "version": "26.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-26.9.0.tgz", - "integrity": "sha512-TWJxWGp1J628gxh2KhaH1H1paEdgE2J61BBF1I59c6xWeL5+D1BzMxGDN/nXAfX+aSkR5u80K+XhskK6Gwq9ng==", - "dev": true, - "dependencies": { - "@typescript-eslint/utils": "^5.10.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, "node_modules/eslint-plugin-jsdoc": { "version": "38.1.6", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-38.1.6.tgz", @@ -5393,15 +5369,6 @@ } } }, - "node_modules/eslint-plugin-turbo": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-0.0.7.tgz", - "integrity": "sha512-iajOH8eD4jha3duztGVBD1BEmvNrQBaA/y3HFHf91vMDRYRwH7BpHSDFtxydDpk5ghlhRxG299SFxz7D6z4MBQ==", - "dev": true, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -11034,26 +11001,26 @@ } }, "node_modules/turbo": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-1.10.14.tgz", - "integrity": "sha512-hr9wDNYcsee+vLkCDIm8qTtwhJ6+UAMJc3nIY6+PNgUTtXcQgHxCq8BGoL7gbABvNWv76CNbK5qL4Lp9G3ZYRA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-1.11.3.tgz", + "integrity": "sha512-RCJOUFcFMQNIGKSjC9YmA5yVP1qtDiBA0Lv9VIgrXraI5Da1liVvl3VJPsoDNIR9eFMyA/aagx1iyj6UWem5hA==", "dev": true, "bin": { "turbo": "bin/turbo" }, "optionalDependencies": { - "turbo-darwin-64": "1.10.14", - "turbo-darwin-arm64": "1.10.14", - "turbo-linux-64": "1.10.14", - "turbo-linux-arm64": "1.10.14", - "turbo-windows-64": "1.10.14", - "turbo-windows-arm64": "1.10.14" + "turbo-darwin-64": "1.11.3", + "turbo-darwin-arm64": "1.11.3", + "turbo-linux-64": "1.11.3", + "turbo-linux-arm64": "1.11.3", + "turbo-windows-64": "1.11.3", + "turbo-windows-arm64": "1.11.3" } }, "node_modules/turbo-darwin-64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-1.10.14.tgz", - "integrity": "sha512-I8RtFk1b9UILAExPdG/XRgGQz95nmXPE7OiGb6ytjtNIR5/UZBS/xVX/7HYpCdmfriKdVwBKhalCoV4oDvAGEg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-1.11.3.tgz", + "integrity": "sha512-IsOOg2bVbIt3o/X8Ew9fbQp5t1hTHN3fGNQYrPQwMR2W1kIAC6RfbVD4A9OeibPGyEPUpwOH79hZ9ydFH5kifw==", "cpu": [ "x64" ], @@ -11064,9 +11031,9 @@ ] }, "node_modules/turbo-darwin-arm64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.10.14.tgz", - "integrity": "sha512-KAdUWryJi/XX7OD0alOuOa0aJ5TLyd4DNIYkHPHYcM6/d7YAovYvxRNwmx9iv6Vx6IkzTnLeTiUB8zy69QkG9Q==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.11.3.tgz", + "integrity": "sha512-FsJL7k0SaPbJzI/KCnrf/fi3PgCDCjTliMc/kEFkuWVA6Httc3Q4lxyLIIinz69q6JTx8wzh6yznUMzJRI3+dg==", "cpu": [ "arm64" ], @@ -11077,9 +11044,9 @@ ] }, "node_modules/turbo-linux-64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-1.10.14.tgz", - "integrity": "sha512-BOBzoREC2u4Vgpap/WDxM6wETVqVMRcM8OZw4hWzqCj2bqbQ6L0wxs1LCLWVrghQf93JBQtIGAdFFLyCSBXjWQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-1.11.3.tgz", + "integrity": "sha512-SvW7pvTVRGsqtSkII5w+wriZXvxqkluw5FO/MNAdFw0qmoov+PZ237+37/NgArqE3zVn1GX9P6nUx9VO+xcQAg==", "cpu": [ "x64" ], @@ -11090,9 +11057,9 @@ ] }, "node_modules/turbo-linux-arm64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-1.10.14.tgz", - "integrity": "sha512-D8T6XxoTdN5D4V5qE2VZG+/lbZX/89BkAEHzXcsSUTRjrwfMepT3d2z8aT6hxv4yu8EDdooZq/2Bn/vjMI32xw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-1.11.3.tgz", + "integrity": "sha512-YhUfBi1deB3m+3M55X458J6B7RsIS7UtM3P1z13cUIhF+pOt65BgnaSnkHLwETidmhRh8Dl3GelaQGrB3RdCDw==", "cpu": [ "arm64" ], @@ -11103,9 +11070,9 @@ ] }, "node_modules/turbo-windows-64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-1.10.14.tgz", - "integrity": "sha512-zKNS3c1w4i6432N0cexZ20r/aIhV62g69opUn82FLVs/zk3Ie0GVkSB6h0rqIvMalCp7enIR87LkPSDGz9K4UA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-1.11.3.tgz", + "integrity": "sha512-s+vEnuM2TiZuAUUUpmBHDr6vnNbJgj+5JYfnYmVklYs16kXh+EppafYQOAkcRIMAh7GjV3pLq5/uGqc7seZeHA==", "cpu": [ "x64" ], @@ -11116,9 +11083,9 @@ ] }, "node_modules/turbo-windows-arm64": { - "version": "1.10.14", - "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-1.10.14.tgz", - "integrity": "sha512-rkBwrTPTxNSOUF7of8eVvvM+BkfkhA2OvpHM94if8tVsU+khrjglilp8MTVPHlyS9byfemPAmFN90oRIPB05BA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-1.11.3.tgz", + "integrity": "sha512-ZR5z5Zpc7cASwfdRAV5yNScCZBsgGSbcwiA/u3farCacbPiXsfoWUkz28iyrx21/TRW0bi6dbsB2v17swa8bjw==", "cpu": [ "arm64" ], @@ -11946,14 +11913,6 @@ "@types/node": "^16.11.7", "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -11979,14 +11938,6 @@ "@typescript-eslint/parser": "^5.16.0", "@vitest/ui": "^0.33.0", "@webref/css": "6.6.2", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", "typescript": "^5.0.4", "vitest": "^0.32.4" @@ -12003,18 +11954,23 @@ "@types/node": "^16.11.7", "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", + "chalk": "^5.3.0", "express": "^4.18.2", "prettier": "^2.6.0" } }, + "packages/integration-test/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/jest-environment": { "name": "@happy-dom/jest-environment", "version": "0.0.0", @@ -12044,15 +12000,6 @@ "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", "cpy": "^8.1.2", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jest": "^26.1.2", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", "express": "^4.18.2", "glob": "^7.2.0", "jest": "^29.4.0", @@ -12074,25 +12021,7 @@ "name": "@happy-dom/uncaught-exception-observer", "version": "0.0.0", "license": "MIT", - "devDependencies": { - "@types/node": "^16.11.7", - "@typescript-eslint/eslint-plugin": "^5.16.0", - "@typescript-eslint/parser": "^5.16.0", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", - "happy-dom": "^0.0.0", - "prettier": "^2.6.0", - "typescript": "^5.0.4" - }, - "peerDependencies": { - "happy-dom": ">= 2.25.2" - } + "devDependencies": {} } } } diff --git a/package.json b/package.json index 7da917e64..251439d3e 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "compile": "turbo run compile --cache-dir=.turbo", "watch": "turbo run watch --parallel", "clean": "git clean -Xdfq", - "lint": "turbo run lint --cache-dir=.turbo", - "lint:fix": "turbo run lint:fix", + "lint": "eslint --ignore-path .lintignore --max-warnings 0 .", + "lint:fix": "eslint --ignore-path .lintignore --max-warnings 0 --fix .", + "lint:changed": "eslint --ignore-path .lintignore --max-warnings 0 --fix $(git diff --diff-filter=d --name-only HEAD | grep -E '^[a-zA-Z0-9_].*\\.(cjs|mjs|js|jsx|ts|tsx|json)$' | xargs)", "test": "turbo run test --cache-dir=.turbo", "test:watch": "turbo run test:watch --parallel" }, @@ -30,7 +31,7 @@ "vitest": "^0.32.4", "prettier": "^2.6.0", "semver": "^7.3.5", - "turbo": "^1.7.3", + "turbo": "^1.11.3", "typescript": "^5.0.4" }, "engines": { diff --git a/packages/global-registrator/.eslintrc.cjs b/packages/global-registrator/.eslintrc.cjs deleted file mode 100644 index f4088a566..000000000 --- a/packages/global-registrator/.eslintrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('happy-dom/.eslintrc.cjs'); diff --git a/packages/global-registrator/package.json b/packages/global-registrator/package.json index aaa1d0240..5c2cb86d4 100644 --- a/packages/global-registrator/package.json +++ b/packages/global-registrator/package.json @@ -68,8 +68,6 @@ "compile": "tsc && tsc --moduleResolution Node --module CommonJS --outDir cjs && npm run change-cjs-file-extension", "change-cjs-file-extension": "node ../happy-dom/bin/change-file-extension.cjs --dir=./cjs --fromExt=.js --toExt=.cjs", "watch": "npm run compile && tsc -w --preserveWatchOutput", - "lint": "eslint --ignore-path .gitignore --max-warnings 0 .", - "lint:fix": "eslint --ignore-path .gitignore --max-warnings 0 --fix .", "test": "tsc --project ./test && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node ./tmp/react/React.test.cjs", "test:debug": "tsc --project ./test && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node --inspect-brk ./tmp/react/React.test.cjs" }, @@ -80,14 +78,6 @@ "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", "@types/node": "^16.11.7", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", "typescript": "^5.0.4", "react": "^18.2.0", diff --git a/packages/global-registrator/src/GlobalRegistrator.ts b/packages/global-registrator/src/GlobalRegistrator.ts index 0a0c2329d..774165e63 100644 --- a/packages/global-registrator/src/GlobalRegistrator.ts +++ b/packages/global-registrator/src/GlobalRegistrator.ts @@ -25,11 +25,10 @@ export default class GlobalRegistrator { if (global[key] !== window[key] && !IGNORE_LIST.includes(key)) { this.registered[key] = global[key] !== window[key] && global[key] !== undefined ? global[key] : null; - - // Only bind functions that aren't used as classes, since bound functions can't be extended. - const bind = typeof window[key] === 'function' && !isClassLikeName(key); - - global[key] = bind ? window[key].bind(global) : window[key]; + global[key] = + typeof window[key] === 'function' && !window[key].toString().startsWith('class ') + ? window[key].bind(global) + : window[key]; } } @@ -60,7 +59,3 @@ export default class GlobalRegistrator { this.registered = null; } } - -function isClassLikeName(name: string): boolean { - return name[0] === name[0].toUpperCase(); -} diff --git a/packages/happy-dom/.eslintrc.cjs b/packages/happy-dom/.eslintrc.cjs deleted file mode 100644 index 728d1a2cd..000000000 --- a/packages/happy-dom/.eslintrc.cjs +++ /dev/null @@ -1,174 +0,0 @@ -const WARN = 'warn'; -const ERROR = 'error'; -const OFF = 'off'; - -const COMMON_CONFIG = { - plugins: ['turbo', 'jsdoc', 'filenames', 'import', 'prettier'], - extends: ['plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended'], - rules: { - 'prettier/prettier': WARN, - 'no-underscore-dangle': OFF, - 'no-debugger': WARN, - 'space-infix-ops': ERROR, - 'no-console': WARN, - 'wrap-iife': OFF, - 'no-self-assign': ERROR, - 'no-self-compare': ERROR, - 'no-loop-func': OFF, - 'array-callback-return': ERROR, - curly: ERROR, - 'no-fallthrough': OFF, - 'dot-notation': OFF, - 'prefer-const': WARN, - 'no-empty-function': OFF, - 'no-with': ERROR, - 'one-var': [ERROR, 'never'], - camelcase: [WARN, { properties: 'always', ignoreImports: true }], - 'spaced-comment': [WARN, 'always'], - 'capitalized-comments': [WARN, 'always', { ignorePattern: 'prettier' }], - 'no-useless-rename': WARN, - 'jsdoc/check-alignment': WARN, - 'jsdoc/check-examples': OFF, - 'jsdoc/check-indentation': WARN, - 'jsdoc/check-syntax': WARN, - 'jsdoc/check-tag-names': WARN, - 'jsdoc/check-types': WARN, - 'jsdoc/implements-on-classes': WARN, - 'jsdoc/match-description': OFF, - 'jsdoc/newline-after-description': WARN, - 'jsdoc/no-types': OFF, - 'jsdoc/no-undefined-types': OFF, - 'jsdoc/require-description': OFF, - 'jsdoc/require-description-complete-sentence': OFF, - 'jsdoc/require-example': OFF, - 'jsdoc/require-hyphen-before-param-description': [WARN, 'never'], - 'jsdoc/require-param': WARN, - 'jsdoc/require-param-name': WARN, - 'jsdoc/require-returns-check': WARN, - 'jsdoc/require-returns-description': WARN, - 'jsdoc/valid-types': WARN, - 'filenames/match-exported': WARN, - 'no-useless-constructor': WARN, - 'jsdoc/require-jsdoc': [ - WARN, - { - require: { - ArrowFunctionExpression: false, - FunctionDeclaration: false, - FunctionExpression: false, - ClassDeclaration: true, - MethodDefinition: true - } - } - ], - 'import/no-extraneous-dependencies': WARN - } -}; - -const TS_PARSER_FIELDS = { - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaFeatures: { - jsx: true - }, - ecmaVersion: 2020, - sourceType: 'module' - } -}; - -module.exports = { - env: { - es6: true, - browser: true, - node: true - }, - overrides: [ - { - files: ['*.js', '*.jsx', '*.mjs'], - ...TS_PARSER_FIELDS, - plugins: COMMON_CONFIG.plugins, - extends: COMMON_CONFIG.extends, - rules: { - ...COMMON_CONFIG.rules, - 'no-undef': ERROR, - 'jsdoc/check-param-names': WARN, - 'jsdoc/require-param-type': WARN, - 'jsdoc/require-returns': WARN, - 'jsdoc/require-param-description': WARN, - 'jsdoc/require-returns-type': WARN, - 'consistent-return': WARN - } - }, - { - files: ['*.json'], - plugins: ['json'], - extends: ['eslint:recommended', 'plugin:json/recommended'] - }, - { - files: ['*.ts', '*.tsx'], - plugins: [...COMMON_CONFIG.plugins, '@typescript-eslint'], - ...TS_PARSER_FIELDS, - extends: [...COMMON_CONFIG.extends, 'plugin:@typescript-eslint/recommended'], - rules: { - ...COMMON_CONFIG.rules, - '@typescript-eslint/explicit-member-accessibility': [ - ERROR, - { overrides: { constructors: 'no-public' } } - ], - '@typescript-eslint/no-unused-vars': OFF, // TSC is already doing this - '@typescript-eslint/ban-types': OFF, // TSC is already doing this - 'no-undef': OFF, // TSC is already doing this - '@typescript-eslint/no-var-requires': OFF, - '@typescript-eslint/explicit-module-boundary-types': OFF, // TSC is already doing this - '@typescript-eslint/consistent-type-assertions': [ - WARN, - { assertionStyle: 'angle-bracket' } - ], - '@typescript-eslint/no-explicit-any': ERROR, - '@typescript-eslint/no-empty-function': OFF, - '@typescript-eslint/no-use-before-define': OFF, - '@typescript-eslint/no-this-alias': OFF, - '@typescript-eslint/explicit-function-return-type': [ERROR, { allowExpressions: true }], - '@typescript-eslint/member-ordering': [ - WARN, - { - default: ['signature', 'field', 'constructor', ['get', 'set'], 'method'] - } - ], - '@typescript-eslint/ban-ts-comment': OFF, - 'jsdoc/no-types': WARN, - 'import/named': OFF, - 'import/no-named-as-default': WARN, - 'import/no-extraneous-dependencies': WARN, - 'import/no-absolute-path': WARN, - '@typescript-eslint/naming-convention': [ - WARN, - { - selector: 'interface', - format: ['PascalCase'], - prefix: ['I'] - }, - { - selector: 'enum', - format: ['PascalCase'], - suffix: ['Enum'] - }, - { - selector: 'typeParameter', - format: ['PascalCase'] - }, - { - selector: 'memberLike', - modifiers: ['private'], - leadingUnderscore: 'allow', - format: ['camelCase'] - }, - { - selector: 'class', - format: ['PascalCase'] - } - ] - } - } - ] -}; diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md index f4d7ed260..741ed234b 100644 --- a/packages/happy-dom/README.md +++ b/packages/happy-dom/README.md @@ -51,14 +51,12 @@ npm install happy-dom A simple example of how you can use Happy DOM. +## Window + ```javascript import { Window } from 'happy-dom'; -const window = new Window({ - url: 'https://localhost:8080', - width: 1024, - height: 768 -}); +const window = new Window({ url: 'https://localhost:8080' }); const document = window.document; document.body.innerHTML = '
'; @@ -72,6 +70,30 @@ container.appendChild(button); console.log(document.body.innerHTML); ``` +## Browser + +```javascript +import { Browser, BrowserErrorCaptureEnum } from 'happy-dom'; + +const browser = new Browser({ settings: { errorCapture: BrowserErrorCaptureEnum.processLevel } }); +const page = browser.newPage(); + +// Navigates page +await page.goto('https://github.com/capricorn86'); + +// Clicks on link +page.mainFrame.document.querySelector('a[href*="capricorn86/happy-dom"]').click(); + +// Waits for all operations on the page to complete (fetch, timers etc.) +await page.waitUntilComplete(); + +// Outputs "GitHub - capricorn86/happy-dom: Happy DOM..." +console.log(page.mainFrame.document.title); + +// Closes the browser +await browser.close(); +``` + # Documentation Read more about how to use Happy DOM in our [Wiki](https://github.com/capricorn86/happy-dom/wiki). diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index f1d3eef84..1d43d30fe 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -65,12 +65,10 @@ "access": "public" }, "scripts": { - "compile": "tsc && tsc --moduleResolution Node --module CommonJS --outDir cjs && npm run change-cjs-file-extension && npm run build-version-file", + "compile": "tsc && rm -rf ./cjs && tsc --moduleResolution Node --module CommonJS --outDir cjs && npm run change-cjs-file-extension && npm run build-version-file", "change-cjs-file-extension": "node ./bin/change-file-extension.cjs --dir=./cjs --fromExt=.js --toExt=.cjs", "build-version-file": "node ./bin/build-version-file.cjs", "watch": "tsc -w --preserveWatchOutput", - "lint": "eslint --ignore-path .gitignore --max-warnings 0 .", - "lint:fix": "eslint --ignore-path .gitignore --max-warnings 0 --fix .", "test": "vitest run --singleThread", "test:ui": "vitest --ui", "test:watch": "vitest --singleThread", @@ -92,14 +90,6 @@ "@typescript-eslint/parser": "^5.16.0", "@vitest/ui": "^0.33.0", "@webref/css": "6.6.2", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", "typescript": "^5.0.4", "vitest": "^0.32.4" diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts new file mode 100644 index 000000000..116b41550 --- /dev/null +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -0,0 +1,150 @@ +export const abort = Symbol('abort'); +export const activeElement = Symbol('activeElement'); +export const appendFormControlItem = Symbol('appendFormControlItem'); +export const appendNamedItem = Symbol('appendNamedItem'); +export const asyncTaskManager = Symbol('asyncTaskManager'); +export const bodyBuffer = Symbol('bodyBuffer'); +export const buffer = Symbol('buffer'); +export const cacheID = Symbol('cacheID'); +export const cachedResponse = Symbol('cachedResponse'); +export const callbacks = Symbol('callbacks'); +export const captureEventListenerCount = Symbol('captureEventListenerCount'); +export const checked = Symbol('checked'); +export const childNodes = Symbol('childNodes'); +export const children = Symbol('children'); +export const classList = Symbol('classList'); +export const computedStyle = Symbol('computedStyle'); +export const connectToNode = Symbol('connectToNode'); +export const contentLength = Symbol('contentLength'); +export const contentType = Symbol('contentType'); +export const cssText = Symbol('cssText'); +export const currentScript = Symbol('currentScript'); +export const currentTarget = Symbol('currentTarget'); +export const data = Symbol('data'); +export const defaultView = Symbol('defaultView'); +export const destroy = Symbol('destroy'); +export const dirtyness = Symbol('dirtyness'); +export const end = Symbol('end'); +export const entries = Symbol('entries'); +export const evaluateCSS = Symbol('evaluateCSS'); +export const evaluateScript = Symbol('evaluateScript'); +export const exceptionObserver = Symbol('exceptionObserver'); +export const formNode = Symbol('formNode'); +export const getAttributeName = Symbol('getAttributeName'); +export const happyDOMSettingsID = Symbol('happyDOMSettingsID'); +export const height = Symbol('height'); +export const immediatePropagationStopped = Symbol('immediatePropagationStopped'); +export const isFirstWrite = Symbol('isFirstWrite'); +export const isFirstWriteAfterOpen = Symbol('isFirstWriteAfterOpen'); +export const isInPassiveEventListener = Symbol('isInPassiveEventListener'); +export const isValidPropertyName = Symbol('isValidPropertyName'); +export const isValue = Symbol('isValue'); +export const listenerOptions = Symbol('listenerOptions'); +export const listeners = Symbol('listeners'); +export const namedItems = Symbol('namedItems'); +export const nextActiveElement = Symbol('nextActiveElement'); +export const observe = Symbol('observe'); +export const observedAttributes = Symbol('observedAttributes'); +export const observers = Symbol('observers'); +export const ownerDocument = Symbol('ownerDocument'); +export const ownerElement = Symbol('ownerElement'); +export const propagationStopped = Symbol('propagationStopped'); +export const readyStateManager = Symbol('readyStateManager'); +export const referrer = Symbol('referrer'); +export const registry = Symbol('registry'); +export const relList = Symbol('relList'); +export const removeFormControlItem = Symbol('removeFormControlItem'); +export const removeNamedItem = Symbol('removeNamedItem'); +export const removeNamedItemIndex = Symbol('removeNamedItemIndex'); +export const removeNamedItemWithoutConsequences = Symbol('removeNamedItemWithoutConsequences'); +export const resetSelection = Symbol('resetSelection'); +export const rootNode = Symbol('rootNode'); +export const selectNode = Symbol('selectNode'); +export const selectedness = Symbol('selectedness'); +export const selection = Symbol('selection'); +export const setNamedItemWithoutConsequences = Symbol('setNamedItemWithoutConsequences'); +export const setupVMContext = Symbol('setupVMContext'); +export const shadowRoot = Symbol('shadowRoot'); +export const start = Symbol('start'); +export const style = Symbol('style'); +export const target = Symbol('target'); +export const textAreaNode = Symbol('textAreaNode'); +export const unobserve = Symbol('unobserve'); +export const updateIndices = Symbol('updateIndices'); +export const updateOptionItems = Symbol('updateOptionItems'); +export const url = Symbol('url'); +export const value = Symbol('value'); +export const width = Symbol('width'); +export const window = Symbol('window'); +export const windowResizeListener = Symbol('windowResizeListener'); +export const mutationObservers = Symbol('mutationObservers'); +export const openerFrame = Symbol('openerFrame'); +export const openerWindow = Symbol('openerFrame'); +export const popup = Symbol('popup'); +export const isConnected = Symbol('isConnected'); +export const parentNode = Symbol('parentNode'); +export const nodeType = Symbol('nodeType'); +export const tagName = Symbol('tagName'); +export const prefix = Symbol('prefix'); +export const scrollHeight = Symbol('scrollHeight'); +export const scrollWidth = Symbol('scrollWidth'); +export const scrollTop = Symbol('scrollTop'); +export const scrollLeft = Symbol('scrollLeft'); +export const attributes = Symbol('attributes'); +export const namespaceURI = Symbol('namespaceURI'); +export const accessKey = Symbol('accessKey'); +export const accessKeyLabel = Symbol('accessKeyLabel'); +export const contentEditable = Symbol('contentEditable'); +export const isContentEditable = Symbol('isContentEditable'); +export const offsetHeight = Symbol('offsetHeight'); +export const offsetWidth = Symbol('offsetWidth'); +export const offsetLeft = Symbol('offsetLeft'); +export const offsetTop = Symbol('offsetTop'); +export const clientHeight = Symbol('clientHeight'); +export const clientWidth = Symbol('clientWidth'); +export const clientLeft = Symbol('clientLeft'); +export const clientTop = Symbol('clientTop'); +export const name = Symbol('name'); +export const specified = Symbol('specified'); +export const adoptedStyleSheets = Symbol('adoptedStyleSheets'); +export const implementation = Symbol('implementation'); +export const readyState = Symbol('readyState'); +export const ownerWindow = Symbol('ownerWindow'); +export const publicId = Symbol('publicId'); +export const systemId = Symbol('systemId'); +export const validationMessage = Symbol('validationMessage'); +export const validity = Symbol('validity'); +export const returnValue = Symbol('returnValue'); +export const elements = Symbol('elements'); +export const length = Symbol('length'); +export const complete = Symbol('complete'); +export const naturalHeight = Symbol('naturalHeight'); +export const naturalWidth = Symbol('naturalWidth'); +export const loading = Symbol('loading'); +export const x = Symbol('x'); +export const y = Symbol('y'); +export const defaultChecked = Symbol('defaultChecked'); +export const files = Symbol('files'); +export const sheet = Symbol('sheet'); +export const volume = Symbol('volume'); +export const paused = Symbol('paused'); +export const currentTime = Symbol('currentTime'); +export const playbackRate = Symbol('playbackRate'); +export const defaultPlaybackRate = Symbol('defaultPlaybackRate'); +export const muted = Symbol('muted'); +export const defaultMuted = Symbol('defaultMuted'); +export const preservesPitch = Symbol('preservesPitch'); +export const buffered = Symbol('buffered'); +export const duration = Symbol('duration'); +export const error = Symbol('error'); +export const ended = Symbol('ended'); +export const networkState = Symbol('networkState'); +export const textTracks = Symbol('textTracks'); +export const videoTracks = Symbol('videoTracks'); +export const seeking = Symbol('seeking'); +export const seekable = Symbol('seekable'); +export const played = Symbol('played'); +export const options = Symbol('options'); +export const content = Symbol('content'); +export const mode = Symbol('mode'); +export const host = Symbol('host'); diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index ae906b455..2b066c1e5 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -1,58 +1,46 @@ +// We need to set this as a global constant, so that using fake timers in Jest and Vitest won't override this on the global object. +const TIMER = { + setImmediate: setImmediate, + clearImmediate: clearImmediate, + clearTimeout: clearTimeout +}; + /** * Handles async tasks. */ export default class AsyncTaskManager { private static taskID = 0; - private runningTasks: { [k: string]: () => void } = {}; + private runningTasks: { [k: string]: (destroy: boolean) => void } = {}; private runningTaskCount = 0; private runningTimers: NodeJS.Timeout[] = []; private runningImmediates: NodeJS.Immediate[] = []; - private whenCompleteImmediate: NodeJS.Immediate | null = null; - private whenCompleteResolvers: Array<() => void> = []; + private waitUntilCompleteTimer: NodeJS.Immediate | null = null; + private waitUntilCompleteResolvers: Array<() => void> = []; /** * Returns a promise that is resolved when async tasks are complete. * * @returns Promise. */ - public whenComplete(): Promise { + public waitUntilComplete(): Promise { return new Promise((resolve) => { - this.whenCompleteResolvers.push(resolve); + this.waitUntilCompleteResolvers.push(resolve); this.endTask(this.startTask()); }); } /** - * Cancels all tasks. + * Aborts all tasks. */ - public cancelAll(): void { - const runningTimers = this.runningTimers; - const runningImmediates = this.runningImmediates; - const runningTasks = this.runningTasks; - - this.runningTasks = {}; - this.runningTaskCount = 0; - this.runningImmediates = []; - this.runningTimers = []; - - if (this.whenCompleteImmediate) { - global.clearImmediate(this.whenCompleteImmediate); - this.whenCompleteImmediate = null; - } - - for (const immediate of runningImmediates) { - global.clearImmediate(immediate); - } - - for (const timer of runningTimers) { - global.clearTimeout(timer); - } - - for (const key of Object.keys(runningTasks)) { - runningTasks[key](); - } + public abort(): Promise { + return this.abortAll(false); + } - this.resolveWhenComplete(); + /** + * Destroys the manager. + */ + public destroy(): Promise { + return this.abortAll(true); } /** @@ -125,12 +113,12 @@ export default class AsyncTaskManager { if (this.runningTasks[taskID]) { delete this.runningTasks[taskID]; this.runningTaskCount--; - if (this.whenCompleteImmediate) { - global.clearImmediate(this.whenCompleteImmediate); + if (this.waitUntilCompleteTimer) { + TIMER.clearImmediate(this.waitUntilCompleteTimer); } if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.whenCompleteImmediate = global.setImmediate(() => { - this.whenCompleteImmediate = null; + this.waitUntilCompleteTimer = TIMER.setImmediate(() => { + this.waitUntilCompleteTimer = null; if ( !this.runningTaskCount && !this.runningTimers.length && @@ -166,10 +154,46 @@ export default class AsyncTaskManager { * Resolves when complete. */ private resolveWhenComplete(): void { - const resolvers = this.whenCompleteResolvers; - this.whenCompleteResolvers = []; + const resolvers = this.waitUntilCompleteResolvers; + this.waitUntilCompleteResolvers = []; for (const resolver of resolvers) { resolver(); } } + + /** + * Aborts all tasks. + * + * @param destroy Destroy. + */ + private abortAll(destroy: boolean): Promise { + const runningTimers = this.runningTimers; + const runningImmediates = this.runningImmediates; + const runningTasks = this.runningTasks; + + this.runningTasks = {}; + this.runningTaskCount = 0; + this.runningImmediates = []; + this.runningTimers = []; + + if (this.waitUntilCompleteTimer) { + TIMER.clearImmediate(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } + + for (const immediate of runningImmediates) { + TIMER.clearImmediate(immediate); + } + + for (const timer of runningTimers) { + TIMER.clearTimeout(timer); + } + + for (const key of Object.keys(runningTasks)) { + runningTasks[key](destroy); + } + + // We need to wait for microtasks to complete before resolving. + return this.waitUntilComplete(); + } } diff --git a/packages/happy-dom/src/browser/Browser.ts b/packages/happy-dom/src/browser/Browser.ts new file mode 100644 index 000000000..0df1f0407 --- /dev/null +++ b/packages/happy-dom/src/browser/Browser.ts @@ -0,0 +1,104 @@ +import IBrowserSettings from './types/IBrowserSettings.js'; +import BrowserContext from './BrowserContext.js'; +import IOptionalBrowserSettings from './types/IOptionalBrowserSettings.js'; +import BrowserSettingsFactory from './BrowserSettingsFactory.js'; +import BrowserPage from './BrowserPage.js'; +import IBrowser from './types/IBrowser.js'; + +/** + * Browser. + * + * Much of the interface for the browser has been taken from Puppeteer and Playwright, so that the API is familiar. + */ +export default class Browser implements IBrowser { + public readonly contexts: BrowserContext[]; + public readonly settings: IBrowserSettings; + public readonly console: Console | null; + + /** + * Constructor. + * + * @param [options] Options. + * @param [options.settings] Browser settings. + * @param [options.console] Console. + */ + constructor(options?: { settings?: IOptionalBrowserSettings; console?: Console }) { + this.console = options?.console || null; + this.settings = BrowserSettingsFactory.getSettings(options?.settings); + this.contexts = [new BrowserContext(this)]; + } + + /** + * Returns the default context. + * + * @returns Default context. + */ + public get defaultContext(): BrowserContext { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + return this.contexts[0]; + } + + /** + * Aborts all ongoing operations and destroys the browser. + */ + public async close(): Promise { + await Promise.all(this.contexts.slice().map((context) => context.close())); + (this.contexts) = []; + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + public async waitUntilComplete(): Promise { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + await Promise.all(this.contexts.map((page) => page.waitUntilComplete())); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + // Using Promise instead of async/await to prevent microtask + return new Promise((resolve, reject) => { + if (!this.contexts.length) { + resolve(); + return; + } + Promise.all(this.contexts.slice().map((context) => context.abort())) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + } + + /** + * Creates a new incognito context. + * + * @returns Context. + */ + public newIncognitoContext(): BrowserContext { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + const context = new BrowserContext(this); + this.contexts.push(context); + return context; + } + + /** + * Creates a new page. + * + * @returns Page. + */ + public newPage(): BrowserPage { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + return this.contexts[0].newPage(); + } +} diff --git a/packages/happy-dom/src/browser/BrowserContext.ts b/packages/happy-dom/src/browser/BrowserContext.ts new file mode 100644 index 000000000..f0123eb1e --- /dev/null +++ b/packages/happy-dom/src/browser/BrowserContext.ts @@ -0,0 +1,89 @@ +import CookieContainer from '../cookie/CookieContainer.js'; +import ICookieContainer from '../cookie/types/ICookieContainer.js'; +import ResponseCache from '../fetch/cache/response/ResponseCache.js'; +import IResponseCache from '../fetch/cache/response/IResponseCache.js'; +import Browser from './Browser.js'; +import BrowserPage from './BrowserPage.js'; +import IBrowserContext from './types/IBrowserContext.js'; +import IPreflightResponseCache from '../fetch/cache/preflight/IPreflightResponseCache.js'; +import PreflightResponseCache from '../fetch/cache/preflight/PreflightResponseCache.js'; + +/** + * Browser context. + */ +export default class BrowserContext implements IBrowserContext { + public readonly pages: BrowserPage[] = []; + public readonly browser: Browser; + public readonly cookieContainer: ICookieContainer = new CookieContainer(); + public readonly responseCache: IResponseCache = new ResponseCache(); + public readonly preflightResponseCache: IPreflightResponseCache = new PreflightResponseCache(); + + /** + * Constructor. + * + * @param browser + */ + constructor(browser: Browser) { + this.browser = browser; + } + + /** + * Aborts all ongoing operations and destroys the context. + */ + public async close(): Promise { + if (!this.browser) { + return; + } + await Promise.all(this.pages.slice().map((page) => page.close())); + const browser = this.browser; + const index = browser.contexts.indexOf(this); + if (index !== -1) { + browser.contexts.splice(index, 1); + } + (this.pages) = []; + (this.browser) = null; + (this.cookieContainer) = null; + this.responseCache.clear(); + this.preflightResponseCache.clear(); + (this.responseCache) = null; + (this.preflightResponseCache) = null; + if (browser.contexts.length === 0) { + browser.close(); + } + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + public async waitUntilComplete(): Promise { + await Promise.all(this.pages.map((page) => page.waitUntilComplete())); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + return new Promise((resolve, reject) => { + if (!this.pages.length) { + resolve(); + return; + } + Promise.all(this.pages.slice().map((page) => page.abort())) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + } + + /** + * Creates a new page. + * + * @returns Page. + */ + public newPage(): BrowserPage { + const page = new BrowserPage(this); + this.pages.push(page); + return page; + } +} diff --git a/packages/happy-dom/src/browser/BrowserFrame.ts b/packages/happy-dom/src/browser/BrowserFrame.ts new file mode 100644 index 000000000..fccaff3c8 --- /dev/null +++ b/packages/happy-dom/src/browser/BrowserFrame.ts @@ -0,0 +1,168 @@ +import BrowserPage from './BrowserPage.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import AsyncTaskManager from '../async-task-manager/AsyncTaskManager.js'; +import IBrowserFrame from './types/IBrowserFrame.js'; +import BrowserWindow from '../window/BrowserWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import ICrossOriginBrowserWindow from '../window/ICrossOriginBrowserWindow.js'; +import Location from '../location/Location.js'; +import IResponse from '../fetch/types/IResponse.js'; +import IGoToOptions from './types/IGoToOptions.js'; +import { Script } from 'vm'; +import BrowserFrameURL from './utilities/BrowserFrameURL.js'; +import BrowserFrameScriptEvaluator from './utilities/BrowserFrameScriptEvaluator.js'; +import BrowserFrameNavigator from './utilities/BrowserFrameNavigator.js'; +import IReloadOptions from './types/IReloadOptions.js'; +import BrowserFrameExceptionObserver from './utilities/BrowserFrameExceptionObserver.js'; +import BrowserErrorCaptureEnum from './enums/BrowserErrorCaptureEnum.js'; +import IDocument from '../nodes/document/IDocument.js'; + +/** + * Browser frame. + */ +export default class BrowserFrame implements IBrowserFrame { + public readonly childFrames: BrowserFrame[] = []; + public readonly parentFrame: BrowserFrame | null = null; + public readonly page: BrowserPage; + public readonly window: BrowserWindow; + public [PropertySymbol.asyncTaskManager] = new AsyncTaskManager(); + public [PropertySymbol.exceptionObserver]: BrowserFrameExceptionObserver | null = null; + public [PropertySymbol.listeners]: { navigation: Array<() => void> } = { navigation: [] }; + public [PropertySymbol.openerFrame]: IBrowserFrame | null = null; + public [PropertySymbol.openerWindow]: IBrowserWindow | ICrossOriginBrowserWindow | null = null; + public [PropertySymbol.popup] = false; + + /** + * Constructor. + * + * @param page Page. + */ + constructor(page: BrowserPage) { + this.page = page; + this.window = new BrowserWindow(this); + + // Attach process level error capturing. + if (page.context.browser.settings.errorCapture === BrowserErrorCaptureEnum.processLevel) { + this[PropertySymbol.exceptionObserver] = new BrowserFrameExceptionObserver(); + this[PropertySymbol.exceptionObserver].observe(this); + } + } + + /** + * Returns the content. + * + * @returns Content. + */ + public get content(): string { + return this.window.document.documentElement.outerHTML; + } + + /** + * Sets the content. + * + * @param content Content. + */ + public set content(content) { + this.window.document[PropertySymbol.isFirstWrite] = true; + this.window.document[PropertySymbol.isFirstWriteAfterOpen] = false; + this.window.document.open(); + this.window.document.write(content); + } + + /** + * Returns the URL. + * + * @returns URL. + */ + public get url(): string { + return this.window.location.href; + } + + /** + * Sets the content. + * + * @param url URL. + */ + public set url(url) { + (this.window.location) = new Location( + this, + BrowserFrameURL.getRelativeURL(this, url).href + ); + } + + /** + * Returns document. + * + * @returns Document. + */ + public get document(): IDocument { + return this.window?.document ?? null; + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + public async waitUntilComplete(): Promise { + await Promise.all([ + this[PropertySymbol.asyncTaskManager].waitUntilComplete(), + ...this.childFrames.map((frame) => frame.waitUntilComplete()) + ]); + } + + /** + * Returns a promise that is resolved when the frame has navigated and the response HTML has been written to the document. + */ + public waitForNavigation(): Promise { + return new Promise((resolve) => this[PropertySymbol.listeners].navigation.push(resolve)); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + if (!this.childFrames.length) { + return this[PropertySymbol.asyncTaskManager].abort(); + } + return new Promise((resolve, reject) => { + // Using Promise instead of async/await to prevent microtask + Promise.all( + this.childFrames + .map((frame) => frame.abort()) + .concat([this[PropertySymbol.asyncTaskManager].abort()]) + ) + .then(() => resolve()) + .catch(reject); + }); + } + + /** + * Evaluates code or a VM Script in the page's context. + * + * @param script Script. + * @returns Result. + */ + public evaluate(script: string | Script): any { + return BrowserFrameScriptEvaluator.evaluate(this, script); + } + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + * @returns Response. + */ + public goto(url: string, options?: IGoToOptions): Promise { + return BrowserFrameNavigator.goto(BrowserWindow, this, url, options); + } + + /** + * Reloads the current frame. + * + * @param [options] Options. + * @returns Response. + */ + public reload(options: IReloadOptions): Promise { + return BrowserFrameNavigator.goto(BrowserWindow, this, this.url, options); + } +} diff --git a/packages/happy-dom/src/browser/BrowserPage.ts b/packages/happy-dom/src/browser/BrowserPage.ts new file mode 100644 index 000000000..77c665105 --- /dev/null +++ b/packages/happy-dom/src/browser/BrowserPage.ts @@ -0,0 +1,153 @@ +import VirtualConsolePrinter from '../console/VirtualConsolePrinter.js'; +import BrowserFrame from './BrowserFrame.js'; +import BrowserContext from './BrowserContext.js'; +import VirtualConsole from '../console/VirtualConsole.js'; +import IBrowserPage from './types/IBrowserPage.js'; +import BrowserPageUtility from './utilities/BrowserPageUtility.js'; +import { Script } from 'vm'; +import IGoToOptions from './types/IGoToOptions.js'; +import IResponse from '../fetch/types/IResponse.js'; +import IReloadOptions from './types/IReloadOptions.js'; +import IBrowserPageViewport from './types/IBrowserPageViewport.js'; +import IOptionalBrowserPageViewport from './types/IOptionalBrowserPageViewport.js'; +import DefaultBrowserPageViewport from './DefaultBrowserPageViewport.js'; +import Event from '../event/Event.js'; + +/** + * Browser page. + */ +export default class BrowserPage implements IBrowserPage { + public readonly virtualConsolePrinter = new VirtualConsolePrinter(); + public readonly mainFrame: BrowserFrame; + public readonly context: BrowserContext; + public readonly console: Console; + public readonly viewport: IBrowserPageViewport = Object.assign({}, DefaultBrowserPageViewport); + + /** + * Constructor. + * + * @param context Browser context. + */ + constructor(context: BrowserContext) { + this.context = context; + this.console = context.browser.console ?? new VirtualConsole(this.virtualConsolePrinter); + this.mainFrame = new BrowserFrame(this); + } + + /** + * Returns frames. + */ + public get frames(): BrowserFrame[] { + return BrowserPageUtility.getFrames(this); + } + + /** + * Returns the viewport. + */ + public get content(): string { + return this.mainFrame.content; + } + + /** + * Sets the content. + * + * @param content Content. + */ + public set content(content) { + this.mainFrame.content = content; + } + + /** + * Returns the URL. + * + * @returns URL. + */ + public get url(): string { + return this.mainFrame.url; + } + + /** + * Sets the content. + * + * @param url URL. + */ + public set url(url) { + this.mainFrame.url = url; + } + + /** + * Aborts all ongoing operations and destroys the page. + */ + public close(): Promise { + return BrowserPageUtility.closePage(this); + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + public waitUntilComplete(): Promise { + return this.mainFrame.waitUntilComplete(); + } + + /** + * Returns a promise that is resolved when the page has navigated and the response HTML has been written to the document. + */ + public waitForNavigation(): Promise { + return this.mainFrame.waitForNavigation(); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + return this.mainFrame.abort(); + } + + /** + * Evaluates code or a VM Script in the page's context. + * + * @param script Script. + * @returns Result. + */ + public evaluate(script: string | Script): any { + return this.mainFrame.evaluate(script); + } + + /** + * Sets the viewport. + * + * @param viewport Viewport. + */ + public setViewport(viewport: IOptionalBrowserPageViewport): void { + const previousViewport = Object.assign({}, this.viewport); + Object.assign(this.viewport, viewport); + if ( + previousViewport.width !== this.viewport.width || + previousViewport.height !== this.viewport.height || + previousViewport.devicePixelRatio !== this.viewport.devicePixelRatio + ) { + this.mainFrame.window.dispatchEvent(new Event('resize')); + } + } + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + * @returns Response. + */ + public goto(url: string, options?: IGoToOptions): Promise { + return this.mainFrame.goto(url, options); + } + + /** + * Reloads the current page. + * + * @param [options] Options. + * @returns Response. + */ + public reload(options: IReloadOptions): Promise { + return this.mainFrame.reload(options); + } +} diff --git a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts new file mode 100644 index 000000000..7ed799ab2 --- /dev/null +++ b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts @@ -0,0 +1,34 @@ +import IBrowserSettings from './types/IBrowserSettings.js'; +import IOptionalBrowserSettings from './types/IOptionalBrowserSettings.js'; +import DefaultBrowserSettings from './DefaultBrowserSettings.js'; + +/** + * Browser settings utility. + */ +export default class BrowserSettingsFactory { + /** + * Returns browser settings. + * + * @param [settings] Browser settings. + * @param [freezeObject] "true" to freeze the object. + * @returns Settings. + */ + public static getSettings(settings?: IOptionalBrowserSettings): IBrowserSettings { + return { + ...DefaultBrowserSettings, + ...settings, + navigation: { + ...DefaultBrowserSettings.navigation, + ...settings?.navigation + }, + navigator: { + ...DefaultBrowserSettings.navigator, + ...settings?.navigator + }, + device: { + ...DefaultBrowserSettings.device, + ...settings?.device + } + }; + } +} diff --git a/packages/happy-dom/src/browser/DefaultBrowserPageViewport.ts b/packages/happy-dom/src/browser/DefaultBrowserPageViewport.ts new file mode 100644 index 000000000..616bc4b03 --- /dev/null +++ b/packages/happy-dom/src/browser/DefaultBrowserPageViewport.ts @@ -0,0 +1,7 @@ +import IBrowserPageViewport from './types/IBrowserPageViewport.js'; + +export default { + width: 1024, + height: 768, + devicePixelRatio: 1 +}; diff --git a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts new file mode 100644 index 000000000..808d3f347 --- /dev/null +++ b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts @@ -0,0 +1,32 @@ +import PackageVersion from '../version.js'; +import BrowserErrorCaptureEnum from './enums/BrowserErrorCaptureEnum.js'; +import BrowserNavigationCrossOriginPolicyEnum from './enums/BrowserNavigationCrossOriginPolicyEnum.js'; +import IBrowserSettings from './types/IBrowserSettings.js'; + +export default { + disableJavaScriptEvaluation: false, + disableJavaScriptFileLoading: false, + disableCSSFileLoading: false, + disableIframePageLoading: false, + disableComputedStyleRendering: false, + disableErrorCapturing: false, + errorCapture: BrowserErrorCaptureEnum.tryAndCatch, + enableFileSystemHttpRequests: false, + navigation: { + disableMainFrameNavigation: false, + disableChildFrameNavigation: false, + disableChildPageNavigation: false, + disableFallbackToSetURL: false, + crossOriginPolicy: BrowserNavigationCrossOriginPolicyEnum.anyOrigin + }, + navigator: { + userAgent: `Mozilla/5.0 (X11; ${ + process.platform.charAt(0).toUpperCase() + process.platform.slice(1) + ' ' + process.arch + }) AppleWebKit/537.36 (KHTML, like Gecko) HappyDOM/${PackageVersion.version}`, + maxTouchPoints: 0 + }, + device: { + prefersColorScheme: 'light', + mediaType: 'screen' + } +}; diff --git a/packages/happy-dom/src/browser/detached-browser/DetachedBrowser.ts b/packages/happy-dom/src/browser/detached-browser/DetachedBrowser.ts new file mode 100644 index 000000000..4959d35fb --- /dev/null +++ b/packages/happy-dom/src/browser/detached-browser/DetachedBrowser.ts @@ -0,0 +1,111 @@ +import IBrowserSettings from '../types/IBrowserSettings.js'; +import DetachedBrowserContext from './DetachedBrowserContext.js'; +import IOptionalBrowserSettings from '../types/IOptionalBrowserSettings.js'; +import BrowserSettingsFactory from '../BrowserSettingsFactory.js'; +import DetachedBrowserPage from './DetachedBrowserPage.js'; +import IBrowser from '../types/IBrowser.js'; +import IBrowserFrame from '../types/IBrowserFrame.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; + +/** + * Detached browser used when constructing a Window instance without a browser. + * + * Much of the interface for the browser has been taken from Puppeteer and Playwright, so that the API is familiar. + */ +export default class DetachedBrowser implements IBrowser { + public readonly contexts: DetachedBrowserContext[]; + public readonly settings: IBrowserSettings; + public readonly console: Console | null; + public readonly windowClass: new ( + browserFrame: IBrowserFrame, + options?: { url?: string; width?: number; height?: number } + ) => IBrowserWindow | null; + + /** + * Constructor. + * + * @param windowClass Window class. + * @param [options] Options. + * @param [options.settings] Browser settings. + * @param [options.console] Console. + */ + constructor( + windowClass: new ( + browserFrame: IBrowserFrame, + options?: { url?: string; width?: number; height?: number } + ) => IBrowserWindow, + options?: { settings?: IOptionalBrowserSettings; console?: Console } + ) { + this.windowClass = windowClass; + this.console = options?.console || null; + this.settings = BrowserSettingsFactory.getSettings(options?.settings); + this.contexts = []; + this.contexts.push(new DetachedBrowserContext(this)); + } + + /** + * Returns the default context. + * + * @returns Default context. + */ + public get defaultContext(): DetachedBrowserContext { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + return this.contexts[0]; + } + + /** + * Aborts all ongoing operations and destroys the browser. + */ + public async close(): Promise { + await Promise.all(this.contexts.slice().map((context) => context.close())); + (this.contexts) = []; + (this.console) = null; + ( IBrowserWindow | null>this.windowClass) = null; + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + public async waitUntilComplete(): Promise { + await Promise.all(this.contexts.map((page) => page.waitUntilComplete())); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + // Using Promise instead of async/await to prevent microtask + return new Promise((resolve, reject) => { + if (!this.contexts.length) { + resolve(); + return; + } + Promise.all(this.contexts.slice().map((context) => context.abort())) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + } + + /** + * Creates a new incognito context. + */ + public newIncognitoContext(): DetachedBrowserContext { + throw new Error('Not possible to create a new context on a detached browser.'); + } + + /** + * Creates a new page. + * + * @returns Page. + */ + public newPage(): DetachedBrowserPage { + if (this.contexts.length === 0) { + throw new Error('No default context. The browser has been closed.'); + } + return this.contexts[0].newPage(); + } +} diff --git a/packages/happy-dom/src/browser/detached-browser/DetachedBrowserContext.ts b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserContext.ts new file mode 100644 index 000000000..c1e73419a --- /dev/null +++ b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserContext.ts @@ -0,0 +1,92 @@ +import DetachedBrowser from './DetachedBrowser.js'; +import DetachedBrowserPage from './DetachedBrowserPage.js'; +import IBrowserContext from '../types/IBrowserContext.js'; +import ICookieContainer from '../../cookie/types/ICookieContainer.js'; +import CookieContainer from '../../cookie/CookieContainer.js'; +import ResponseCache from '../../fetch/cache/response/ResponseCache.js'; +import IResponseCache from '../../fetch/cache/response/IResponseCache.js'; +import IPreflightResponseCache from '../../fetch/cache/preflight/IPreflightResponseCache.js'; +import PreflightResponseCache from '../../fetch/cache/preflight/PreflightResponseCache.js'; + +/** + * Detached browser context used when constructing a Window instance without a browser. + */ +export default class DetachedBrowserContext implements IBrowserContext { + public readonly pages: DetachedBrowserPage[]; + public readonly browser: DetachedBrowser; + public readonly cookieContainer: ICookieContainer = new CookieContainer(); + public readonly responseCache: IResponseCache = new ResponseCache(); + public readonly preflightResponseCache: IPreflightResponseCache = new PreflightResponseCache(); + + /** + * Constructor. + * + * @param browser Browser. + */ + constructor(browser: DetachedBrowser) { + this.browser = browser; + this.pages = []; + this.pages.push(new DetachedBrowserPage(this)); + } + + /** + * Aborts all ongoing operations and destroys the context. + */ + public async close(): Promise { + if (!this.browser) { + return; + } + await Promise.all(this.pages.slice().map((page) => page.close())); + const browser = this.browser; + const index = browser.contexts.indexOf(this); + if (index !== -1) { + browser.contexts.splice(index, 1); + } + (this.pages) = []; + (this.browser) = null; + (this.cookieContainer) = null; + this.responseCache.clear(); + this.preflightResponseCache.clear(); + (this.responseCache) = null; + (this.preflightResponseCache) = null; + if (browser.contexts.length === 0) { + browser.close(); + } + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + public async waitUntilComplete(): Promise { + await Promise.all(this.pages.map((page) => page.waitUntilComplete())); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + return new Promise((resolve, reject) => { + if (!this.pages.length) { + resolve(); + return; + } + Promise.all(this.pages.slice().map((page) => page.abort())) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + } + + /** + * Creates a new page. + * + * @param [opener] Opener. + * @returns Page. + */ + public newPage(): DetachedBrowserPage { + const page = new DetachedBrowserPage(this); + this.pages.push(page); + return page; + } +} diff --git a/packages/happy-dom/src/browser/detached-browser/DetachedBrowserFrame.ts b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserFrame.ts new file mode 100644 index 000000000..f602d9136 --- /dev/null +++ b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserFrame.ts @@ -0,0 +1,188 @@ +import DetachedBrowserPage from './DetachedBrowserPage.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import AsyncTaskManager from '../../async-task-manager/AsyncTaskManager.js'; +import IBrowserFrame from '../types/IBrowserFrame.js'; +import Location from '../../location/Location.js'; +import IResponse from '../../fetch/types/IResponse.js'; +import IGoToOptions from '../types/IGoToOptions.js'; +import { Script } from 'vm'; +import BrowserFrameURL from '../utilities/BrowserFrameURL.js'; +import BrowserFrameScriptEvaluator from '../utilities/BrowserFrameScriptEvaluator.js'; +import BrowserFrameNavigator from '../utilities/BrowserFrameNavigator.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import IReloadOptions from '../types/IReloadOptions.js'; +import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; +import BrowserFrameExceptionObserver from '../utilities/BrowserFrameExceptionObserver.js'; +import IDocument from '../../nodes/document/IDocument.js'; +import ICrossOriginBrowserWindow from '../../window/ICrossOriginBrowserWindow.js'; + +/** + * Browser frame used when constructing a Window instance without a browser. + */ +export default class DetachedBrowserFrame implements IBrowserFrame { + public readonly childFrames: DetachedBrowserFrame[] = []; + public readonly parentFrame: DetachedBrowserFrame | null = null; + public readonly page: DetachedBrowserPage; + // Needs to be injected from the outside when the browser frame is constructed. + public window: IBrowserWindow; + public [PropertySymbol.asyncTaskManager] = new AsyncTaskManager(); + public [PropertySymbol.exceptionObserver]: BrowserFrameExceptionObserver | null = null; + public [PropertySymbol.listeners]: { navigation: Array<() => void> } = { navigation: [] }; + public [PropertySymbol.openerFrame]: IBrowserFrame | null = null; + public [PropertySymbol.openerWindow]: IBrowserWindow | ICrossOriginBrowserWindow | null = null; + public [PropertySymbol.popup] = false; + + /** + * Constructor. + * + * @param page Page. + * @param [window] Window. + */ + constructor(page: DetachedBrowserPage) { + this.page = page; + if (page.context.browser.contexts[0]?.pages[0]?.mainFrame) { + this.window = new this.page.context.browser.windowClass(this); + } + + // Attach process level error capturing. + if (page.context.browser.settings.errorCapture === BrowserErrorCaptureEnum.processLevel) { + this[PropertySymbol.exceptionObserver] = new BrowserFrameExceptionObserver(); + this[PropertySymbol.exceptionObserver].observe(this); + } + } + + /** + * Returns the content. + * + * @returns Content. + */ + public get content(): string { + if (!this.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + return this.window.document.documentElement.outerHTML; + } + + /** + * Sets the content. + * + * @param content Content. + */ + public set content(content) { + if (!this.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + this.window.document[PropertySymbol.isFirstWrite] = true; + this.window.document[PropertySymbol.isFirstWriteAfterOpen] = false; + this.window.document.open(); + this.window.document.write(content); + } + + /** + * Returns the URL. + * + * @returns URL. + */ + public get url(): string { + if (!this.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + return this.window.location.href; + } + + /** + * Sets the content. + * + * @param url URL. + */ + public set url(url) { + if (!this.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + (this.window.location) = new Location( + this, + BrowserFrameURL.getRelativeURL(this, url).href + ); + } + + /** + * Returns document. + * + * @returns Document. + */ + public get document(): IDocument { + return this.window?.document ?? null; + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + public async waitUntilComplete(): Promise { + await Promise.all([ + this[PropertySymbol.asyncTaskManager].waitUntilComplete(), + ...this.childFrames.map((frame) => frame.waitUntilComplete()) + ]); + } + + /** + * Returns a promise that is resolved when the frame has navigated and the response HTML has been written to the document. + */ + public waitForNavigation(): Promise { + return new Promise((resolve) => this[PropertySymbol.listeners].navigation.push(resolve)); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + if (!this.childFrames.length) { + return this[PropertySymbol.asyncTaskManager].abort(); + } + return new Promise((resolve, reject) => { + // Using Promise instead of async/await to prevent microtask + Promise.all( + this.childFrames + .map((frame) => frame.abort()) + .concat([this[PropertySymbol.asyncTaskManager].abort()]) + ) + .then(() => resolve()) + .catch(reject); + }); + } + + /** + * Evaluates code or a VM Script in the page's context. + * + * @param script Script. + * @returns Result. + */ + public evaluate(script: string | Script): any { + return BrowserFrameScriptEvaluator.evaluate(this, script); + } + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + * @returns Response. + */ + public goto(url: string, options?: IGoToOptions): Promise { + return BrowserFrameNavigator.goto(this.page.context.browser.windowClass, this, url, options); + } + + /** + * Reloads the current frame. + * + * @param [options] Options. + * @returns Response. + */ + public reload(options: IReloadOptions): Promise { + return BrowserFrameNavigator.goto( + this.page.context.browser.windowClass, + this, + this.url, + options + ); + } +} diff --git a/packages/happy-dom/src/browser/detached-browser/DetachedBrowserPage.ts b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserPage.ts new file mode 100644 index 000000000..943d27463 --- /dev/null +++ b/packages/happy-dom/src/browser/detached-browser/DetachedBrowserPage.ts @@ -0,0 +1,165 @@ +import VirtualConsolePrinter from '../../console/VirtualConsolePrinter.js'; +import DetachedBrowserFrame from './DetachedBrowserFrame.js'; +import DetachedBrowserContext from './DetachedBrowserContext.js'; +import VirtualConsole from '../../console/VirtualConsole.js'; +import IBrowserPage from '../types/IBrowserPage.js'; +import { Script } from 'vm'; +import IGoToOptions from '../types/IGoToOptions.js'; +import IResponse from '../../fetch/types/IResponse.js'; +import BrowserPageUtility from '../utilities/BrowserPageUtility.js'; +import IReloadOptions from '../types/IReloadOptions.js'; +import DefaultBrowserPageViewport from '../DefaultBrowserPageViewport.js'; +import IOptionalBrowserPageViewport from '../types/IOptionalBrowserPageViewport.js'; +import IBrowserPageViewport from '../types/IBrowserPageViewport.js'; +import Event from '../../event/Event.js'; + +/** + * Detached browser page used when constructing a Window instance without a browser. + */ +export default class DetachedBrowserPage implements IBrowserPage { + public readonly virtualConsolePrinter = new VirtualConsolePrinter(); + public readonly mainFrame: DetachedBrowserFrame; + public readonly context: DetachedBrowserContext; + public readonly console: Console; + public readonly viewport: IBrowserPageViewport = Object.assign({}, DefaultBrowserPageViewport); + + /** + * Constructor. + * + * @param context Browser context. + */ + constructor(context: DetachedBrowserContext) { + this.context = context; + this.console = context.browser.console ?? new VirtualConsole(this.virtualConsolePrinter); + this.mainFrame = new DetachedBrowserFrame(this); + } + + /** + * Returns frames. + */ + public get frames(): DetachedBrowserFrame[] { + return BrowserPageUtility.getFrames(this); + } + + /** + * Returns the viewport. + */ + public get content(): string { + return this.mainFrame.content; + } + + /** + * Sets the content. + * + * @param content Content. + */ + public set content(content) { + this.mainFrame.content = content; + } + + /** + * Returns the URL. + * + * @returns URL. + */ + public get url(): string { + return this.mainFrame.url; + } + + /** + * Sets the content. + * + * @param url URL. + */ + public set url(url) { + this.mainFrame.url = url; + } + + /** + * Aborts all ongoing operations and destroys the page. + */ + public close(): Promise { + // Using Promise instead of async/await to prevent microtask + return new Promise((resolve, reject) => { + const context = this.context; + BrowserPageUtility.closePage(this) + .then(() => { + // As we are in a detached page, a context or browser should not exist without a page as there are no references to them. + if (context.pages[0] === this) { + context.close(); + } + resolve(); + }) + .catch((error) => reject(error)); + }); + } + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + public waitUntilComplete(): Promise { + return this.mainFrame.waitUntilComplete(); + } + + /** + * Returns a promise that is resolved when the page has navigated and the response HTML has been written to the document. + */ + public waitForNavigation(): Promise { + return this.mainFrame.waitForNavigation(); + } + + /** + * Aborts all ongoing operations. + */ + public abort(): Promise { + return this.mainFrame.abort(); + } + + /** + * Evaluates code or a VM Script in the page's context. + * + * @param script Script. + * @returns Result. + */ + public evaluate(script: string | Script): any { + return this.mainFrame.evaluate(script); + } + + /** + * Sets the viewport. + * + * @param viewport Viewport. + */ + public setViewport(viewport: IOptionalBrowserPageViewport): void { + const previousViewport = Object.assign({}, this.viewport); + Object.assign(this.viewport, viewport); + if ( + previousViewport.width !== this.viewport.width || + previousViewport.height !== this.viewport.height || + previousViewport.devicePixelRatio !== this.viewport.devicePixelRatio + ) { + this.mainFrame.window.dispatchEvent(new Event('resize')); + } + } + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + * @returns Response. + */ + public goto(url: string, options?: IGoToOptions): Promise { + return this.mainFrame.goto(url, options); + } + + /** + * Reloads the current page. + * + * @param [options] Options. + * @returns Response. + */ + public reload(options: IReloadOptions): Promise { + return this.mainFrame.reload(options); + } +} diff --git a/packages/happy-dom/src/browser/enums/BrowserErrorCaptureEnum.ts b/packages/happy-dom/src/browser/enums/BrowserErrorCaptureEnum.ts new file mode 100644 index 000000000..f7c004f2a --- /dev/null +++ b/packages/happy-dom/src/browser/enums/BrowserErrorCaptureEnum.ts @@ -0,0 +1,10 @@ +enum BrowserErrorCaptureEnum { + /** Happy DOM use try and catch when evaluating code, but will not be able to catch all errors and Promise rejections. This will decrease performance as using try and catch makes the execution significally slower. This is the default setting. */ + tryAndCatch = 'tryAndCatch', + /** Happy DOM will add an event listener to the Node.js process to catch all errors and Promise rejections. This will not work in Jest and Vitest as it conflicts with their error listeners. */ + processLevel = 'processLevel', + /** Error capturing is disabled. Errors and Promise rejections will be thrown. */ + disabled = 'disabled' +} + +export default BrowserErrorCaptureEnum; diff --git a/packages/happy-dom/src/browser/enums/BrowserNavigationCrossOriginPolicyEnum.ts b/packages/happy-dom/src/browser/enums/BrowserNavigationCrossOriginPolicyEnum.ts new file mode 100644 index 000000000..10bd41767 --- /dev/null +++ b/packages/happy-dom/src/browser/enums/BrowserNavigationCrossOriginPolicyEnum.ts @@ -0,0 +1,10 @@ +enum BrowserNavigationCrossOriginPolicyEnum { + /** The browser can navigate to any origin. */ + anyOrigin = 'anyOrigin', + /** The browser can only navigate to the same origin as the current page or its parent. */ + sameOrigin = 'sameOrigin', + /** The browser can never navigate from a secure protocol (https) to an unsecure protocol (http), but it can always navigate to a secure (https). */ + strictOrigin = 'strictOrigin' +} + +export default BrowserNavigationCrossOriginPolicyEnum; diff --git a/packages/happy-dom/src/browser/types/IBrowser.ts b/packages/happy-dom/src/browser/types/IBrowser.ts new file mode 100644 index 000000000..34fd479c4 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowser.ts @@ -0,0 +1,46 @@ +import IBrowserContext from './IBrowserContext.js'; +import IBrowserPage from './IBrowserPage.js'; +import IBrowserSettings from './IBrowserSettings.js'; + +/** + * Browser. + * + * Much of the interface for the browser has been taken from Puppeteer and Playwright, so that the API is familiar. + */ +export default interface IBrowser { + readonly defaultContext: IBrowserContext; + readonly contexts: IBrowserContext[]; + readonly settings: IBrowserSettings; + readonly console: Console | null; + + /** + * Aborts all ongoing operations and destroys the browser. + */ + close(): void; + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + waitUntilComplete(): Promise; + + /** + * Aborts all ongoing operations. + */ + abort(): void; + + /** + * Creates a new incognito context. + * + * @returns Context. + */ + newIncognitoContext(): IBrowserContext; + + /** + * Creates a new page. + * + * @returns Page. + */ + newPage(): IBrowserPage; +} diff --git a/packages/happy-dom/src/browser/types/IBrowserContext.ts b/packages/happy-dom/src/browser/types/IBrowserContext.ts new file mode 100644 index 000000000..c4b4b01bc --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowserContext.ts @@ -0,0 +1,40 @@ +import ICookieContainer from '../../cookie/types/ICookieContainer.js'; +import IResponseCache from '../../fetch/cache/response/IResponseCache.js'; +import IBrowser from './IBrowser.js'; +import IBrowserPage from './IBrowserPage.js'; +import IPreflightResponseCache from '../../fetch/cache/preflight/IPreflightResponseCache.js'; + +/** + * Browser context. + */ +export default interface IBrowserContext { + readonly pages: IBrowserPage[]; + readonly browser: IBrowser; + readonly cookieContainer: ICookieContainer; + readonly responseCache: IResponseCache; + readonly preflightResponseCache: IPreflightResponseCache; + + /** + * Aborts all ongoing operations and destroys the context. + */ + close(): Promise; + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + * + * @returns Promise. + */ + waitUntilComplete(): Promise; + + /** + * Aborts all ongoing operations. + */ + abort(): Promise; + + /** + * Creates a new page. + * + * @returns Page. + */ + newPage(): IBrowserPage; +} diff --git a/packages/happy-dom/src/browser/types/IBrowserFrame.ts b/packages/happy-dom/src/browser/types/IBrowserFrame.ts new file mode 100644 index 000000000..f3dc8d5a1 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowserFrame.ts @@ -0,0 +1,68 @@ +import AsyncTaskManager from '../../async-task-manager/AsyncTaskManager.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import IDocument from '../../nodes/document/IDocument.js'; +import IBrowserPage from './IBrowserPage.js'; +import IResponse from '../../fetch/types/IResponse.js'; +import IGoToOptions from './IGoToOptions.js'; +import { Script } from 'vm'; +import IReloadOptions from './IReloadOptions.js'; +import BrowserFrameExceptionObserver from '../utilities/BrowserFrameExceptionObserver.js'; +import ICrossOriginBrowserWindow from '../../window/ICrossOriginBrowserWindow.js'; + +/** + * Browser frame. + */ +export default interface IBrowserFrame { + readonly childFrames: IBrowserFrame[]; + readonly parentFrame: IBrowserFrame | null; + readonly page: IBrowserPage; + readonly window: IBrowserWindow; + readonly document: IDocument; + content: string; + url: string; + [PropertySymbol.asyncTaskManager]: AsyncTaskManager; + [PropertySymbol.exceptionObserver]: BrowserFrameExceptionObserver | null; + [PropertySymbol.listeners]: { navigation: Array<() => void> }; + [PropertySymbol.openerFrame]: IBrowserFrame | null; + [PropertySymbol.openerWindow]: IBrowserWindow | ICrossOriginBrowserWindow | null; + [PropertySymbol.popup]: boolean; + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + waitUntilComplete(): Promise; + + /** + * Returns a promise that is resolved when the frame has navigated and the response HTML has been written to the document. + */ + waitForNavigation(): Promise; + + /** + * Aborts all ongoing operations. + */ + abort(): Promise; + + /** + * Evaluates code or a VM Script in the page's context. + * + * @param script Script. + * @returns Result. + */ + evaluate(script: string | Script): any; + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + */ + goto(url: string, options?: IGoToOptions): Promise; + + /** + * Reloads the current frame. + * + * @param [options] Options. + */ + reload(options: IReloadOptions): Promise; +} diff --git a/packages/happy-dom/src/browser/types/IBrowserPage.ts b/packages/happy-dom/src/browser/types/IBrowserPage.ts new file mode 100644 index 000000000..3e16ddee9 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowserPage.ts @@ -0,0 +1,71 @@ +import IBrowserPageViewport from '../types/IBrowserPageViewport.js'; +import VirtualConsolePrinter from '../../console/VirtualConsolePrinter.js'; +import IBrowserFrame from './IBrowserFrame.js'; +import IBrowserContext from './IBrowserContext.js'; +import { Script } from 'vm'; +import IGoToOptions from './IGoToOptions.js'; +import IResponse from '../../fetch/types/IResponse.js'; +import IReloadOptions from './IReloadOptions.js'; +import IOptionalBrowserPageViewport from './IOptionalBrowserPageViewport.js'; + +/** + * Browser page. + */ +export default interface IBrowserPage { + readonly virtualConsolePrinter: VirtualConsolePrinter; + readonly mainFrame: IBrowserFrame; + readonly context: IBrowserContext; + readonly console: Console; + readonly frames: IBrowserFrame[]; + readonly viewport: IBrowserPageViewport; + content: string; + url: string; + + /** + * Aborts all ongoing operations and destroys the page. + */ + close(): Promise; + + /** + * Returns a promise that is resolved when all resources has been loaded, fetch has completed, and all async tasks such as timers are complete. + */ + waitUntilComplete(): Promise; + + /** + * Returns a promise that is resolved when the page has navigated and the response HTML has been written to the document. + */ + waitForNavigation(): Promise; + + /** + * Aborts all ongoing operations. + */ + abort(): Promise; + + /** + * Evaluates code or a VM Script in the page's context. + */ + evaluate(script: string | Script): any; + + /** + * Sets the viewport. + * + * @param viewport Viewport. + */ + setViewport(viewport: IOptionalBrowserPageViewport): void; + + /** + * Go to a page. + * + * @param url URL. + * @param [options] Options. + */ + goto(url: string, options?: IGoToOptions): Promise; + + /** + * Reloads the current page. + * + * @param [options] Options. + * @returns Response. + */ + reload(options: IReloadOptions): Promise; +} diff --git a/packages/happy-dom/src/browser/types/IBrowserPageViewport.ts b/packages/happy-dom/src/browser/types/IBrowserPageViewport.ts new file mode 100644 index 000000000..9a952f9e1 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowserPageViewport.ts @@ -0,0 +1,5 @@ +export default interface IBrowserPageViewport { + width: number; + height: number; + devicePixelRatio: number; +} diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts new file mode 100644 index 000000000..c53cddb3a --- /dev/null +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -0,0 +1,77 @@ +import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; +import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; + +/** + * Browser settings. + */ +export default interface IBrowserSettings { + /** Disables JavaScript evaluation. */ + disableJavaScriptEvaluation: boolean; + + /** Disables JavaScript file loading. */ + disableJavaScriptFileLoading: boolean; + + /** Disables CSS file loading. */ + disableCSSFileLoading: boolean; + + /** Disables computed style rendering. */ + disableComputedStyleRendering: boolean; + + /** + * Disables error capturing. + * + * @deprecated Use errorCapture instead. + */ + disableErrorCapturing: boolean; + + /** + * Error capturing policy. + */ + errorCapture: BrowserErrorCaptureEnum; + + /** + * @deprecated Not something that browsers support anymore as it is not secure. + */ + enableFileSystemHttpRequests: boolean; + + /** + * @deprecated Use navigation.disableChildFrameNavigation instead. + */ + disableIframePageLoading: boolean; + + /** + * Settings for the browser's navigation (when following links or opening windows). + */ + navigation: { + /** Disables navigation to other pages in the main frame or a page. */ + disableMainFrameNavigation: boolean; + + /** Disables navigation to other pages in child frames (such as iframes). */ + disableChildFrameNavigation: boolean; + + /** Disables navigation to other pages in child pages (such as popup windows). */ + disableChildPageNavigation: boolean; + + /** Disables the fallback to setting the URL when navigating to a page is disabled or when inside a detached browser frame. */ + disableFallbackToSetURL: boolean; + + /** Sets the policy for cross-origin navigation. */ + crossOriginPolicy: BrowserNavigationCrossOriginPolicyEnum; + }; + + /** + * Settings for the browser's navigator. + */ + navigator: { + userAgent: string; + maxTouchPoints: number; + }; + + /** + * Settings for the browser's device. + */ + device: { + prefersColorScheme: string; + mediaType: string; + }; +} diff --git a/packages/happy-dom/src/browser/types/IGoToOptions.ts b/packages/happy-dom/src/browser/types/IGoToOptions.ts new file mode 100644 index 000000000..7b051546c --- /dev/null +++ b/packages/happy-dom/src/browser/types/IGoToOptions.ts @@ -0,0 +1,17 @@ +import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js'; +import IReloadOptions from './IReloadOptions.js'; + +/** + * Go to options. + */ +export default interface IGoToOptions extends IReloadOptions { + /** + * Referrer. + */ + referrer?: string; + + /** + * Referrer policy. + */ + referrerPolicy?: IRequestReferrerPolicy; +} diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserPageViewport.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserPageViewport.ts new file mode 100644 index 000000000..d80c11958 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserPageViewport.ts @@ -0,0 +1,5 @@ +export default interface IOptionalBrowserPageViewport { + width?: number; + height?: number; + devicePixelRatio?: number; +} diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts new file mode 100644 index 000000000..fed9ef5b4 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -0,0 +1,74 @@ +import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; +import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; + +export default interface IOptionalBrowserSettings { + /** Disables JavaScript evaluation. */ + disableJavaScriptEvaluation?: boolean; + + /** Disables JavaScript file loading. */ + disableJavaScriptFileLoading?: boolean; + + /** Disables CSS file loading. */ + disableCSSFileLoading?: boolean; + + /** Disables computed style rendering. */ + disableComputedStyleRendering?: boolean; + + /** + * Disables error capturing. + * + * @deprecated Use errorCapture instead. + */ + disableErrorCapturing?: boolean; + + /** + * Error capturing policy. + */ + errorCapture?: BrowserErrorCaptureEnum; + + /** + * @deprecated Not something that browsers support anymore as it is not secure. + */ + enableFileSystemHttpRequests?: boolean; + + /** + * @deprecated Use navigation.disableChildFrameNavigation instead. + */ + disableIframePageLoading?: boolean; + + /** + * Settings for the browser's navigation (when following links or opening windows). + */ + navigation?: { + /** Disables navigation to other pages in the main frame or a page. */ + disableMainFrameNavigation?: boolean; + + /** Disables navigation to other pages in child frames (such as iframes). */ + disableChildFrameNavigation?: boolean; + + /** Disables navigation to other pages in child pages (such as popup windows). */ + disableChildPageNavigation?: boolean; + + /** Disables the fallback to setting the URL when navigating to a page is disabled or when inside a detached browser frame. */ + disableFallbackToSetURL?: boolean; + + /** Sets the policy for cross-origin navigation. */ + crossOriginPolicy?: BrowserNavigationCrossOriginPolicyEnum; + }; + + /** + * Settings for the browser's navigator. + */ + navigator?: { + userAgent?: string; + maxTouchPoints?: number; + }; + + /** + * Settings for the browser's device. + */ + device?: { + prefersColorScheme?: string; + mediaType?: string; + }; +} diff --git a/packages/happy-dom/src/browser/types/IReloadOptions.ts b/packages/happy-dom/src/browser/types/IReloadOptions.ts new file mode 100644 index 000000000..2ddbcf5b5 --- /dev/null +++ b/packages/happy-dom/src/browser/types/IReloadOptions.ts @@ -0,0 +1,14 @@ +/** + * Reload options. + */ +export default interface IReloadOptions { + /** + * Set to true to bypass the cache. + */ + hard?: boolean; + + /** + * Timeout in ms. Default is 30000ms. + */ + timeout?: number; +} diff --git a/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameExceptionObserver.ts similarity index 59% rename from packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts rename to packages/happy-dom/src/browser/utilities/BrowserFrameExceptionObserver.ts index 2e4933054..1df264583 100644 --- a/packages/uncaught-exception-observer/src/UncaughtExceptionObserver.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameExceptionObserver.ts @@ -1,11 +1,11 @@ -import IWindow from 'happy-dom/lib/window/IWindow.js'; +import IBrowserFrame from '../types/IBrowserFrame.js'; /** * Listens for uncaught exceptions coming from Happy DOM on the running Node process and dispatches error events on the Window instance. */ -export default class UncaughtExceptionObserver { +export default class BrowserFrameExceptionObserver { private static listenerCount = 0; - private window: IWindow | null = null; + private browserFrame: IBrowserFrame | null = null; private uncaughtExceptionListener: ( error: Error, origin: 'uncaughtException' | 'unhandledRejection' @@ -15,16 +15,16 @@ export default class UncaughtExceptionObserver { /** * Observes the Node process for uncaught exceptions. * - * @param window + * @param browserFrame Browser frame. */ - public observe(window: IWindow): void { - if (this.window) { + public observe(browserFrame: IBrowserFrame): void { + if (this.browserFrame) { throw new Error('Already observing.'); } - this.window = window; + this.browserFrame = browserFrame; - (this.constructor).listenerCount++; + (this.constructor).listenerCount++; this.uncaughtExceptionListener = ( error: unknown, @@ -35,16 +35,16 @@ export default class UncaughtExceptionObserver { } if ( - (error instanceof this.window.Error || error instanceof this.window.DOMException) && - error.stack?.includes('/happy-dom/') + error instanceof this.browserFrame.window.Error || + error instanceof this.browserFrame.window.DOMException ) { - this.window.console.error(error); - this.window.dispatchEvent( - new this.window.ErrorEvent('error', { error, message: error.message }) + this.browserFrame.window.console.error(error); + this.browserFrame.window.dispatchEvent( + new this.browserFrame.window.ErrorEvent('error', { error, message: error.message }) ); } else if ( process.listenerCount('uncaughtException') === - (this.constructor).listenerCount + (this.constructor).listenerCount ) { // eslint-disable-next-line no-console console.error(error); @@ -57,16 +57,16 @@ export default class UncaughtExceptionObserver { // Therefore we want to use the "unhandledRejection" event as well. this.uncaughtRejectionListener = (error: unknown) => { if ( - (error instanceof this.window.Error || error instanceof this.window.DOMException) && - error.stack?.includes('/happy-dom/') + error instanceof this.browserFrame.window.Error || + error instanceof this.browserFrame.window.DOMException ) { - this.window.console.error(error); - this.window.dispatchEvent( - new this.window.ErrorEvent('error', { error, message: error.message }) + this.browserFrame.window.console.error(error); + this.browserFrame.window.dispatchEvent( + new this.browserFrame.window.ErrorEvent('error', { error, message: error.message }) ); } else if ( process.listenerCount('unhandledRejection') === - (this.constructor).listenerCount + (this.constructor).listenerCount ) { // eslint-disable-next-line no-console console.error(error); @@ -83,17 +83,17 @@ export default class UncaughtExceptionObserver { * Disconnects observer. */ public disconnect(): void { - if (!this.window) { + if (!this.browserFrame) { return; } - (this.constructor).listenerCount--; + (this.constructor).listenerCount--; process.off('uncaughtException', this.uncaughtExceptionListener); process.off('unhandledRejection', this.uncaughtRejectionListener); this.uncaughtExceptionListener = null; this.uncaughtRejectionListener = null; - this.window = null; + this.browserFrame = null; } } diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts new file mode 100644 index 000000000..f506ae6bc --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts @@ -0,0 +1,71 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import IBrowserPage from '../types/IBrowserPage.js'; +/** + * Browser frame factory. + */ +export default class BrowserFrameFactory { + /** + * Creates a new frame. + * + * @param parentFrame Parent frame. + * @returns Frame. + */ + public static newChildFrame(parentFrame: IBrowserFrame): IBrowserFrame { + const frame = new ( IBrowserFrame>parentFrame.constructor)( + parentFrame.page + ); + (frame.parentFrame) = parentFrame; + parentFrame.childFrames.push(frame); + return frame; + } + + /** + * Aborts all ongoing operations and destroys the frame. + * + * @param frame Frame. + */ + public static destroyFrame(frame: IBrowserFrame): Promise { + // Using Promise instead of async/await to prevent microtask + return new Promise((resolve, reject) => { + if (!frame.window) { + resolve(); + return; + } + + if (frame.parentFrame) { + const index = frame.parentFrame.childFrames.indexOf(frame); + if (index !== -1) { + frame.parentFrame.childFrames.splice(index, 1); + } + } + + // We need to destroy the Window instance before triggering any async tasks as Window.close() is not async. + frame.window[PropertySymbol.destroy](); + (frame.page) = null; + (frame.window) = null; + frame[PropertySymbol.openerFrame] = null; + frame[PropertySymbol.openerWindow] = null; + + if (!frame.childFrames.length) { + return frame[PropertySymbol.asyncTaskManager] + .destroy() + .then(() => { + frame[PropertySymbol.exceptionObserver]?.disconnect(); + resolve(); + }) + .catch((error) => reject(error)); + } + + Promise.all(frame.childFrames.slice().map((childFrame) => this.destroyFrame(childFrame))) + .then(() => { + return frame[PropertySymbol.asyncTaskManager].destroy().then(() => { + frame[PropertySymbol.exceptionObserver]?.disconnect(); + resolve(); + }); + }) + .catch((error) => reject(error)); + }); + } +} diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts new file mode 100644 index 000000000..fc95b2fba --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts @@ -0,0 +1,179 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IGoToOptions from '../types/IGoToOptions.js'; +import IResponse from '../../fetch/types/IResponse.js'; +import DocumentReadyStateManager from '../../nodes/document/DocumentReadyStateManager.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import Location from '../../location/Location.js'; +import AbortController from '../../fetch/AbortController.js'; +import BrowserFrameFactory from './BrowserFrameFactory.js'; +import BrowserFrameURL from './BrowserFrameURL.js'; +import BrowserFrameValidator from './BrowserFrameValidator.js'; +import AsyncTaskManager from '../../async-task-manager/AsyncTaskManager.js'; +import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js'; + +/** + * Browser frame navigation utility. + */ +export default class BrowserFrameNavigator { + /** + * Go to a page. + * + * @throws Error if the request can't be resolved (because of SSL error or similar). It will not throw if the response is not ok. + * @param windowClass Window class. + * @param frame Frame. + * @param url URL. + * @param [options] Options. + * @returns Response. + */ + public static async goto( + windowClass: new ( + browserFrame: IBrowserFrame, + options?: { url?: string; width?: number; height?: number } + ) => IBrowserWindow, + frame: IBrowserFrame, + url: string, + options?: IGoToOptions + ): Promise { + const targetURL = BrowserFrameURL.getRelativeURL(frame, url); + + if (!frame.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + + if (targetURL.protocol === 'javascript:') { + if (frame && !frame.page.context.browser.settings.disableJavaScriptEvaluation) { + const readyStateManager = (< + { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } + >(frame.window))[PropertySymbol.readyStateManager]; + + readyStateManager.startTask(); + + // The browser will wait for the next tick before executing the script. + await new Promise((resolve) => frame.page.mainFrame.window.setTimeout(resolve)); + + const code = + '//# sourceURL=' + frame.url + '\n' + targetURL.href.replace('javascript:', ''); + + if ( + frame.page.context.browser.settings.disableErrorCapturing || + frame.page.context.browser.settings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + frame.window.eval(code); + } else { + WindowErrorUtility.captureError(frame.window, () => frame.window.eval(code)); + } + + readyStateManager.endTask(); + } + + return null; + } + + if (!BrowserFrameValidator.validateCrossOriginPolicy(frame, targetURL)) { + return null; + } + + if (!BrowserFrameValidator.validateFrameNavigation(frame)) { + if (!frame.page.context.browser.settings.navigation.disableFallbackToSetURL) { + (frame.window.location) = new Location(frame, targetURL.href); + } + + return null; + } + + const width = frame.window.innerWidth; + const height = frame.window.innerHeight; + const devicePixelRatio = frame.window.devicePixelRatio; + + for (const childFrame of frame.childFrames) { + BrowserFrameFactory.destroyFrame(childFrame); + } + + (frame.childFrames) = []; + frame.window[PropertySymbol.destroy](); + frame[PropertySymbol.asyncTaskManager].destroy(); + frame[PropertySymbol.asyncTaskManager] = new AsyncTaskManager(); + + (frame.window) = new windowClass(frame, { url: targetURL.href, width, height }); + (frame.window.devicePixelRatio) = devicePixelRatio; + + if (options?.referrer) { + frame.window.document[PropertySymbol.referrer] = options.referrer; + } + + if (targetURL.protocol === 'about:') { + return null; + } + + const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( + (frame.window) + ))[PropertySymbol.readyStateManager]; + + readyStateManager.startTask(); + + const abortController = new AbortController(); + let response: IResponse; + let responseText: string; + + const timeout = frame.window.setTimeout( + () => abortController.abort('Request timed out.'), + options?.timeout ?? 30000 + ); + const finalize = (): void => { + frame.window.clearTimeout(timeout); + readyStateManager.endTask(); + const listeners = frame[PropertySymbol.listeners].navigation; + frame[PropertySymbol.listeners].navigation = []; + for (const listener of listeners) { + listener(); + } + }; + + try { + response = await frame.window.fetch(targetURL.href, { + referrer: options?.referrer, + referrerPolicy: options?.referrerPolicy, + signal: abortController.signal, + headers: options?.hard ? { 'Cache-Control': 'no-cache' } : undefined + }); + + // Handles the "X-Frame-Options" header for child frames. + if (frame.parentFrame) { + const originURL = frame.parentFrame.window.location; + const xFrameOptions = response.headers.get('X-Frame-Options')?.toLowerCase(); + const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null'; + + if (xFrameOptions === 'deny' || (xFrameOptions === 'sameorigin' && !isSameOrigin)) { + throw new Error( + `Refused to display '${url}' in a frame because it set 'X-Frame-Options' to '${xFrameOptions}'.` + ); + } + } + + responseText = await response.text(); + } catch (error) { + finalize(); + throw error; + } + + if (!response.ok) { + frame.page.console.error(`GET ${targetURL.href} ${response.status} (${response.statusText})`); + } + + // Fixes issue where evaluating the response can throw an error. + // By using requestAnimationFrame() the error will not reject the promise. + // The error will be caught by process error level listener or a try and catch in the requestAnimationFrame(). + frame.window.requestAnimationFrame(() => (frame.content = responseText)); + + await new Promise((resolve) => + frame.window.requestAnimationFrame(() => { + finalize(); + resolve(null); + }) + ); + + return response; + } +} diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameScriptEvaluator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameScriptEvaluator.ts new file mode 100644 index 000000000..560da2ce6 --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameScriptEvaluator.ts @@ -0,0 +1,22 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import { Script } from 'vm'; + +/** + * Browser frame script evaluator. + */ +export default class BrowserFrameScriptEvaluator { + /** + * Evaluates code or a VM Script in the frame's context. + * + * @param frame Frame. + * @param script Script. + * @returns Result. + */ + public static evaluate(frame: IBrowserFrame, script: string | Script): any { + if (!frame.window) { + throw new Error('The frame has been destroyed, the "window" property is not set.'); + } + script = typeof script === 'string' ? new Script(script) : script; + return script.runInContext(frame.window); + } +} diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameURL.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameURL.ts new file mode 100644 index 000000000..e4edf48d6 --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameURL.ts @@ -0,0 +1,40 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import { URL } from 'url'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; + +/** + * Browser frame URL utility. + */ +export default class BrowserFrameURL { + /** + * Returns relative URL. + * + * @param frame Frame. + * @param url URL. + * @returns Relative URL. + */ + public static getRelativeURL(frame: IBrowserFrame, url: string): URL { + url = url || 'about:blank'; + + if (url.startsWith('about:') || url.startsWith('javascript:')) { + return new URL(url); + } + + try { + return new URL(url, frame.window.location); + } catch (e) { + if (frame.window.location.hostname) { + throw new DOMException( + `Failed to construct URL from string "${url}".`, + DOMExceptionNameEnum.uriMismatchError + ); + } else { + throw new DOMException( + `Failed to construct URL from string "${url}" relative to URL "${frame.window.location.href}".`, + DOMExceptionNameEnum.uriMismatchError + ); + } + } + } +} diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameValidator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameValidator.ts new file mode 100644 index 000000000..28ba9b15c --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameValidator.ts @@ -0,0 +1,83 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import { URL } from 'url'; +import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js'; +import DetachedBrowserFrame from '../detached-browser/DetachedBrowserFrame.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; + +/** + * Browser frame validator. + */ +export default class BrowserFrameValidator { + /** + * Returns true if the frame navigation complies with the cross origin policy. + * + * @param frame Frame. + * @param toURL URL. + * @returns True if the frame navigation complies with the cross origin policy. + */ + public static validateCrossOriginPolicy(frame: IBrowserFrame, toURL: URL): boolean { + const settings = frame.page.context.browser.settings; + let fromURL = frame.page.mainFrame.window.location; + + if (frame[PropertySymbol.openerFrame]) { + fromURL = frame[PropertySymbol.openerFrame].window.location; + } else if (frame.parentFrame) { + fromURL = frame.parentFrame.window.location; + } + + if ( + settings.navigation.crossOriginPolicy === BrowserNavigationCrossOriginPolicyEnum.sameOrigin && + fromURL.protocol !== 'about:' && + toURL.protocol !== 'about:' && + toURL.protocol !== 'javascript:' && + fromURL.origin !== toURL.origin + ) { + return false; + } + + if ( + settings.navigation.crossOriginPolicy === + BrowserNavigationCrossOriginPolicyEnum.strictOrigin && + fromURL.protocol === 'https:' && + toURL.protocol === 'http:' + ) { + return false; + } + + return true; + } + + /** + * Returns true if navigation is allowed for the frame. + * + * @param frame Frame. + * @returns True if navigation is allowed for the frame. + */ + public static validateFrameNavigation(frame: IBrowserFrame): boolean { + const settings = frame.page.context.browser.settings; + + // When using the Window instance directly and not via the Browser API we should not navigate the browser frame. + if ( + frame instanceof DetachedBrowserFrame && + frame.page.context === frame.page.context.browser.defaultContext && + frame.page.context.pages[0] === frame.page && + frame.page.mainFrame === frame + ) { + return false; + } + + if (settings.navigation.disableMainFrameNavigation && frame.page.mainFrame === frame) { + return false; + } + + if (settings.navigation.disableChildFrameNavigation && frame.page.mainFrame !== frame) { + return false; + } + + if (settings.navigation.disableChildPageNavigation && !!frame[PropertySymbol.openerFrame]) { + return false; + } + + return true; + } +} diff --git a/packages/happy-dom/src/browser/utilities/BrowserPageUtility.ts b/packages/happy-dom/src/browser/utilities/BrowserPageUtility.ts new file mode 100644 index 000000000..d4e2f41ca --- /dev/null +++ b/packages/happy-dom/src/browser/utilities/BrowserPageUtility.ts @@ -0,0 +1,63 @@ +import IBrowserFrame from '../types/IBrowserFrame.js'; +import IBrowserPage from '../types/IBrowserPage.js'; +import IVirtualConsolePrinter from '../../console/types/IVirtualConsolePrinter.js'; +import IBrowserContext from '../types/IBrowserContext.js'; +import BrowserFrameFactory from './BrowserFrameFactory.js'; + +/** + * Browser page utility. + */ +export default class BrowserPageUtility { + /** + * Returns frames for a page. + * + * @param page Page. + * @returns Frames. + */ + public static getFrames(page: IBrowserPage): IBrowserFrame[] { + return this.findFrames(page.mainFrame); + } + + /** + * Aborts all ongoing operations and destroys the page. + * + * @param page Page. + */ + public static closePage(page: IBrowserPage): Promise { + // Using Promise instead of async/await to prevent microtask + return new Promise((resolve, reject) => { + if (!page.mainFrame) { + resolve(); + return; + } + + const index = page.context.pages.indexOf(page); + if (index !== -1) { + page.context.pages.splice(index, 1); + } + + BrowserFrameFactory.destroyFrame(page.mainFrame) + .then(() => { + (page.virtualConsolePrinter) = null; + (page.mainFrame) = null; + (page.context) = null; + resolve(); + }) + .catch((error) => reject(error)); + }); + } + + /** + * Returns all frames. + * + * @param parentFrame Parent frame. + * @returns Frames, including the parent. + */ + private static findFrames(parentFrame: IBrowserFrame): IBrowserFrame[] { + let frames = [parentFrame]; + for (const frame of parentFrame.childFrames) { + frames = frames.concat(this.findFrames(frame)); + } + return frames; + } +} diff --git a/packages/happy-dom/src/clipboard/Clipboard.ts b/packages/happy-dom/src/clipboard/Clipboard.ts index 3b1980b71..097a679b8 100644 --- a/packages/happy-dom/src/clipboard/Clipboard.ts +++ b/packages/happy-dom/src/clipboard/Clipboard.ts @@ -1,5 +1,5 @@ import DOMException from '../exception/DOMException.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import ClipboardItem from './ClipboardItem.js'; import Blob from '../file/Blob.js'; @@ -10,7 +10,7 @@ import Blob from '../file/Blob.js'; * https://developer.mozilla.org/en-US/docs/Web/API/Clipboard. */ export default class Clipboard { - #ownerWindow: IWindow; + #ownerWindow: IBrowserWindow; #data: ClipboardItem[] = []; /** @@ -18,7 +18,7 @@ export default class Clipboard { * * @param ownerWindow Owner window. */ - constructor(ownerWindow: IWindow) { + constructor(ownerWindow: IBrowserWindow) { this.#ownerWindow = ownerWindow; } diff --git a/packages/happy-dom/src/config/ElementTag.ts b/packages/happy-dom/src/config/ElementTag.ts index d82fc5f84..dbcb19d98 100644 --- a/packages/happy-dom/src/config/ElementTag.ts +++ b/packages/happy-dom/src/config/ElementTag.ts @@ -1,155 +1,130 @@ -import HTMLElement from '../nodes/html-element/HTMLElement.js'; -import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement.js'; -import HTMLFormElement from '../nodes/html-form-element/HTMLFormElement.js'; -import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; -import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement.js'; -import SVGSVGElement from '../nodes/svg-element/SVGSVGElement.js'; -import SVGElement from '../nodes/svg-element/SVGElement.js'; -import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; -import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement.js'; -import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; -import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement.js'; -import HTMLLabelElement from '../nodes/html-label-element/HTMLLabelElement.js'; -import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement.js'; -import HTMLMetaElement from '../nodes/html-meta-element/HTMLMetaElement.js'; -import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement.js'; -import HTMLSelectElement from '../nodes/html-select-element/HTMLSelectElement.js'; -import HTMLOptionElement from '../nodes/html-option-element/HTMLOptionElement.js'; -import HTMLOptGroupElement from '../nodes/html-opt-group-element/HTMLOptGroupElement.js'; -import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement.js'; -import HTMLButtonElement from '../nodes/html-button-element/HTMLButtonElement.js'; -import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement.js'; -import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement.js'; -import HTMLAnchorElement from '../nodes/html-anchor-element/HTMLAnchorElement.js'; -import HTMLIFrameElement from '../nodes/html-iframe-element/HTMLIFrameElement.js'; - -export default { - A: HTMLAnchorElement, - ABBR: HTMLElement, - ADDRESS: HTMLElement, - AREA: HTMLElement, - ARTICLE: HTMLElement, - ASIDE: HTMLElement, - AUDIO: HTMLAudioElement, - B: HTMLElement, - BASE: HTMLBaseElement, - BDI: HTMLElement, - BDO: HTMLElement, - BLOCKQUAOTE: HTMLElement, - BODY: HTMLElement, - TEMPLATE: HTMLTemplateElement, - FORM: HTMLFormElement, - INPUT: HTMLInputElement, - TEXTAREA: HTMLTextAreaElement, - SCRIPT: HTMLScriptElement, - IMG: HTMLImageElement, - LINK: HTMLLinkElement, - STYLE: HTMLStyleElement, - LABEL: HTMLLabelElement, - SLOT: HTMLSlotElement, - SVG: SVGSVGElement, - G: SVGElement, - CIRCLE: SVGElement, - ELLIPSE: SVGElement, - LINE: SVGElement, - PATH: SVGElement, - POLYGON: SVGElement, - POLYLINE: SVGElement, - RECT: SVGElement, - STOP: SVGElement, - USE: SVGElement, - META: HTMLMetaElement, - BLOCKQUOTE: HTMLElement, - BR: HTMLElement, - BUTTON: HTMLButtonElement, - CANVAS: HTMLElement, - CAPTION: HTMLElement, - CITE: HTMLElement, - CODE: HTMLElement, - COL: HTMLElement, - COLGROUP: HTMLElement, - DATA: HTMLElement, - DATALIST: HTMLElement, - DD: HTMLElement, - DEL: HTMLElement, - DETAILS: HTMLElement, - DFN: HTMLElement, - DIALOG: HTMLDialogElement, - DIV: HTMLElement, - DL: HTMLElement, - DT: HTMLElement, - EM: HTMLElement, - EMBED: HTMLElement, - FIELDSET: HTMLElement, - FIGCAPTION: HTMLElement, - FIGURE: HTMLElement, - FOOTER: HTMLElement, - H1: HTMLElement, - H2: HTMLElement, - H3: HTMLElement, - H4: HTMLElement, - H5: HTMLElement, - H6: HTMLElement, - HEAD: HTMLElement, - HEADER: HTMLElement, - HGROUP: HTMLElement, - HR: HTMLElement, - HTML: HTMLElement, - I: HTMLElement, - IFRAME: HTMLIFrameElement, - INS: HTMLElement, - KBD: HTMLElement, - LEGEND: HTMLElement, - LI: HTMLElement, - MAIN: HTMLElement, - MAP: HTMLElement, - MARK: HTMLElement, - MATH: HTMLElement, - MENU: HTMLElement, - MENUITEM: HTMLElement, - METER: HTMLElement, - NAV: HTMLElement, - NOSCRIPT: HTMLElement, - OBJECT: HTMLElement, - OL: HTMLElement, - OPTGROUP: HTMLOptGroupElement, - OPTION: HTMLOptionElement, - OUTPUT: HTMLElement, - P: HTMLElement, - PARAM: HTMLElement, - PICTURE: HTMLElement, - PRE: HTMLElement, - PROGRESS: HTMLElement, - Q: HTMLElement, - RB: HTMLElement, - RP: HTMLElement, - RT: HTMLElement, - RTC: HTMLElement, - RUBY: HTMLElement, - S: HTMLElement, - SAMP: HTMLElement, - SECTION: HTMLElement, - SELECT: HTMLSelectElement, - SMALL: HTMLElement, - SOURCE: HTMLElement, - SPAN: HTMLElement, - STRONG: HTMLElement, - SUB: HTMLElement, - SUMMARY: HTMLElement, - SUP: HTMLElement, - TABLE: HTMLElement, - TBODY: HTMLElement, - TD: HTMLElement, - TFOOT: HTMLElement, - TH: HTMLElement, - THEAD: HTMLElement, - TIME: HTMLElement, - TITLE: HTMLElement, - TR: HTMLElement, - TRACK: HTMLElement, - U: HTMLElement, - UL: HTMLElement, - VAR: HTMLElement, - VIDEO: HTMLVideoElement, - WBR: HTMLElement +export default <{ [key: string]: string }>{ + A: 'HTMLAnchorElement', + ABBR: 'HTMLElement', + ADDRESS: 'HTMLElement', + AREA: 'HTMLElement', + ARTICLE: 'HTMLElement', + ASIDE: 'HTMLElement', + AUDIO: 'HTMLAudioElement', + B: 'HTMLElement', + BASE: 'HTMLBaseElement', + BDI: 'HTMLElement', + BDO: 'HTMLElement', + BLOCKQUAOTE: 'HTMLElement', + BODY: 'HTMLElement', + TEMPLATE: 'HTMLTemplateElement', + FORM: 'HTMLFormElement', + INPUT: 'HTMLInputElement', + TEXTAREA: 'HTMLTextAreaElement', + SCRIPT: 'HTMLScriptElement', + IMG: 'HTMLImageElement', + LINK: 'HTMLLinkElement', + STYLE: 'HTMLStyleElement', + LABEL: 'HTMLLabelElement', + SLOT: 'HTMLSlotElement', + SVG: 'SVGSVGElement', + G: 'SVGElement', + CIRCLE: 'SVGElement', + ELLIPSE: 'SVGElement', + LINE: 'SVGElement', + PATH: 'SVGElement', + POLYGON: 'SVGElement', + POLYLINE: 'SVGElement', + RECT: 'SVGElement', + STOP: 'SVGElement', + USE: 'SVGElement', + META: 'HTMLMetaElement', + BLOCKQUOTE: 'HTMLElement', + BR: 'HTMLElement', + BUTTON: 'HTMLButtonElement', + CANVAS: 'HTMLElement', + CAPTION: 'HTMLElement', + CITE: 'HTMLElement', + CODE: 'HTMLElement', + COL: 'HTMLElement', + COLGROUP: 'HTMLElement', + DATA: 'HTMLElement', + DATALIST: 'HTMLElement', + DD: 'HTMLElement', + DEL: 'HTMLElement', + DETAILS: 'HTMLElement', + DFN: 'HTMLElement', + DIALOG: 'HTMLDialogElement', + DIV: 'HTMLElement', + DL: 'HTMLElement', + DT: 'HTMLElement', + EM: 'HTMLElement', + EMBED: 'HTMLElement', + FIELDSET: 'HTMLElement', + FIGCAPTION: 'HTMLElement', + FIGURE: 'HTMLElement', + FOOTER: 'HTMLElement', + H1: 'HTMLElement', + H2: 'HTMLElement', + H3: 'HTMLElement', + H4: 'HTMLElement', + H5: 'HTMLElement', + H6: 'HTMLElement', + HEAD: 'HTMLElement', + HEADER: 'HTMLElement', + HGROUP: 'HTMLElement', + HR: 'HTMLElement', + HTML: 'HTMLElement', + I: 'HTMLElement', + IFRAME: 'HTMLIFrameElement', + INS: 'HTMLElement', + KBD: 'HTMLElement', + LEGEND: 'HTMLElement', + LI: 'HTMLElement', + MAIN: 'HTMLElement', + MAP: 'HTMLElement', + MARK: 'HTMLElement', + MATH: 'HTMLElement', + MENU: 'HTMLElement', + MENUITEM: 'HTMLElement', + METER: 'HTMLElement', + NAV: 'HTMLElement', + NOSCRIPT: 'HTMLElement', + OBJECT: 'HTMLElement', + OL: 'HTMLElement', + OPTGROUP: 'HTMLOptGroupElement', + OPTION: 'HTMLOptionElement', + OUTPUT: 'HTMLElement', + P: 'HTMLElement', + PARAM: 'HTMLElement', + PICTURE: 'HTMLElement', + PRE: 'HTMLElement', + PROGRESS: 'HTMLElement', + Q: 'HTMLElement', + RB: 'HTMLElement', + RP: 'HTMLElement', + RT: 'HTMLElement', + RTC: 'HTMLElement', + RUBY: 'HTMLElement', + S: 'HTMLElement', + SAMP: 'HTMLElement', + SECTION: 'HTMLElement', + SELECT: 'HTMLSelectElement', + SMALL: 'HTMLElement', + SOURCE: 'HTMLElement', + SPAN: 'HTMLElement', + STRONG: 'HTMLElement', + SUB: 'HTMLElement', + SUMMARY: 'HTMLElement', + SUP: 'HTMLElement', + TABLE: 'HTMLElement', + TBODY: 'HTMLElement', + TD: 'HTMLElement', + TFOOT: 'HTMLElement', + TH: 'HTMLElement', + THEAD: 'HTMLElement', + TIME: 'HTMLElement', + TITLE: 'HTMLElement', + TR: 'HTMLElement', + TRACK: 'HTMLElement', + U: 'HTMLElement', + UL: 'HTMLElement', + VAR: 'HTMLElement', + VIDEO: 'HTMLVideoElement', + WBR: 'HTMLElement' }; diff --git a/packages/happy-dom/src/console/VirtualConsole.ts b/packages/happy-dom/src/console/VirtualConsole.ts index 5c23f54eb..56232288b 100644 --- a/packages/happy-dom/src/console/VirtualConsole.ts +++ b/packages/happy-dom/src/console/VirtualConsole.ts @@ -15,11 +15,11 @@ export default class VirtualConsole implements Console { // This is not part of the browser specs. public Console: ConsoleConstructor; - private _printer: IVirtualConsolePrinter; - private _count: { [label: string]: number } = {}; - private _time: { [label: string]: number } = {}; - private _groupID = 0; - private _groups: IVirtualConsoleLogGroup[] = []; + #printer: IVirtualConsolePrinter; + #count: { [label: string]: number } = {}; + #time: { [label: string]: number } = {}; + #groupID = 0; + #groups: IVirtualConsoleLogGroup[] = []; /** * Constructor. @@ -27,7 +27,7 @@ export default class VirtualConsole implements Console { * @param printer Console printer. */ constructor(printer: IVirtualConsolePrinter) { - this._printer = printer; + this.#printer = printer; } /** @@ -38,11 +38,11 @@ export default class VirtualConsole implements Console { */ public assert(assertion: boolean, ...args: Array): void { if (!assertion) { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.assert, level: VirtualConsoleLogLevelEnum.error, message: ['Assertion failed:', ...args], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } } @@ -51,7 +51,7 @@ export default class VirtualConsole implements Console { * Clears the console. */ public clear(): void { - this._printer.clear(); + this.#printer.clear(); } /** @@ -60,17 +60,17 @@ export default class VirtualConsole implements Console { * @param [label='default'] Label. */ public count(label = 'default'): void { - if (!this._count[label]) { - this._count[label] = 0; + if (!this.#count[label]) { + this.#count[label] = 0; } - this._count[label]++; + this.#count[label]++; - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.count, level: VirtualConsoleLogLevelEnum.info, - message: [`${label}: ${this._count[label]}`], - group: this._groups[this._groups.length - 1] || null + message: [`${label}: ${this.#count[label]}`], + group: this.#groups[this.#groups.length - 1] || null }); } @@ -80,13 +80,13 @@ export default class VirtualConsole implements Console { * @param [label='default'] Label. */ public countReset(label = 'default'): void { - delete this._count[label]; + delete this.#count[label]; - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.countReset, level: VirtualConsoleLogLevelEnum.warn, message: [`${label}: 0`], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -96,11 +96,11 @@ export default class VirtualConsole implements Console { * @param args Arguments. */ public debug(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.debug, level: VirtualConsoleLogLevelEnum.log, message: args, - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -110,11 +110,11 @@ export default class VirtualConsole implements Console { * @param data Data. */ public dir(data: object): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.dir, level: VirtualConsoleLogLevelEnum.log, message: [data], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -124,11 +124,11 @@ export default class VirtualConsole implements Console { * @param data Data. */ public dirxml(data: object): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.dirxml, level: VirtualConsoleLogLevelEnum.log, message: [data], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -138,11 +138,11 @@ export default class VirtualConsole implements Console { * @param args Arguments. */ public error(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.error, level: VirtualConsoleLogLevelEnum.error, message: args, - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -163,15 +163,15 @@ export default class VirtualConsole implements Console { * @param [label] Label. */ public group(label?: string): void { - this._groupID++; + this.#groupID++; const group = { - id: this._groupID, + id: this.#groupID, label: label || 'default', collapsed: false, - parent: this._groups[this._groups.length - 1] || null + parent: this.#groups[this.#groups.length - 1] || null }; - this._groups.push(group); - this._printer.print({ + this.#groups.push(group); + this.#printer.print({ type: VirtualConsoleLogTypeEnum.group, level: VirtualConsoleLogLevelEnum.log, message: [label || 'default'], @@ -185,15 +185,15 @@ export default class VirtualConsole implements Console { * @param [label] Label. */ public groupCollapsed(label?: string): void { - this._groupID++; + this.#groupID++; const group = { - id: this._groupID, + id: this.#groupID, label: label || 'default', collapsed: true, - parent: this._groups[this._groups.length - 1] || null + parent: this.#groups[this.#groups.length - 1] || null }; - this._groups.push(group); - this._printer.print({ + this.#groups.push(group); + this.#printer.print({ type: VirtualConsoleLogTypeEnum.groupCollapsed, level: VirtualConsoleLogLevelEnum.log, message: [label || 'default'], @@ -205,10 +205,10 @@ export default class VirtualConsole implements Console { * Exits the current inline group in the console. */ public groupEnd(): void { - if (this._groups.length === 0) { + if (this.#groups.length === 0) { return; } - this._groups.pop(); + this.#groups.pop(); } /** @@ -216,11 +216,11 @@ export default class VirtualConsole implements Console { * @param args */ public info(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.info, level: VirtualConsoleLogLevelEnum.info, message: args, - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -230,11 +230,11 @@ export default class VirtualConsole implements Console { * @param args Arguments. */ public log(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.log, level: VirtualConsoleLogLevelEnum.log, message: args, - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -262,11 +262,11 @@ export default class VirtualConsole implements Console { * @param data Data. */ public table(data: { [key: string]: number | string | boolean } | string[]): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.table, level: VirtualConsoleLogLevelEnum.log, message: [data], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -276,7 +276,7 @@ export default class VirtualConsole implements Console { * @param [label=default] Label. */ public time(label = 'default'): void { - this._time[label] = PerfHooks.performance.now(); + this.#time[label] = PerfHooks.performance.now(); } /** @@ -286,14 +286,14 @@ export default class VirtualConsole implements Console { * @param [label=default] Label. */ public timeEnd(label = 'default'): void { - const time = this._time[label]; + const time = this.#time[label]; if (time) { const duration = PerfHooks.performance.now() - time; - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.timeEnd, level: VirtualConsoleLogLevelEnum.info, message: [`${label}: ${duration}ms - timer ended`], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } } @@ -306,14 +306,14 @@ export default class VirtualConsole implements Console { * @param [args] Arguments. */ public timeLog(label = 'default', ...args: Array): void { - const time = this._time[label]; + const time = this.#time[label]; if (time) { const duration = PerfHooks.performance.now() - time; - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.timeLog, level: VirtualConsoleLogLevelEnum.info, message: [`${label}: ${duration}ms`, ...args], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } } @@ -333,11 +333,11 @@ export default class VirtualConsole implements Console { * @param args Arguments. */ public trace(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.trace, level: VirtualConsoleLogLevelEnum.log, message: [...args, new Error('stack').stack.replace('Error: stack', '')], - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } @@ -347,11 +347,11 @@ export default class VirtualConsole implements Console { * @param args Arguments. */ public warn(...args: Array): void { - this._printer.print({ + this.#printer.print({ type: VirtualConsoleLogTypeEnum.warn, level: VirtualConsoleLogLevelEnum.warn, message: args, - group: this._groups[this._groups.length - 1] || null + group: this.#groups[this.#groups.length - 1] || null }); } } diff --git a/packages/happy-dom/src/console/VirtualConsolePrinter.ts b/packages/happy-dom/src/console/VirtualConsolePrinter.ts index 934751a31..6b477b806 100644 --- a/packages/happy-dom/src/console/VirtualConsolePrinter.ts +++ b/packages/happy-dom/src/console/VirtualConsolePrinter.ts @@ -8,8 +8,8 @@ import IVirtualConsolePrinter from './types/IVirtualConsolePrinter.js'; * Virtual console printer. */ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { - private _logEntries: IVirtualConsoleLogEntry[] = []; - private _listeners: { + #logEntries: IVirtualConsoleLogEntry[] = []; + #listeners: { print: Array<(event: Event) => void>; clear: Array<(event: Event) => void>; } = { print: [], clear: [] }; @@ -20,7 +20,7 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * @param logEntry Log entry. */ public print(logEntry: IVirtualConsoleLogEntry): void { - this._logEntries.push(logEntry); + this.#logEntries.push(logEntry); this.dispatchEvent(new Event('print')); } @@ -28,7 +28,7 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * Clears the output. */ public clear(): void { - this._logEntries = []; + this.#logEntries = []; this.dispatchEvent(new Event('clear')); } @@ -39,10 +39,10 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * @param listener Listener. */ public addEventListener(eventType: 'print' | 'clear', listener: (event: Event) => void): void { - if (!this._listeners[eventType]) { + if (!this.#listeners[eventType]) { throw new Error(`Event type "${eventType}" is not supported.`); } - this._listeners[eventType].push(listener); + this.#listeners[eventType].push(listener); } /** @@ -52,12 +52,12 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * @param listener Listener. */ public removeEventListener(eventType: 'print' | 'clear', listener: (event: Event) => void): void { - if (!this._listeners[eventType]) { + if (!this.#listeners[eventType]) { throw new Error(`Event type "${eventType}" is not supported.`); } - const index = this._listeners[eventType].indexOf(listener); + const index = this.#listeners[eventType].indexOf(listener); if (index !== -1) { - this._listeners[eventType].splice(index, 1); + this.#listeners[eventType].splice(index, 1); } } @@ -67,10 +67,10 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * @param event Event. */ public dispatchEvent(event: Event): void { - if (!this._listeners[event.type]) { + if (!this.#listeners[event.type]) { throw new Error(`Event type "${event.type}" is not supported.`); } - for (const listener of this._listeners[event.type]) { + for (const listener of this.#listeners[event.type]) { listener(event); } } @@ -81,8 +81,8 @@ export default class VirtualConsolePrinter implements IVirtualConsolePrinter { * @returns Console log entries. */ public read(): IVirtualConsoleLogEntry[] { - const logEntries = this._logEntries; - this._logEntries = []; + const logEntries = this.#logEntries; + this.#logEntries = []; return logEntries; } diff --git a/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts b/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts index 409a155fe..cdb002892 100644 --- a/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts +++ b/packages/happy-dom/src/console/types/IVirtualConsolePrinter.ts @@ -54,5 +54,5 @@ export default interface IVirtualConsolePrinter { * @param [logLevel] Log level. * @returns Buffer as a string of concatenated log entries. */ - readAsString(logLevel: VirtualConsoleLogLevelEnum): string; + readAsString(logLevel?: VirtualConsoleLogLevelEnum): string; } diff --git a/packages/happy-dom/src/cookie/Cookie.ts b/packages/happy-dom/src/cookie/Cookie.ts deleted file mode 100644 index c06d5b23c..000000000 --- a/packages/happy-dom/src/cookie/Cookie.ts +++ /dev/null @@ -1,125 +0,0 @@ -import DOMException from '../exception/DOMException.js'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; -import CookieSameSiteEnum from './CookieSameSiteEnum.js'; -import URL from '../url/URL.js'; - -/** - * Cookie. - */ -export default class Cookie { - // Required - public key = ''; - public value: string | null = null; - public originURL: URL; - - // Optional - public domain = ''; - public path = ''; - public expires: Date | null = null; - public httpOnly = false; - public secure = false; - public sameSite: CookieSameSiteEnum = CookieSameSiteEnum.lax; - - /** - * Constructor. - * - * @param originURL Origin URL. - * @param cookie Cookie. - */ - constructor(originURL, cookie: string) { - const parts = cookie.split(';'); - const [key, value] = parts.shift().split('='); - - this.originURL = originURL; - this.key = key.trim(); - this.value = value !== undefined ? value : null; - - if (!this.key) { - throw new DOMException(`Invalid cookie: ${cookie}.`, DOMExceptionNameEnum.syntaxError); - } - - for (const part of parts) { - const keyAndValue = part.split('='); - const key = keyAndValue[0].trim().toLowerCase(); - const value = keyAndValue[1]; - - switch (key) { - case 'expires': - this.expires = new Date(value); - break; - case 'max-age': - this.expires = new Date(parseInt(value, 10) * 1000 + Date.now()); - break; - case 'domain': - this.domain = value; - break; - case 'path': - this.path = value.startsWith('/') ? value : `/${value}`; - break; - case 'httponly': - this.httpOnly = true; - break; - case 'secure': - this.secure = true; - break; - case 'samesite': - switch (value.toLowerCase()) { - case 'strict': - this.sameSite = CookieSameSiteEnum.strict; - break; - case 'lax': - this.sameSite = CookieSameSiteEnum.lax; - break; - case 'none': - this.sameSite = CookieSameSiteEnum.none; - } - break; - } - } - } - - /** - * Returns cookie string. - * - * @returns Cookie string. - */ - public toString(): string { - if (this.value !== null) { - return `${this.key}=${this.value}`; - } - - return this.key; - } - - /** - * Returns "true" if expired. - * - * @returns "true" if expired. - */ - public isExpired(): boolean { - // If the expries/maxage is set, then determine whether it is expired. - if (this.expires && this.expires.getTime() < Date.now()) { - return true; - } - // If the expries/maxage is not set, it's a session-level cookie that will expire when the browser is closed. - // (it's never expired in happy-dom) - return false; - } - - /** - * Validate cookie. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#cookie_prefixes - * @returns "true" if valid. - */ - public validate(): boolean { - const lowerKey = this.key.toLowerCase(); - if (lowerKey.startsWith('__secure-') && !this.secure) { - return false; - } - if (lowerKey.startsWith('__host-') && (!this.secure || this.path !== '/' || this.domain)) { - return false; - } - return true; - } -} diff --git a/packages/happy-dom/src/cookie/CookieContainer.ts b/packages/happy-dom/src/cookie/CookieContainer.ts new file mode 100644 index 000000000..3f880303c --- /dev/null +++ b/packages/happy-dom/src/cookie/CookieContainer.ts @@ -0,0 +1,72 @@ +import URL from '../url/URL.js'; +import ICookie from './types/ICookie.js'; +import ICookieContainer from './types/ICookieContainer.js'; +import CookieExpireUtility from './urilities/CookieExpireUtility.js'; +import CookieURLUtility from './urilities/CookieURLUtility.js'; + +/** + * Cookie Container. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie. + */ +export default class CookieContainer implements ICookieContainer { + #cookies: ICookie[] = []; + + /** + * Adds cookies. + * + * @param cookies Cookies. + */ + public addCookies(cookies: ICookie[]): void { + const indexMap: { [k: string]: number } = {}; + const getKey = (cookie: ICookie): string => + `${cookie.key}-${cookie.originURL.hostname}-${ + cookie.originURL.pathname + }-${typeof cookie.value}`; + + // Creates a map of cookie key, domain, path and value to index. + for (let i = 0, max = this.#cookies.length; i < max; i++) { + indexMap[getKey(this.#cookies[i])] = i; + } + + for (const cookie of cookies) { + if (cookie?.key) { + // Remove existing cookie with same name, domain and path. + const index = indexMap[getKey(cookie)]; + + if (index !== undefined) { + this.#cookies.splice(index, 1); + } + + if (!CookieExpireUtility.hasExpired(cookie)) { + indexMap[getKey(cookie)] = this.#cookies.length; + this.#cookies.push(cookie); + } + } + } + } + + /** + * Returns cookies. + * + * @param [url] URL. + * @param [httpOnly] "true" if only http cookies should be returned. + * @returns Cookies. + */ + public getCookies(url: URL | null = null, httpOnly = false): ICookie[] { + const cookies = []; + + for (const cookie of this.#cookies) { + if ( + !CookieExpireUtility.hasExpired(cookie) && + (!httpOnly || !cookie.httpOnly) && + (!url || CookieURLUtility.cookieMatchesURL(cookie, url || cookie.originURL)) + ) { + cookies.push(cookie); + } + } + + return cookies; + } +} diff --git a/packages/happy-dom/src/cookie/CookieJar.ts b/packages/happy-dom/src/cookie/CookieJar.ts deleted file mode 100644 index 389e93d41..000000000 --- a/packages/happy-dom/src/cookie/CookieJar.ts +++ /dev/null @@ -1,81 +0,0 @@ -import Cookie from './Cookie.js'; -import CookieSameSiteEnum from './CookieSameSiteEnum.js'; -import URL from '../url/URL.js'; - -/** - * CookieJar. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie. - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie. - */ -export default class CookieJar { - private cookies: Cookie[] = []; - - /** - * Adds cookie string. - * - * @param originURL Origin URL. - * @param cookieString Cookie string. - */ - public addCookieString(originURL: URL, cookieString: string): void { - if (!cookieString) { - return; - } - - const newCookie = new Cookie(originURL, cookieString); - - if (!newCookie.validate()) { - return; - } - - for (let i = 0, max = this.cookies.length; i < max; i++) { - if ( - this.cookies[i].key === newCookie.key && - this.cookies[i].originURL.hostname === newCookie.originURL.hostname && - // Cookies with or without values are treated differently in the browser. - // Therefore, the cookie should only be replaced if either both has a value or if both has no value. - // The cookie value is null if it has no value set. - // This is a bit unlogical, so it would be nice with a link to the spec here. - typeof this.cookies[i].value === typeof newCookie.value - ) { - this.cookies.splice(i, 1); - break; - } - } - - if (!newCookie.isExpired()) { - this.cookies.push(newCookie); - } - } - - /** - * Get cookie string. - * - * @param targetURL Target URL. - * @param fromDocument If true, the caller is a document. - * @returns Cookie string. - */ - public getCookieString(targetURL: URL, fromDocument: boolean): string { - let cookieString = ''; - - for (const cookie of this.cookies) { - if ( - (!fromDocument || !cookie.httpOnly) && - !cookie.isExpired() && - (!cookie.secure || targetURL.protocol === 'https:') && - (!cookie.domain || targetURL.hostname.endsWith(cookie.domain)) && - (!cookie.path || targetURL.pathname.startsWith(cookie.path)) && - // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value - ((cookie.sameSite === CookieSameSiteEnum.none && cookie.secure) || - cookie.originURL.hostname === targetURL.hostname) - ) { - if (cookieString) { - cookieString += '; '; - } - cookieString += cookie.toString(); - } - } - - return cookieString; - } -} diff --git a/packages/happy-dom/src/cookie/CookieSameSiteEnum.ts b/packages/happy-dom/src/cookie/enums/CookieSameSiteEnum.ts similarity index 100% rename from packages/happy-dom/src/cookie/CookieSameSiteEnum.ts rename to packages/happy-dom/src/cookie/enums/CookieSameSiteEnum.ts diff --git a/packages/happy-dom/src/cookie/types/ICookie.ts b/packages/happy-dom/src/cookie/types/ICookie.ts new file mode 100644 index 000000000..35bbea971 --- /dev/null +++ b/packages/happy-dom/src/cookie/types/ICookie.ts @@ -0,0 +1,16 @@ +import CookieSameSiteEnum from '../enums/CookieSameSiteEnum.js'; + +export default interface ICookie { + // Required + key: string; + value: string | null; + originURL: URL; + + // Optional + domain: string; + path: string; + expires: Date | null; + httpOnly: boolean; + secure: boolean; + sameSite: CookieSameSiteEnum; +} diff --git a/packages/happy-dom/src/cookie/types/ICookieContainer.ts b/packages/happy-dom/src/cookie/types/ICookieContainer.ts new file mode 100644 index 000000000..3fb79eefa --- /dev/null +++ b/packages/happy-dom/src/cookie/types/ICookieContainer.ts @@ -0,0 +1,26 @@ +import URL from '../../url/URL.js'; +import ICookie from '../types/ICookie.js'; + +/** + * Cookie Container. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie. + */ +export default interface ICookieContainer { + /** + * Adds cookies. + * + * @param cookies Cookies. + */ + addCookies(cookies: ICookie[]): void; + + /** + * Returns cookies. + * + * @param [url] URL. + * @param [httpOnly] "true" if only http cookies should be returned. + * @returns Cookies. + */ + getCookies(url: URL | null, httpOnly: boolean): ICookie[]; +} diff --git a/packages/happy-dom/src/cookie/urilities/CookieExpireUtility.ts b/packages/happy-dom/src/cookie/urilities/CookieExpireUtility.ts new file mode 100644 index 000000000..835d5f48d --- /dev/null +++ b/packages/happy-dom/src/cookie/urilities/CookieExpireUtility.ts @@ -0,0 +1,16 @@ +import ICookie from '../types/ICookie.js'; + +/** + * Cookie expire utility. + */ +export default class CookieExpireUtility { + /** + * Returns "true" if cookie has expired. + * + * @param cookie Cookie. + * @returns "true" if cookie has expired. + */ + public static hasExpired(cookie: ICookie): boolean { + return cookie.expires && cookie.expires.getTime() < Date.now(); + } +} diff --git a/packages/happy-dom/src/cookie/urilities/CookieStringUtility.ts b/packages/happy-dom/src/cookie/urilities/CookieStringUtility.ts new file mode 100644 index 000000000..f3370904f --- /dev/null +++ b/packages/happy-dom/src/cookie/urilities/CookieStringUtility.ts @@ -0,0 +1,116 @@ +import CookieSameSiteEnum from '../enums/CookieSameSiteEnum.js'; +import URL from '../../url/URL.js'; +import ICookie from '../types/ICookie.js'; + +/** + * Cookie string. + */ +export default class CookieStringUtility { + /** + * Returns cookie. + * + * @param originURL Origin URL. + * @param cookieString Cookie string. + * @returns Cookie. + */ + public static stringToCookie(originURL: URL, cookieString: string): ICookie | null { + const parts = cookieString.split(';'); + const [key, value] = parts.shift().split('='); + + const cookie: ICookie = { + // Required + key: key.trim(), + value: value ?? null, + originURL, + + // Optional + domain: '', + path: '', + expires: null, + httpOnly: false, + secure: false, + sameSite: CookieSameSiteEnum.lax + }; + + // Invalid if key is empty. + if (!cookie.key) { + return null; + } + + for (const part of parts) { + const keyAndValue = part.split('='); + const key = keyAndValue[0].trim().toLowerCase(); + const value = keyAndValue[1]; + + switch (key) { + case 'expires': + cookie.expires = new Date(value); + break; + case 'max-age': + cookie.expires = new Date(parseInt(value, 10) * 1000 + Date.now()); + break; + case 'domain': + cookie.domain = value; + break; + case 'path': + cookie.path = value.startsWith('/') ? value : `/${value}`; + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'secure': + cookie.secure = true; + break; + case 'samesite': + switch (value.toLowerCase()) { + case 'strict': + cookie.sameSite = CookieSameSiteEnum.strict; + break; + case 'lax': + cookie.sameSite = CookieSameSiteEnum.lax; + break; + case 'none': + cookie.sameSite = CookieSameSiteEnum.none; + } + break; + } + } + + const lowerKey = cookie.key.toLowerCase(); + + // Invalid if __secure- prefix is used and cookie is not secure. + if (lowerKey.startsWith('__secure-') && !cookie.secure) { + return null; + } + + // Invalid if __host- prefix is used and cookie is not secure, not on root path or has a domain. + if ( + lowerKey.startsWith('__host-') && + (!cookie.secure || cookie.path !== '/' || cookie.domain) + ) { + return null; + } + + return cookie; + } + + /** + * Returns cookie string with key and value. + * + * @param cookies Cookies. + * @returns Cookie string. + */ + public static cookiesToString(cookies: ICookie[]): string { + const cookieString: string[] = []; + + for (const cookie of cookies) { + if (cookie.value !== null) { + cookieString.push(`${cookie.key}=${cookie.value}`); + } else { + cookieString.push(cookie.key); + } + } + + return cookieString.join('; '); + } +} diff --git a/packages/happy-dom/src/cookie/urilities/CookieURLUtility.ts b/packages/happy-dom/src/cookie/urilities/CookieURLUtility.ts new file mode 100644 index 000000000..efc4dda8f --- /dev/null +++ b/packages/happy-dom/src/cookie/urilities/CookieURLUtility.ts @@ -0,0 +1,26 @@ +import CookieSameSiteEnum from '../enums/CookieSameSiteEnum.js'; +import URL from '../../url/URL.js'; +import ICookie from '../types/ICookie.js'; + +/** + * Cookie string. + */ +export default class CookieURLUtility { + /** + * Returns "true" if cookie matches URL. + * + * @param cookie Cookie. + * @param url URL. + * @returns "true" if cookie matches URL. + */ + public static cookieMatchesURL(cookie: ICookie, url: URL): boolean { + return ( + (!cookie.secure || url.protocol === 'https:') && + (!cookie.domain || url.hostname.endsWith(cookie.domain)) && + (!cookie.path || url.pathname.startsWith(cookie.path)) && + // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + ((cookie.sameSite === CookieSameSiteEnum.none && cookie.secure) || + cookie.originURL.hostname === url.hostname) + ); + } +} diff --git a/packages/happy-dom/src/css/CSSParser.ts b/packages/happy-dom/src/css/CSSParser.ts index a2b918d77..e08e8a00d 100644 --- a/packages/happy-dom/src/css/CSSParser.ts +++ b/packages/happy-dom/src/css/CSSParser.ts @@ -1,4 +1,5 @@ import CSSRule from './CSSRule.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import CSSStyleSheet from './CSSStyleSheet.js'; import CSSStyleRule from './rules/CSSStyleRule.js'; import CSSKeyframeRule from './rules/CSSKeyframeRule.js'; @@ -131,7 +132,7 @@ export default class CSSParser { case CSSRule.FONT_FACE_RULE: case CSSRule.KEYFRAME_RULE: case CSSRule.STYLE_RULE: - (parentRule)._cssText = cssText; + (parentRule)[PropertySymbol.cssText] = cssText; break; } } diff --git a/packages/happy-dom/src/css/CSSStyleSheet.ts b/packages/happy-dom/src/css/CSSStyleSheet.ts index ffce83c11..bb5d8b9ab 100644 --- a/packages/happy-dom/src/css/CSSStyleSheet.ts +++ b/packages/happy-dom/src/css/CSSStyleSheet.ts @@ -21,7 +21,7 @@ export default class CSSStyleSheet { public title: string; public alternate: boolean; public disabled: boolean; - private _currentText: string = null; + #currentText: string = null; /** * Constructor. @@ -112,8 +112,8 @@ export default class CSSStyleSheet { * @param text CSS text. */ public replaceSync(text: string): void { - if (this._currentText !== text) { - this._currentText = text; + if (this.#currentText !== text) { + this.#currentText = text; (this.cssRules) = CSSParser.parseFromString(this, text); } } diff --git a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts index 227b0ac8e..073116e70 100644 --- a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts +++ b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts @@ -1,4 +1,5 @@ import IElement from '../../nodes/element/IElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IAttr from '../../nodes/attr/IAttr.js'; import CSSRule from '../CSSRule.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; @@ -12,10 +13,10 @@ import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; */ export default abstract class AbstractCSSStyleDeclaration { public readonly parentRule: CSSRule = null; - protected _style: CSSStyleDeclarationPropertyManager = null; - protected _ownerElement: IElement; - protected _computed: boolean; - protected _elementStyle: CSSStyleDeclarationElementStyle = null; + #style: CSSStyleDeclarationPropertyManager = null; + #ownerElement: IElement; + #computed: boolean; + #elementStyle: CSSStyleDeclarationElementStyle = null; /** * Constructor. @@ -24,11 +25,11 @@ export default abstract class AbstractCSSStyleDeclaration { * @param [computed] Computed. */ constructor(ownerElement: IElement = null, computed = false) { - this._style = !ownerElement ? new CSSStyleDeclarationPropertyManager() : null; - this._ownerElement = ownerElement; - this._computed = ownerElement ? computed : false; - this._elementStyle = ownerElement - ? new CSSStyleDeclarationElementStyle(ownerElement, this._computed) + this.#style = !ownerElement ? new CSSStyleDeclarationPropertyManager() : null; + this.#ownerElement = ownerElement; + this.#computed = ownerElement ? computed : false; + this.#elementStyle = ownerElement + ? new CSSStyleDeclarationElementStyle(ownerElement, this.#computed) : null; } @@ -38,12 +39,12 @@ export default abstract class AbstractCSSStyleDeclaration { * @returns Length. */ public get length(): number { - if (this._ownerElement) { - const style = this._elementStyle.getElementStyle(); + if (this.#ownerElement) { + const style = this.#elementStyle.getElementStyle(); return style.size(); } - return this._style.size(); + return this.#style.size(); } /** @@ -52,15 +53,15 @@ export default abstract class AbstractCSSStyleDeclaration { * @returns CSS text. */ public get cssText(): string { - if (this._ownerElement) { - if (this._computed) { + if (this.#ownerElement) { + if (this.#computed) { return ''; } - return this._elementStyle.getElementStyle().toString(); + return this.#elementStyle.getElementStyle().toString(); } - return this._style.toString(); + return this.#style.toString(); } /** @@ -69,32 +70,32 @@ export default abstract class AbstractCSSStyleDeclaration { * @param cssText CSS text. */ public set cssText(cssText: string) { - if (this._computed) { + if (this.#computed) { throw new DOMException( `Failed to execute 'cssText' on 'CSSStyleDeclaration': These styles are computed, and the properties are therefore read-only.`, DOMExceptionNameEnum.domException ); } - if (this._ownerElement) { + if (this.#ownerElement) { const style = new CSSStyleDeclarationPropertyManager({ cssText }); - let styleAttribute = this._ownerElement.attributes['style']; + let styleAttribute = this.#ownerElement[PropertySymbol.attributes]['style']; if (!styleAttribute) { - styleAttribute = this._ownerElement.ownerDocument.createAttribute('style'); - // We use "_setNamedItemWithoutConsequences" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. - (this._ownerElement.attributes)._setNamedItemWithoutConsequences( - styleAttribute - ); + styleAttribute = this.#ownerElement[PropertySymbol.ownerDocument].createAttribute('style'); + // We use "[PropertySymbol.setNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. + (this.#ownerElement[PropertySymbol.attributes])[ + PropertySymbol.setNamedItemWithoutConsequences + ](styleAttribute); } - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; + if (this.#ownerElement[PropertySymbol.isConnected]) { + this.#ownerElement[PropertySymbol.ownerDocument][PropertySymbol.cacheID]++; } - styleAttribute.value = style.toString(); + styleAttribute[PropertySymbol.value] = style.toString(); } else { - this._style = new CSSStyleDeclarationPropertyManager({ cssText }); + this.#style = new CSSStyleDeclarationPropertyManager({ cssText }); } } @@ -105,10 +106,10 @@ export default abstract class AbstractCSSStyleDeclaration { * @returns Item. */ public item(index: number): string { - if (this._ownerElement) { - return this._elementStyle.getElementStyle().item(index); + if (this.#ownerElement) { + return this.#elementStyle.getElementStyle().item(index); } - return this._style.item(index); + return this.#style.item(index); } /** @@ -119,7 +120,7 @@ export default abstract class AbstractCSSStyleDeclaration { * @param [priority] Can be "important", or an empty string. */ public setProperty(name: string, value: string, priority?: 'important' | '' | undefined): void { - if (this._computed) { + if (this.#computed) { throw new DOMException( `Failed to execute 'setProperty' on 'CSSStyleDeclaration': These styles are computed, and therefore the '${name}' property is read-only.`, DOMExceptionNameEnum.domException @@ -134,28 +135,28 @@ export default abstract class AbstractCSSStyleDeclaration { if (!stringValue) { this.removeProperty(name); - } else if (this._ownerElement) { - let styleAttribute = this._ownerElement.attributes['style']; + } else if (this.#ownerElement) { + let styleAttribute = this.#ownerElement[PropertySymbol.attributes]['style']; if (!styleAttribute) { - styleAttribute = this._ownerElement.ownerDocument.createAttribute('style'); + styleAttribute = this.#ownerElement[PropertySymbol.ownerDocument].createAttribute('style'); - // We use "_setNamedItemWithoutConsequences" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. - (this._ownerElement.attributes)._setNamedItemWithoutConsequences( - styleAttribute - ); + // We use "[PropertySymbol.setNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. + (this.#ownerElement[PropertySymbol.attributes])[ + PropertySymbol.setNamedItemWithoutConsequences + ](styleAttribute); } - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; + if (this.#ownerElement[PropertySymbol.isConnected]) { + this.#ownerElement[PropertySymbol.ownerDocument][PropertySymbol.cacheID]++; } - const style = this._elementStyle.getElementStyle(); + const style = this.#elementStyle.getElementStyle(); style.set(name, stringValue, !!priority); - styleAttribute.value = style.toString(); + styleAttribute[PropertySymbol.value] = style.toString(); } else { - this._style.set(name, stringValue, !!priority); + this.#style.set(name, stringValue, !!priority); } } @@ -167,30 +168,33 @@ export default abstract class AbstractCSSStyleDeclaration { * @param [priority] Can be "important", or an empty string. */ public removeProperty(name: string): void { - if (this._computed) { + if (this.#computed) { throw new DOMException( `Failed to execute 'removeProperty' on 'CSSStyleDeclaration': These styles are computed, and therefore the '${name}' property is read-only.`, DOMExceptionNameEnum.domException ); } - if (this._ownerElement) { - const style = this._elementStyle.getElementStyle(); + if (this.#ownerElement) { + const style = this.#elementStyle.getElementStyle(); style.remove(name); const newCSSText = style.toString(); - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; + if (this.#ownerElement[PropertySymbol.isConnected]) { + this.#ownerElement[PropertySymbol.ownerDocument][PropertySymbol.cacheID]++; } if (newCSSText) { - (this._ownerElement.attributes['style']).value = newCSSText; + (this.#ownerElement[PropertySymbol.attributes]['style'])[PropertySymbol.value] = + newCSSText; } else { - // We use "_removeNamedItemWithoutConsequences" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. - (this._ownerElement.attributes)._removeNamedItemWithoutConsequences('style'); + // We use "[PropertySymbol.removeNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. + (this.#ownerElement[PropertySymbol.attributes])[ + PropertySymbol.removeNamedItemWithoutConsequences + ]('style'); } } else { - this._style.remove(name); + this.#style.remove(name); } } @@ -201,11 +205,11 @@ export default abstract class AbstractCSSStyleDeclaration { * @returns Property value. */ public getPropertyValue(name: string): string { - if (this._ownerElement) { - const style = this._elementStyle.getElementStyle(); + if (this.#ownerElement) { + const style = this.#elementStyle.getElementStyle(); return style.get(name)?.value || ''; } - return this._style.get(name)?.value || ''; + return this.#style.get(name)?.value || ''; } /** @@ -215,10 +219,10 @@ export default abstract class AbstractCSSStyleDeclaration { * @returns "important" if set to be important. */ public getPropertyPriority(name: string): string { - if (this._ownerElement) { - const style = this._elementStyle.getElementStyle(); + if (this.#ownerElement) { + const style = this.#elementStyle.getElementStyle(); return style.get(name)?.important ? 'important' : ''; } - return this._style.get(name)?.important ? 'important' : ''; + return this.#style.get(name)?.important ? 'important' : ''; } } diff --git a/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts b/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts index 368c64eee..0ab9754f8 100644 --- a/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts +++ b/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts @@ -1,4 +1,5 @@ import IShadowRoot from '../../../nodes/shadow-root/IShadowRoot.js'; +import * as PropertySymbol from '../../../PropertySymbol.js'; import IElement from '../../../nodes/element/IElement.js'; import IDocument from '../../../nodes/document/IDocument.js'; import IHTMLStyleElement from '../../../nodes/html-style-element/IHTMLStyleElement.js'; @@ -16,8 +17,9 @@ import CSSStyleDeclarationCSSParser from '../css-parser/CSSStyleDeclarationCSSPa import QuerySelector from '../../../query-selector/QuerySelector.js'; import CSSMeasurementConverter from '../measurement-converter/CSSMeasurementConverter.js'; import MediaQueryList from '../../../match-media/MediaQueryList.js'; +import WindowBrowserSettingsReader from '../../../window/WindowBrowserSettingsReader.js'; -const CSS_VARIABLE_REGEXP = /var\( *(--[^) ]+)\)/g; +const CSS_VARIABLE_REGEXP = /var\( *(--[^), ]+)\)|var\( *(--[^), ]+), *([^), ]+)\)/g; const CSS_MEASUREMENT_REGEXP = /[0-9.]+(px|rem|em|vw|vh|%|vmin|vmax|cm|mm|in|pt|pc|Q)/g; type IStyleAndElement = { @@ -63,7 +65,7 @@ export default class CSSStyleDeclarationElementStyle { return this.getComputedElementStyle(); } - const cssText = this.element.attributes['style']?.value; + const cssText = this.element[PropertySymbol.attributes]['style']?.[PropertySymbol.value]; if (cssText) { if (this.cache.propertyManager && this.cache.cssText === cssText) { @@ -92,24 +94,25 @@ export default class CSSStyleDeclarationElementStyle { }; let shadowRootElements: Array = []; - if (!this.element.isConnected) { + if (!this.element[PropertySymbol.isConnected]) { return new CSSStyleDeclarationPropertyManager(); } if ( this.cache.propertyManager && - this.cache.documentCacheID === this.element.ownerDocument['_cacheID'] + this.cache.documentCacheID === + this.element[PropertySymbol.ownerDocument][PropertySymbol.cacheID] ) { return this.cache.propertyManager; } - this.cache.documentCacheID = this.element.ownerDocument['_cacheID']; + this.cache.documentCacheID = this.element[PropertySymbol.ownerDocument][PropertySymbol.cacheID]; // Walks through all parent elements and stores them in an array with element and matching CSS text. while (styleAndElement.element) { - if (styleAndElement.element.nodeType === NodeTypeEnum.elementNode) { + if (styleAndElement.element[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { const rootNode = styleAndElement.element.getRootNode(); - if (rootNode.nodeType === NodeTypeEnum.documentNode) { + if (rootNode[PropertySymbol.nodeType] === NodeTypeEnum.documentNode) { documentElements.unshift(styleAndElement); } else { shadowRootElements.unshift(styleAndElement); @@ -117,9 +120,11 @@ export default class CSSStyleDeclarationElementStyle { parentElements.unshift(styleAndElement); } - if (styleAndElement.element === this.element.ownerDocument) { + if (styleAndElement.element === this.element[PropertySymbol.ownerDocument]) { const styleSheets = >( - this.element.ownerDocument.querySelectorAll('style,link[rel="stylesheet"]') + this.element[PropertySymbol.ownerDocument].querySelectorAll( + 'style,link[rel="stylesheet"]' + ) ); for (const styleSheet of styleSheets) { @@ -132,17 +137,25 @@ export default class CSSStyleDeclarationElementStyle { } } + for (const styleSheet of this.element[PropertySymbol.ownerDocument].adoptedStyleSheets) { + this.parseCSSRules({ + elements: documentElements, + cssRules: styleSheet.cssRules + }); + } + styleAndElement = { element: null, cssTexts: [] }; } else if ( - styleAndElement.element.nodeType === NodeTypeEnum.documentFragmentNode && + styleAndElement.element[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode && (styleAndElement.element).host ) { + const shadowRoot = styleAndElement.element; const styleSheets = >( - (styleAndElement.element).querySelectorAll('style,link[rel="stylesheet"]') + shadowRoot.querySelectorAll('style,link[rel="stylesheet"]') ); styleAndElement = { - element: (styleAndElement.element).host, + element: shadowRoot.host, cssTexts: [] }; @@ -156,9 +169,21 @@ export default class CSSStyleDeclarationElementStyle { }); } } + + for (const styleSheet of shadowRoot.adoptedStyleSheets) { + this.parseCSSRules({ + elements: shadowRootElements, + cssRules: styleSheet.cssRules, + hostElement: styleAndElement + }); + } + shadowRootElements = []; } else { - styleAndElement = { element: styleAndElement.element.parentNode, cssTexts: [] }; + styleAndElement = { + element: styleAndElement.element[PropertySymbol.parentNode], + cssTexts: [] + }; } } @@ -173,36 +198,49 @@ export default class CSSStyleDeclarationElementStyle { parentElement.cssTexts.sort((a, b) => a.priorityWeight - b.priorityWeight); let elementCSSText = ''; - if (CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName]) { + if ( + CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ] + ) { if ( - typeof CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName] === - 'string' + typeof CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ] === 'string' ) { elementCSSText += - CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName]; + CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ]; } else { for (const key of Object.keys( - CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName] + CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ] )) { if (key === 'default' || !!parentElement.element[key]) { elementCSSText += - CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName][ - key - ]; + CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ][key]; } } } elementCSSText += - CSSStyleDeclarationElementDefaultCSS[(parentElement.element).tagName]; + CSSStyleDeclarationElementDefaultCSS[ + (parentElement.element)[PropertySymbol.tagName] + ]; } for (const cssText of parentElement.cssTexts) { elementCSSText += cssText.cssText; } - const elementStyleAttribute = (parentElement.element).attributes['style']; + const elementStyleAttribute = (parentElement.element)[PropertySymbol.attributes][ + 'style' + ]; if (elementStyleAttribute) { - elementCSSText += elementStyleAttribute.value; + elementCSSText += elementStyleAttribute[PropertySymbol.value]; } CSSStyleDeclarationCSSParser.parse(elementCSSText, (name, value, important) => { @@ -227,7 +265,7 @@ export default class CSSStyleDeclarationElementStyle { parentFontSize, parentSize: parentFontSize }); - if ((parentElement.element).tagName === 'HTML') { + if ((parentElement.element)[PropertySymbol.tagName] === 'HTML') { rootFontSize = parsedValue; } else if (parentElement !== targetElement) { parentFontSize = parsedValue; @@ -275,7 +313,7 @@ export default class CSSStyleDeclarationElementStyle { return; } - const ownerWindow = this.element.ownerDocument.defaultView; + const ownerWindow = this.element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; for (const rule of options.cssRules) { if (rule.type === CSSRuleTypeEnum.styleRule) { @@ -284,7 +322,7 @@ export default class CSSStyleDeclarationElementStyle { if (selectorText.startsWith(':host')) { if (options.hostElement) { options.hostElement.cssTexts.push({ - cssText: (rule)._cssText, + cssText: (rule)[PropertySymbol.cssText], priorityWeight: 0 }); } @@ -293,7 +331,7 @@ export default class CSSStyleDeclarationElementStyle { const matchResult = QuerySelector.match(element.element, selectorText); if (matchResult) { element.cssTexts.push({ - cssText: (rule)._cssText, + cssText: (rule)[PropertySymbol.cssText], priorityWeight: matchResult.priorityWeight }); } @@ -306,7 +344,7 @@ export default class CSSStyleDeclarationElementStyle { new MediaQueryList({ ownerWindow, media: (rule).conditionText, - rootFontSize: this.element.tagName === 'HTML' ? 16 : null + rootFontSize: this.element[PropertySymbol.tagName] === 'HTML' ? 16 : null }).matches ) { this.parseCSSRules({ @@ -331,7 +369,12 @@ export default class CSSStyleDeclarationElementStyle { let match; while ((match = regexp.exec(value)) !== null) { - newValue = newValue.replace(match[0], cssVariables[match[1]] || ''); + // Fallback value - E.g. var(--my-var, #FFFFFF) + if (match[2] !== undefined) { + newValue = newValue.replace(match[0], cssVariables[match[2]] || match[3]); + } else { + newValue = newValue.replace(match[0], cssVariables[match[1]] || ''); + } } return newValue; @@ -353,7 +396,11 @@ export default class CSSStyleDeclarationElementStyle { parentFontSize: string | number; parentSize: string | number | null; }): string { - if (this.element.ownerDocument.defaultView.happyDOM.settings.disableComputedStyleRendering) { + if ( + WindowBrowserSettingsReader.getSettings( + this.element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + ).disableComputedStyleRendering + ) { return options.value; } @@ -364,7 +411,7 @@ export default class CSSStyleDeclarationElementStyle { while ((match = regexp.exec(options.value)) !== null) { if (match[1] !== 'px') { const valueInPixels = CSSMeasurementConverter.toPixels({ - ownerWindow: this.element.ownerDocument.defaultView, + ownerWindow: this.element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], value: match[0], rootFontSize: options.rootFontSize, parentFontSize: options.parentFontSize, diff --git a/packages/happy-dom/src/css/declaration/measurement-converter/CSSMeasurementConverter.ts b/packages/happy-dom/src/css/declaration/measurement-converter/CSSMeasurementConverter.ts index bc5b8bca6..30566ac65 100644 --- a/packages/happy-dom/src/css/declaration/measurement-converter/CSSMeasurementConverter.ts +++ b/packages/happy-dom/src/css/declaration/measurement-converter/CSSMeasurementConverter.ts @@ -1,4 +1,4 @@ -import IWindow from '../../../window/IWindow.js'; +import IBrowserWindow from '../../../window/IBrowserWindow.js'; /** * CSS Measurement Converter. @@ -16,7 +16,7 @@ export default class CSSMeasurementConverter { * @returns Measurement in pixels. */ public static toPixels(options: { - ownerWindow: IWindow; + ownerWindow: IBrowserWindow; value: string; rootFontSize: string | number; parentFontSize: string | number; diff --git a/packages/happy-dom/src/css/rules/CSSFontFaceRule.ts b/packages/happy-dom/src/css/rules/CSSFontFaceRule.ts index f9bac5d91..3ed7013b5 100644 --- a/packages/happy-dom/src/css/rules/CSSFontFaceRule.ts +++ b/packages/happy-dom/src/css/rules/CSSFontFaceRule.ts @@ -1,4 +1,5 @@ import CSSRule from '../CSSRule.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import CSSStyleDeclaration from '../declaration/CSSStyleDeclaration.js'; /** @@ -6,8 +7,8 @@ import CSSStyleDeclaration from '../declaration/CSSStyleDeclaration.js'; */ export default class CSSFontFaceRule extends CSSRule { public readonly type = CSSRule.FONT_FACE_RULE; - public _cssText = ''; - private _style: CSSStyleDeclaration = null; + public [PropertySymbol.cssText] = ''; + #style: CSSStyleDeclaration = null; /** * Returns style. @@ -15,11 +16,11 @@ export default class CSSFontFaceRule extends CSSRule { * @returns Style. */ public get style(): CSSStyleDeclaration { - if (!this._style) { - this._style = new CSSStyleDeclaration(); - (this._style.parentRule) = this; - this._style.cssText = this._cssText; + if (!this.#style) { + this.#style = new CSSStyleDeclaration(); + (this.#style.parentRule) = this; + this.#style.cssText = this[PropertySymbol.cssText]; } - return this._style; + return this.#style; } } diff --git a/packages/happy-dom/src/css/rules/CSSKeyframeRule.ts b/packages/happy-dom/src/css/rules/CSSKeyframeRule.ts index dcc7dd80b..2b3a7b53f 100644 --- a/packages/happy-dom/src/css/rules/CSSKeyframeRule.ts +++ b/packages/happy-dom/src/css/rules/CSSKeyframeRule.ts @@ -1,4 +1,5 @@ import CSSRule from '../CSSRule.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import CSSStyleDeclaration from '../declaration/CSSStyleDeclaration.js'; /** @@ -7,8 +8,8 @@ import CSSStyleDeclaration from '../declaration/CSSStyleDeclaration.js'; export default class CSSKeyframeRule extends CSSRule { public readonly type = CSSRule.KEYFRAME_RULE; public readonly keyText: string; - public _cssText = ''; - private _style: CSSStyleDeclaration = null; + public [PropertySymbol.cssText] = ''; + #style: CSSStyleDeclaration = null; /** * Returns style. @@ -16,12 +17,12 @@ export default class CSSKeyframeRule extends CSSRule { * @returns Style. */ public get style(): CSSStyleDeclaration { - if (!this._style) { - this._style = new CSSStyleDeclaration(); - (this._style.parentRule) = this; - this._style.cssText = this._cssText; + if (!this.#style) { + this.#style = new CSSStyleDeclaration(); + (this.#style.parentRule) = this; + this.#style.cssText = this[PropertySymbol.cssText]; } - return this._style; + return this.#style; } /** diff --git a/packages/happy-dom/src/css/rules/CSSStyleRule.ts b/packages/happy-dom/src/css/rules/CSSStyleRule.ts index 9a7ef6019..94d38da59 100644 --- a/packages/happy-dom/src/css/rules/CSSStyleRule.ts +++ b/packages/happy-dom/src/css/rules/CSSStyleRule.ts @@ -1,4 +1,5 @@ import CSSRule from '../CSSRule.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import CSSStyleDeclaration from '../declaration/CSSStyleDeclaration.js'; /** @@ -8,8 +9,8 @@ export default class CSSStyleRule extends CSSRule { public readonly type = CSSRule.STYLE_RULE; public readonly selectorText = ''; public readonly styleMap = new Map(); - public _cssText = ''; - private _style: CSSStyleDeclaration = null; + public [PropertySymbol.cssText] = ''; + #style: CSSStyleDeclaration = null; /** * Returns style. @@ -17,12 +18,12 @@ export default class CSSStyleRule extends CSSRule { * @returns Style. */ public get style(): CSSStyleDeclaration { - if (!this._style) { - this._style = new CSSStyleDeclaration(); - (this._style.parentRule) = this; - this._style.cssText = this._cssText; + if (!this.#style) { + this.#style = new CSSStyleDeclaration(); + (this.#style.parentRule) = this; + this.#style.cssText = this[PropertySymbol.cssText]; } - return this._style; + return this.#style; } /** diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index bf1251732..2e6ee353b 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -1,4 +1,5 @@ import DOMException from '../exception/DOMException.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import HTMLElement from '../nodes/html-element/HTMLElement.js'; import Node from '../nodes/node/Node.js'; @@ -6,8 +7,10 @@ import Node from '../nodes/node/Node.js'; * Custom elements registry. */ export default class CustomElementRegistry { - public _registry: { [k: string]: { elementClass: typeof HTMLElement; extends: string } } = {}; - public _callbacks: { [k: string]: (() => void)[] } = {}; + public [PropertySymbol.registry]: { + [k: string]: { elementClass: typeof HTMLElement; extends: string }; + } = {}; + public [PropertySymbol.callbacks]: { [k: string]: (() => void)[] } = {}; /** * Defines a custom element class. @@ -32,19 +35,19 @@ export default class CustomElementRegistry { ); } - this._registry[upperTagName] = { + this[PropertySymbol.registry][upperTagName] = { elementClass, extends: options && options.extends ? options.extends.toLowerCase() : null }; // ObservedAttributes should only be called once by CustomElementRegistry (see #117) if (elementClass.prototype.attributeChangedCallback) { - elementClass._observedAttributes = elementClass.observedAttributes; + elementClass[PropertySymbol.observedAttributes] = elementClass.observedAttributes; } - if (this._callbacks[upperTagName]) { - const callbacks = this._callbacks[upperTagName]; - delete this._callbacks[upperTagName]; + if (this[PropertySymbol.callbacks][upperTagName]) { + const callbacks = this[PropertySymbol.callbacks][upperTagName]; + delete this[PropertySymbol.callbacks][upperTagName]; for (const callback of callbacks) { callback(); } @@ -59,7 +62,9 @@ export default class CustomElementRegistry { */ public get(tagName: string): typeof HTMLElement { const upperTagName = tagName.toUpperCase(); - return this._registry[upperTagName] ? this._registry[upperTagName].elementClass : undefined; + return this[PropertySymbol.registry][upperTagName] + ? this[PropertySymbol.registry][upperTagName].elementClass + : undefined; } /** @@ -85,8 +90,9 @@ export default class CustomElementRegistry { return Promise.resolve(); } return new Promise((resolve) => { - this._callbacks[upperTagName] = this._callbacks[upperTagName] || []; - this._callbacks[upperTagName].push(resolve); + this[PropertySymbol.callbacks][upperTagName] = + this[PropertySymbol.callbacks][upperTagName] || []; + this[PropertySymbol.callbacks][upperTagName].push(resolve); }); } } diff --git a/packages/happy-dom/src/dom-implementation/DOMImplementation.ts b/packages/happy-dom/src/dom-implementation/DOMImplementation.ts index c15cdebea..c7d59e650 100644 --- a/packages/happy-dom/src/dom-implementation/DOMImplementation.ts +++ b/packages/happy-dom/src/dom-implementation/DOMImplementation.ts @@ -1,19 +1,21 @@ import DocumentType from '../nodes/document-type/DocumentType.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IDocument from '../nodes/document/IDocument.js'; +import NodeFactory from '../nodes/NodeFactory.js'; /** * The DOMImplementation interface represents an object providing methods which are not dependent on any particular document. Such an object is returned by the. */ export default class DOMImplementation { - protected _ownerDocument: IDocument = null; + #document: IDocument; /** * Constructor. * - * @param ownerDocument + * @param window Window. */ - constructor(ownerDocument: IDocument) { - this._ownerDocument = ownerDocument; + constructor(window: IDocument) { + this.#document = window; } /** @@ -22,18 +24,14 @@ export default class DOMImplementation { * TODO: Not fully implemented. */ public createDocument(): IDocument { - const documentClass = this._ownerDocument.constructor; - // @ts-ignore - documentClass._defaultView = this._ownerDocument.defaultView; - // @ts-ignore - return new documentClass(); + return new this.#document[PropertySymbol.ownerWindow].HTMLDocument(); } /** * Creates and returns an HTML Document. */ public createHTMLDocument(): IDocument { - return this.createDocument(); + return new this.#document[PropertySymbol.ownerWindow].HTMLDocument(); } /** @@ -48,11 +46,13 @@ export default class DOMImplementation { publicId: string, systemId: string ): DocumentType { - DocumentType._ownerDocument = this._ownerDocument; - const documentType = new DocumentType(); - documentType.name = qualifiedName; - documentType.publicId = publicId; - documentType.systemId = systemId; + const documentType = NodeFactory.createNode( + this.#document, + this.#document[PropertySymbol.ownerWindow].DocumentType + ); + documentType[PropertySymbol.name] = qualifiedName; + documentType[PropertySymbol.publicId] = publicId; + documentType[PropertySymbol.systemId] = systemId; return documentType; } } diff --git a/packages/happy-dom/src/dom-parser/DOMParser.ts b/packages/happy-dom/src/dom-parser/DOMParser.ts index a85c1d407..ebb25baa0 100644 --- a/packages/happy-dom/src/dom-parser/DOMParser.ts +++ b/packages/happy-dom/src/dom-parser/DOMParser.ts @@ -1,13 +1,10 @@ import IDocument from '../nodes/document/IDocument.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import XMLParser from '../xml-parser/XMLParser.js'; -import Node from '../nodes/node/Node.js'; import DOMException from '../exception/DOMException.js'; -import HTMLDocument from '../nodes/html-document/HTMLDocument.js'; -import XMLDocument from '../nodes/xml-document/XMLDocument.js'; -import SVGDocument from '../nodes/svg-document/SVGDocument.js'; -import IWindow from '../window/IWindow.js'; -import Document from '../nodes/document/Document.js'; import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; /** * DOM parser. @@ -16,15 +13,15 @@ import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; * https://developer.mozilla.org/en-US/docs/Web/API/DOMParser. */ export default class DOMParser { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; - public readonly _ownerDocument: IDocument = null; + readonly #window: IBrowserWindow; /** * Constructor. + * + * @param window Window. */ - constructor() { - this._ownerDocument = (this.constructor)._ownerDocument; + constructor(window: IBrowserWindow) { + this.#window = window; } /** @@ -39,21 +36,19 @@ export default class DOMParser { throw new DOMException('Second parameter "mimeType" is mandatory.'); } - const ownerDocument = this._ownerDocument; - const newDocument = this._createDocument(mimeType); + const newDocument = this.#createDocument(mimeType); - (newDocument.defaultView) = ownerDocument.defaultView; - newDocument._childNodes.length = 0; - newDocument._children.length = 0; + newDocument[PropertySymbol.childNodes].length = 0; + newDocument[PropertySymbol.children].length = 0; const root = XMLParser.parse(newDocument, string, { evaluateScripts: true }); let documentElement = null; let documentTypeNode = null; - for (const node of root._childNodes) { + for (const node of root[PropertySymbol.childNodes]) { if (node['tagName'] === 'HTML') { documentElement = node; - } else if (node.nodeType === Node.DOCUMENT_TYPE_NODE) { + } else if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { documentTypeNode = node; } @@ -69,7 +64,7 @@ export default class DOMParser { newDocument.appendChild(documentElement); const body = newDocument.body; if (body) { - for (const child of root._childNodes.slice()) { + for (const child of root[PropertySymbol.childNodes].slice()) { body.appendChild(child); } } @@ -77,7 +72,7 @@ export default class DOMParser { switch (mimeType) { case 'image/svg+xml': { - for (const node of root._childNodes.slice()) { + for (const node of root[PropertySymbol.childNodes].slice()) { newDocument.appendChild(node); } } @@ -93,7 +88,7 @@ export default class DOMParser { documentElement.appendChild(bodyElement); newDocument.appendChild(documentElement); - for (const node of root._childNodes.slice()) { + for (const node of root[PropertySymbol.childNodes].slice()) { bodyElement.appendChild(node); } } @@ -109,19 +104,16 @@ export default class DOMParser { * @param mimeType Mime type. * @returns IDocument. */ - private _createDocument(mimeType: string): IDocument { + #createDocument(mimeType: string): IDocument { switch (mimeType) { case 'text/html': - HTMLDocument._defaultView = this._ownerDocument.defaultView; - return new HTMLDocument(); + return new this.#window.HTMLDocument(); case 'image/svg+xml': - SVGDocument._defaultView = this._ownerDocument.defaultView; - return new SVGDocument(); + return new this.#window.SVGDocument(); case 'text/xml': case 'application/xml': case 'application/xhtml+xml': - XMLDocument._defaultView = this._ownerDocument.defaultView; - return new XMLDocument(); + return new this.#window.XMLDocument(); default: throw new DOMException(`Unknown mime type "${mimeType}".`); } diff --git a/packages/happy-dom/src/dom-token-list/DOMTokenList.ts b/packages/happy-dom/src/dom-token-list/DOMTokenList.ts index 52f9cd4ef..a36a24102 100644 --- a/packages/happy-dom/src/dom-token-list/DOMTokenList.ts +++ b/packages/happy-dom/src/dom-token-list/DOMTokenList.ts @@ -1,6 +1,9 @@ import Element from '../nodes/element/Element.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IDOMTokenList from './IDOMTokenList.js'; +const ATTRIBUTE_SPLIT_REGEXP = /[\t\f\n\r ]+/; + /** * DOM Token List. * @@ -8,9 +11,9 @@ import IDOMTokenList from './IDOMTokenList.js'; * https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList. */ export default class DOMTokenList implements IDOMTokenList { - public readonly length = 0; - private _ownerElement: Element; - private _attributeName: string; + #length = 0; + #ownerElement: Element; + #attributeName: string; /** * Constructor. @@ -19,9 +22,18 @@ export default class DOMTokenList implements IDOMTokenList { * @param attributeName Attribute name. */ constructor(ownerElement: Element, attributeName) { - this._ownerElement = ownerElement; - this._attributeName = attributeName; - this._updateIndices(); + this.#ownerElement = ownerElement; + this.#attributeName = attributeName; + this[PropertySymbol.updateIndices](); + } + + /** + * Returns length. + * + * @returns Length. + */ + public get length(): number { + return this.#length; } /** @@ -30,14 +42,14 @@ export default class DOMTokenList implements IDOMTokenList { * @param value Value. */ public set value(value: string) { - this._ownerElement.setAttribute(this._attributeName, value); + this.#ownerElement.setAttribute(this.#attributeName, value); } /** * Get value. */ public get value(): string { - return this._ownerElement.getAttribute(this._attributeName); + return this.#ownerElement.getAttribute(this.#attributeName); } /** @@ -57,14 +69,13 @@ export default class DOMTokenList implements IDOMTokenList { * @param newToken NewToken. */ public replace(token: string, newToken: string): boolean { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; + const list = this.#getTokenList(); const index = list.indexOf(token); if (index === -1) { return false; } list[index] = newToken; - this._ownerElement.setAttribute(this._attributeName, list.join(' ')); + this.#ownerElement.setAttribute(this.#attributeName, list.join(' ')); return true; } @@ -81,18 +92,14 @@ export default class DOMTokenList implements IDOMTokenList { * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object. */ public values(): IterableIterator { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; - return list.values(); + return this.#getTokenList().values(); } /** * Returns an iterator, allowing you to go through all key/value pairs contained in this object. */ public entries(): IterableIterator<[number, string]> { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; - return list.entries(); + return this.#getTokenList().entries(); } /** @@ -102,9 +109,7 @@ export default class DOMTokenList implements IDOMTokenList { * @param thisArg */ public forEach(callback: (currentValue, currentIndex, listObj) => void, thisArg?: this): void { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; - return list.forEach(callback, thisArg); + return this.#getTokenList().forEach(callback, thisArg); } /** @@ -112,9 +117,7 @@ export default class DOMTokenList implements IDOMTokenList { * */ public keys(): IterableIterator { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; - return list.keys(); + return this.#getTokenList().keys(); } /** @@ -123,8 +126,7 @@ export default class DOMTokenList implements IDOMTokenList { * @param tokens Tokens. */ public add(...tokens: string[]): void { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; + const list = this.#getTokenList(); for (const token of tokens) { const index = list.indexOf(token); @@ -135,7 +137,7 @@ export default class DOMTokenList implements IDOMTokenList { } } - this._ownerElement.setAttribute(this._attributeName, list.join(' ')); + this.#ownerElement.setAttribute(this.#attributeName, list.join(' ')); } /** @@ -144,8 +146,7 @@ export default class DOMTokenList implements IDOMTokenList { * @param tokens Tokens. */ public remove(...tokens: string[]): void { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; + const list = this.#getTokenList(); for (const token of tokens) { const index = list.indexOf(token); @@ -154,7 +155,7 @@ export default class DOMTokenList implements IDOMTokenList { } } - this._ownerElement.setAttribute(this._attributeName, list.join(' ')); + this.#ownerElement.setAttribute(this.#attributeName, list.join(' ')); } /** @@ -164,8 +165,8 @@ export default class DOMTokenList implements IDOMTokenList { * @returns TRUE if it contains. */ public contains(className: string): boolean { - const attr = this._ownerElement.getAttribute(this._attributeName); - return (attr ? attr.split(' ') : []).includes(className); + const list = this.#getTokenList(); + return list.includes(className); } /** @@ -197,9 +198,8 @@ export default class DOMTokenList implements IDOMTokenList { /** * Updates indices. */ - public _updateIndices(): void { - const attr = this._ownerElement.getAttribute(this._attributeName); - const list = attr ? Array.from(new Set(attr.split(' '))) : []; + public [PropertySymbol.updateIndices](): void { + const list = this.#getTokenList(); for (let i = list.length - 1, max = this.length; i < max; i++) { delete this[i]; @@ -209,7 +209,27 @@ export default class DOMTokenList implements IDOMTokenList { this[i] = list[i]; } - (this.length) = list.length; + this.#length = list.length; + } + + /** + * Returns token list from attribute value. + * + * @see https://infra.spec.whatwg.org/#split-on-ascii-whitespace + */ + #getTokenList(): string[] { + const attr = this.#ownerElement.getAttribute(this.#attributeName); + if (!attr) { + return []; + } + // It is possible to make this statement shorter by using Array.from() and Set, but this is faster when comparing using a bench test. + const list = []; + for (const item of attr.trim().split(ATTRIBUTE_SPLIT_REGEXP)) { + if (!list.includes(item)) { + list.push(item); + } + } + return list; } /** diff --git a/packages/happy-dom/src/event/Event.ts b/packages/happy-dom/src/event/Event.ts index c039f9b68..2ed9e5ca9 100644 --- a/packages/happy-dom/src/event/Event.ts +++ b/packages/happy-dom/src/event/Event.ts @@ -1,6 +1,7 @@ import IEventInit from './IEventInit.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import INode from '../nodes/node/INode.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import IShadowRoot from '../nodes/shadow-root/IShadowRoot.js'; import IEventTarget from './IEventTarget.js'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; @@ -17,18 +18,19 @@ export default class Event { public cancelable: boolean; public defaultPrevented = false; public eventPhase: EventPhaseEnum = EventPhaseEnum.none; - public _immediatePropagationStopped = false; - public _propagationStopped = false; - public _target: IEventTarget = null; - public _currentTarget: IEventTarget = null; public timeStamp: number = performance.now(); public type: string; - public _isInPassiveEventListener = false; public NONE = EventPhaseEnum.none; public CAPTURING_PHASE = EventPhaseEnum.capturing; public AT_TARGET = EventPhaseEnum.atTarget; public BUBBLING_PHASE = EventPhaseEnum.bubbling; + public [PropertySymbol.immediatePropagationStopped] = false; + public [PropertySymbol.propagationStopped] = false; + public [PropertySymbol.target]: IEventTarget = null; + public [PropertySymbol.currentTarget]: IEventTarget = null; + public [PropertySymbol.isInPassiveEventListener] = false; + /** * Constructor. * @@ -49,7 +51,7 @@ export default class Event { * @returns Target. */ public get target(): IEventTarget { - return this._target; + return this[PropertySymbol.target]; } /** @@ -58,7 +60,7 @@ export default class Event { * @returns Target. */ public get currentTarget(): IEventTarget { - return this._currentTarget; + return this[PropertySymbol.currentTarget]; } /** @@ -67,7 +69,7 @@ export default class Event { * @returns "true" if propagation has been stopped. */ public get cancelBubble(): boolean { - return this._propagationStopped; + return this[PropertySymbol.propagationStopped]; } /** @@ -76,12 +78,14 @@ export default class Event { * @returns Composed path. */ public composedPath(): IEventTarget[] { - if (!this._target) { + if (!this[PropertySymbol.target]) { return []; } const composedPath = []; - let eventTarget: INode | IShadowRoot | IWindow = (this._target); + let eventTarget: INode | IShadowRoot | IBrowserWindow = ( + (this[PropertySymbol.target]) + ); while (eventTarget) { composedPath.push(eventTarget); @@ -90,12 +94,12 @@ export default class Event { eventTarget = ((eventTarget)).parentNode; } else if ( this.composed && - (eventTarget).nodeType === NodeTypeEnum.documentFragmentNode && + (eventTarget)[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode && (eventTarget).host ) { eventTarget = (eventTarget).host; - } else if ((eventTarget).nodeType === NodeTypeEnum.documentNode) { - eventTarget = ((eventTarget)).defaultView; + } else if ((eventTarget)[PropertySymbol.nodeType] === NodeTypeEnum.documentNode) { + eventTarget = ((eventTarget))[PropertySymbol.ownerWindow]; } else { break; } @@ -122,7 +126,7 @@ export default class Event { * Prevents default. */ public preventDefault(): void { - if (!this._isInPassiveEventListener) { + if (!this[PropertySymbol.isInPassiveEventListener]) { this.defaultPrevented = true; } } @@ -131,13 +135,13 @@ export default class Event { * Stops immediate propagation. */ public stopImmediatePropagation(): void { - this._immediatePropagationStopped = true; + this[PropertySymbol.immediatePropagationStopped] = true; } /** * Stops propagation. */ public stopPropagation(): void { - this._propagationStopped = true; + this[PropertySymbol.propagationStopped] = true; } } diff --git a/packages/happy-dom/src/event/EventTarget.ts b/packages/happy-dom/src/event/EventTarget.ts index 2ae28d65c..0d8453098 100644 --- a/packages/happy-dom/src/event/EventTarget.ts +++ b/packages/happy-dom/src/event/EventTarget.ts @@ -1,21 +1,24 @@ import IEventListener from './IEventListener.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import Event from './Event.js'; import IEventTarget from './IEventTarget.js'; import IEventListenerOptions from './IEventListenerOptions.js'; import EventPhaseEnum from './EventPhaseEnum.js'; import INode from '../nodes/node/INode.js'; import IDocument from '../nodes/document/IDocument.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import WindowErrorUtility from '../window/WindowErrorUtility.js'; +import WindowBrowserSettingsReader from '../window/WindowBrowserSettingsReader.js'; +import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; /** * Handles events. */ export default abstract class EventTarget implements IEventTarget { - public readonly _listeners: { + public readonly [PropertySymbol.listeners]: { [k: string]: (((event: Event) => void) | IEventListener)[]; } = {}; - public readonly _listenerOptions: { + public readonly [PropertySymbol.listenerOptions]: { [k: string]: (IEventListenerOptions | null)[]; } = {}; @@ -41,21 +44,21 @@ export default abstract class EventTarget implements IEventTarget { ): void { const listenerOptions = typeof options === 'boolean' ? { capture: options } : options || null; - this._listeners[type] = this._listeners[type] || []; - this._listenerOptions[type] = this._listenerOptions[type] || []; - if (this._listeners[type].includes(listener)) { + this[PropertySymbol.listeners][type] = this[PropertySymbol.listeners][type] || []; + this[PropertySymbol.listenerOptions][type] = this[PropertySymbol.listenerOptions][type] || []; + if (this[PropertySymbol.listeners][type].includes(listener)) { return; } - this._listeners[type].push(listener); - this._listenerOptions[type].push(listenerOptions); + this[PropertySymbol.listeners][type].push(listener); + this[PropertySymbol.listenerOptions][type].push(listenerOptions); // Tracks the amount of capture event listeners to improve performance when they are not used. if (listenerOptions && listenerOptions.capture) { - const window = this._getWindow(); + const window = this.#getWindow(); if (window) { - window['_captureEventListenerCount'][type] = - window['_captureEventListenerCount'][type] ?? 0; - window['_captureEventListenerCount'][type]++; + window[PropertySymbol.captureEventListenerCount][type] = + window[PropertySymbol.captureEventListenerCount][type] ?? 0; + window[PropertySymbol.captureEventListenerCount][type]++; } } } @@ -70,19 +73,22 @@ export default abstract class EventTarget implements IEventTarget { type: string, listener: ((event: Event) => void) | IEventListener ): void { - if (this._listeners[type]) { - const index = this._listeners[type].indexOf(listener); + if (this[PropertySymbol.listeners][type]) { + const index = this[PropertySymbol.listeners][type].indexOf(listener); if (index !== -1) { // Tracks the amount of capture event listeners to improve performance when they are not used. - if (this._listenerOptions[type][index] && this._listenerOptions[type][index].capture) { - const window = this._getWindow(); - if (window && window['_captureEventListenerCount'][type]) { - window['_captureEventListenerCount'][type]--; + if ( + this[PropertySymbol.listenerOptions][type][index] && + this[PropertySymbol.listenerOptions][type][index].capture + ) { + const window = this.#getWindow(); + if (window && window[PropertySymbol.captureEventListenerCount][type]) { + window[PropertySymbol.captureEventListenerCount][type]--; } } - this._listeners[type].splice(index, 1); - this._listenerOptions[type].splice(index, 1); + this[PropertySymbol.listeners][type].splice(index, 1); + this[PropertySymbol.listenerOptions][type].splice(index, 1); } } } @@ -96,22 +102,25 @@ export default abstract class EventTarget implements IEventTarget { * @returns The return value is false if event is cancelable and at least one of the event handlers which handled this event called Event.preventDefault(). */ public dispatchEvent(event: Event): boolean { - const window = this._getWindow(); + const window = this.#getWindow(); if (event.eventPhase === EventPhaseEnum.none) { - event._target = this; + event[PropertySymbol.target] = this; const composedPath = event.composedPath(); // Capturing phase // We only need to iterate over the composed path if there are capture event listeners. - if (window && window['_captureEventListenerCount'][event.type]) { + if (window && window[PropertySymbol.captureEventListenerCount][event.type]) { event.eventPhase = EventPhaseEnum.capturing; for (let i = composedPath.length - 1; i >= 0; i--) { composedPath[i].dispatchEvent(event); - if (event._propagationStopped || event._immediatePropagationStopped) { + if ( + event[PropertySymbol.propagationStopped] || + event[PropertySymbol.immediatePropagationStopped] + ) { break; } } @@ -123,12 +132,19 @@ export default abstract class EventTarget implements IEventTarget { this.dispatchEvent(event); // Bubbling phase - if (event.bubbles && !event._propagationStopped && !event._immediatePropagationStopped) { + if ( + event.bubbles && + !event[PropertySymbol.propagationStopped] && + !event[PropertySymbol.immediatePropagationStopped] + ) { event.eventPhase = EventPhaseEnum.bubbling; for (let i = 1; i < composedPath.length; i++) { composedPath[i].dispatchEvent(event); - if (event._propagationStopped || event._immediatePropagationStopped) { + if ( + event[PropertySymbol.propagationStopped] || + event[PropertySymbol.immediatePropagationStopped] + ) { break; } } @@ -140,7 +156,9 @@ export default abstract class EventTarget implements IEventTarget { return !(event.cancelable && event.defaultPrevented); } - event._currentTarget = this; + event[PropertySymbol.currentTarget] = this; + + const browserSettings = window ? WindowBrowserSettingsReader.getSettings(window) : null; if (event.eventPhase !== EventPhaseEnum.capturing) { const onEventName = 'on' + event.type.toLowerCase(); @@ -150,7 +168,8 @@ export default abstract class EventTarget implements IEventTarget { if ( window && (this !== window || event.type !== 'error') && - !window.happyDOM.settings.disableErrorCapturing + !browserSettings?.disableErrorCapturing && + browserSettings?.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { WindowErrorUtility.captureError(window, this[onEventName].bind(this, event)); } else { @@ -159,10 +178,10 @@ export default abstract class EventTarget implements IEventTarget { } } - if (this._listeners[event.type]) { + if (this[PropertySymbol.listeners][event.type]) { // We need to clone the arrays because the listeners may remove themselves while we are iterating. - const listeners = this._listeners[event.type].slice(); - const listenerOptions = this._listenerOptions[event.type].slice(); + const listeners = this[PropertySymbol.listeners][event.type].slice(); + const listenerOptions = this[PropertySymbol.listenerOptions][event.type].slice(); for (let i = 0, max = listeners.length; i < max; i++) { const listener = listeners[i]; @@ -176,14 +195,15 @@ export default abstract class EventTarget implements IEventTarget { } if (options?.passive) { - event._isInPassiveEventListener = true; + event[PropertySymbol.isInPassiveEventListener] = true; } // We can end up in a never ending loop if the listener for the error event on Window also throws an error. if ( window && (this !== window || event.type !== 'error') && - !window.happyDOM.settings.disableErrorCapturing + !browserSettings?.disableErrorCapturing && + browserSettings?.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { if ((listener).handleEvent) { WindowErrorUtility.captureError( @@ -204,7 +224,7 @@ export default abstract class EventTarget implements IEventTarget { } } - event._isInPassiveEventListener = false; + event[PropertySymbol.isInPassiveEventListener] = false; if (options?.once) { // At this time, listeners and listenersOptions are cloned arrays. When the original value is deleted, @@ -216,7 +236,7 @@ export default abstract class EventTarget implements IEventTarget { max--; } - if (event._immediatePropagationStopped) { + if (event[PropertySymbol.immediatePropagationStopped]) { return !(event.cancelable && event.defaultPrevented); } } @@ -258,15 +278,15 @@ export default abstract class EventTarget implements IEventTarget { * * @returns Window. */ - public _getWindow(): IWindow | null { - if (((this)).ownerDocument) { - return ((this)).ownerDocument.defaultView; + #getWindow(): IBrowserWindow | null { + if (((this))[PropertySymbol.ownerDocument]) { + return ((this))[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; } - if (((this)).defaultView) { - return ((this)).defaultView; + if (((this))[PropertySymbol.ownerWindow]) { + return ((this))[PropertySymbol.ownerWindow]; } - if (((this)).document) { - return (this); + if (((this)).document) { + return (this); } return null; } diff --git a/packages/happy-dom/src/event/IUIEventInit.ts b/packages/happy-dom/src/event/IUIEventInit.ts index e1f1c9093..d6047c74d 100644 --- a/packages/happy-dom/src/event/IUIEventInit.ts +++ b/packages/happy-dom/src/event/IUIEventInit.ts @@ -1,7 +1,7 @@ -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import IEventInit from './IEventInit.js'; export default interface IUIEventInit extends IEventInit { detail?: number; - view?: IWindow; + view?: IBrowserWindow; } diff --git a/packages/happy-dom/src/event/UIEvent.ts b/packages/happy-dom/src/event/UIEvent.ts index 1fc859f16..42cc4e1ba 100644 --- a/packages/happy-dom/src/event/UIEvent.ts +++ b/packages/happy-dom/src/event/UIEvent.ts @@ -1,4 +1,4 @@ -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import Event from './Event.js'; import IUIEventInit from './IUIEventInit.js'; @@ -15,7 +15,7 @@ export default class UIEvent extends Event { public readonly layerY: number = 0; public readonly pageX: number = 0; public readonly pageY: number = 0; - public readonly view: IWindow | null; + public readonly view: IBrowserWindow | null; /** * Constructor. diff --git a/packages/happy-dom/src/event/events/IMessageEventInit.ts b/packages/happy-dom/src/event/events/IMessageEventInit.ts index 59571f94e..99852dcde 100644 --- a/packages/happy-dom/src/event/events/IMessageEventInit.ts +++ b/packages/happy-dom/src/event/events/IMessageEventInit.ts @@ -1,11 +1,11 @@ import IEventInit from '../IEventInit.js'; -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import IMessagePort from '../IMessagePort.js'; export default interface IMessageEventInit extends IEventInit { data?: unknown | null; origin?: string; lastEventId?: string; - source?: IWindow | null; + source?: IBrowserWindow | null; ports?: IMessagePort[]; } diff --git a/packages/happy-dom/src/event/events/MessageEvent.ts b/packages/happy-dom/src/event/events/MessageEvent.ts index a2cd12f8e..25e001497 100644 --- a/packages/happy-dom/src/event/events/MessageEvent.ts +++ b/packages/happy-dom/src/event/events/MessageEvent.ts @@ -1,4 +1,4 @@ -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import Event from '../Event.js'; import IMessagePort from '../IMessagePort.js'; import IMessageEventInit from './IMessageEventInit.js'; @@ -12,7 +12,7 @@ export default class MessageEvent extends Event { public readonly data: unknown | null; public readonly origin: string; public readonly lastEventId: string; - public readonly source: IWindow | null; + public readonly source: IBrowserWindow | null; public readonly ports: IMessagePort[]; /** diff --git a/packages/happy-dom/src/fetch/AbortController.ts b/packages/happy-dom/src/fetch/AbortController.ts index 8ddf30237..d4bf6a428 100644 --- a/packages/happy-dom/src/fetch/AbortController.ts +++ b/packages/happy-dom/src/fetch/AbortController.ts @@ -1,4 +1,5 @@ import AbortSignal from './AbortSignal.js'; +import * as PropertySymbol from '../PropertySymbol.js'; /** * AbortController. @@ -21,6 +22,6 @@ export default class AbortController { * @param [reason] Reason. */ public abort(reason?: string): void { - this.signal._abort(reason); + this.signal[PropertySymbol.abort](reason); } } diff --git a/packages/happy-dom/src/fetch/AbortSignal.ts b/packages/happy-dom/src/fetch/AbortSignal.ts index 038a999de..70e0cb502 100644 --- a/packages/happy-dom/src/fetch/AbortSignal.ts +++ b/packages/happy-dom/src/fetch/AbortSignal.ts @@ -1,4 +1,5 @@ import EventTarget from '../event/EventTarget.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import Event from '../event/Event.js'; /** @@ -23,7 +24,7 @@ export default class AbortSignal extends EventTarget { * * @param [reason] Reason. */ - public _abort(reason?: string): void { + public [PropertySymbol.abort](reason?: string): void { if (this.aborted) { return; } diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 817bff430..77b587760 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -1,13 +1,11 @@ import IRequestInit from './types/IRequestInit.js'; -import IDocument from '../nodes/document/IDocument.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IResponse from './types/IResponse.js'; -import Request from './Request.js'; import IRequestInfo from './types/IRequestInfo.js'; import Headers from './Headers.js'; import FetchRequestReferrerUtility from './utilities/FetchRequestReferrerUtility.js'; import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; -import Response from './Response.js'; import HTTP, { IncomingMessage } from 'http'; import HTTPS from 'https'; import Zlib from 'zlib'; @@ -16,12 +14,20 @@ import { Socket } from 'net'; import Stream from 'stream'; import DataURIParser from './data-uri/DataURIParser.js'; import FetchCORSUtility from './utilities/FetchCORSUtility.js'; -import CookieJar from '../cookie/CookieJar.js'; +import Request from './Request.js'; +import Response from './Response.js'; +import Event from '../event/Event.js'; +import AbortSignal from './AbortSignal.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import CachedResponseStateEnum from './cache/response/CachedResponseStateEnum.js'; +import FetchRequestHeaderUtility from './utilities/FetchRequestHeaderUtility.js'; +import FetchRequestValidationUtility from './utilities/FetchRequestValidationUtility.js'; +import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js'; +import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.js'; +import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js'; -const SUPPORTED_SCHEMAS = ['data:', 'http:', 'https:']; -const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); -const MAX_REDIRECT_COUNT = 20; /** * Handles fetch requests. @@ -42,39 +48,49 @@ export default class Fetch { private previousChunk: Buffer | null = null; private nodeRequest: HTTP.ClientRequest | null = null; private response: Response | null = null; - private ownerDocument: IDocument; + private responseHeaders: Headers | null = null; private request: Request; private redirectCount = 0; + private disableCache: boolean; + private disableCrossOriginPolicy: boolean; + #browserFrame: IBrowserFrame; + #window: IBrowserWindow; /** * Constructor. * * @param options Options. - * @param options.document + * @param options.browserFrame Browser frame. + * @param options.window Window. * @param options.url URL. * @param [options.init] Init. - * @param [options.ownerDocument] Owner document. * @param [options.redirectCount] Redirect count. * @param [options.contentType] Content Type. + * @param [options.disableCache] Disables the use of cached responses. It will still store the response in the cache. + * @param [options.disableCrossOriginPolicy] Disables the Cross-Origin policy. */ constructor(options: { - ownerDocument: IDocument; + browserFrame: IBrowserFrame; + window: IBrowserWindow; url: IRequestInfo; init?: IRequestInit; redirectCount?: number; contentType?: string; + disableCache?: boolean; + disableCrossOriginPolicy?: boolean; }) { - const url = options.url; - - this.ownerDocument = options.ownerDocument; + this.#browserFrame = options.browserFrame; + this.#window = options.window; this.request = typeof options.url === 'string' || options.url instanceof URL - ? new Request(options.url, options.init) - : url; + ? new options.browserFrame.window.Request(options.url, options.init) + : options.url; if (options.contentType) { - (this.request._contentType) = options.contentType; + (this.request[PropertySymbol.contentType]) = options.contentType; } - this.redirectCount = options.redirectCount || 0; + this.redirectCount = options.redirectCount ?? 0; + this.disableCache = options.disableCache ?? false; + this.disableCrossOriginPolicy = options.disableCrossOriginPolicy ?? false; } /** @@ -82,48 +98,301 @@ export default class Fetch { * * @returns Response. */ - public send(): Promise { + public async send(): Promise { + FetchRequestReferrerUtility.prepareRequest(this.#window.location, this.request); + FetchRequestValidationUtility.validateSchema(this.request); + + if (this.request.signal.aborted) { + throw new DOMException('The operation was aborted.', DOMExceptionNameEnum.abortError); + } + + if (this.request[PropertySymbol.url].protocol === 'data:') { + const result = DataURIParser.parse(this.request.url); + this.response = new this.#window.Response(result.buffer, { + headers: { 'Content-Type': result.type } + }); + return this.response; + } + + // Security check for "https" to "http" requests. + if ( + this.request[PropertySymbol.url].protocol === 'http:' && + this.#window.location.protocol === 'https:' + ) { + throw new DOMException( + `Mixed Content: The page at '${ + this.#window.location.href + }' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${ + this.request.url + }'. This request has been blocked; the content must be served over HTTPS.`, + DOMExceptionNameEnum.securityError + ); + } + + if (!this.disableCache) { + const cachedResponse = await this.getCachedResponse(); + + if (cachedResponse) { + return cachedResponse; + } + } + + if (!this.disableCrossOriginPolicy) { + const compliesWithCrossOriginPolicy = await this.compliesWithCrossOriginPolicy(); + + if (!compliesWithCrossOriginPolicy) { + this.#window.console.warn( + `Cross-Origin Request Blocked: The Same Origin Policy dissallows reading the remote resource at "${this.request.url}".` + ); + throw new DOMException( + `Cross-Origin Request Blocked: The Same Origin Policy dissallows reading the remote resource at "${this.request.url}".`, + DOMExceptionNameEnum.networkError + ); + } + } + + return await this.sendRequest(); + } + + /** + * Returns cached response. + * + * @returns Response. + */ + private async getCachedResponse(): Promise { + if (this.disableCache) { + return null; + } + + let cachedResponse = this.#browserFrame.page.context.responseCache.get(this.request); + + if (!cachedResponse || cachedResponse.response.waitingForBody) { + return null; + } + + if (cachedResponse.state === CachedResponseStateEnum.stale) { + const headers = new Headers(cachedResponse.request.headers); + + if (cachedResponse.etag) { + headers.set('If-None-Match', cachedResponse.etag); + } else { + if (!cachedResponse.lastModified) { + return null; + } + headers.set('If-Modified-Since', new Date(cachedResponse.lastModified).toUTCString()); + } + + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.request.url, + init: { headers, method: cachedResponse.request.method }, + disableCache: true, + disableCrossOriginPolicy: true + }); + + if (cachedResponse.etag || !cachedResponse.staleWhileRevalidate) { + const validateResponse = await fetch.send(); + const body = validateResponse.status !== 304 ? await validateResponse.buffer() : null; + + cachedResponse = this.#browserFrame.page.context.responseCache.add(this.request, { + ...validateResponse, + body, + waitingForBody: false + }); + + if (validateResponse.status !== 304) { + const response = new this.#window.Response(body, { + status: validateResponse.status, + statusText: validateResponse.statusText, + headers: validateResponse.headers + }); + (response.url) = validateResponse.url; + response[PropertySymbol.cachedResponse] = cachedResponse; + + return response; + } + } else { + fetch.send().then((response) => { + response.buffer().then((body: Buffer) => { + this.#browserFrame.page.context.responseCache.add(this.request, { + ...response, + body, + waitingForBody: false + }); + }); + }); + } + } + + if (!cachedResponse || cachedResponse.response.waitingForBody) { + return null; + } + + const response = new this.#window.Response(cachedResponse.response.body, { + status: cachedResponse.response.status, + statusText: cachedResponse.response.statusText, + headers: cachedResponse.response.headers + }); + (response.url) = cachedResponse.response.url; + response[PropertySymbol.cachedResponse] = cachedResponse; + + return response; + } + + /** + * Checks if the request complies with the Cross-Origin policy. + * + * @returns True if it complies with the policy. + */ + private async compliesWithCrossOriginPolicy(): Promise { + if ( + this.disableCrossOriginPolicy || + !FetchCORSUtility.isCORS(this.#window.location, this.request[PropertySymbol.url]) + ) { + return true; + } + + const cachedPreflightResponse = this.#browserFrame.page.context.preflightResponseCache.get( + this.request + ); + + if (cachedPreflightResponse) { + if ( + cachedPreflightResponse.allowOrigin !== '*' && + cachedPreflightResponse.allowOrigin !== this.#window.location.origin + ) { + return false; + } + + if ( + cachedPreflightResponse.allowMethods.length !== 0 && + !cachedPreflightResponse.allowMethods.includes(this.request.method) + ) { + return false; + } + + return true; + } + + const requestHeaders = []; + + for (const [header] of this.request.headers) { + requestHeaders.push(header); + } + + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.request.url, + init: { + method: 'OPTIONS', + headers: new Headers({ + 'Access-Control-Request-Method': this.request.method, + 'Access-Control-Request-Headers': requestHeaders.join(', ') + }) + }, + disableCache: true, + disableCrossOriginPolicy: true + }); + + const response = await fetch.send(); + + if (!response.ok) { + return false; + } + + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + + if (!allowOrigin) { + return false; + } + + if (allowOrigin !== '*' && allowOrigin !== this.#window.location.origin) { + return false; + } + + const allowMethods: string[] = []; + + if (response.headers.has('Access-Control-Allow-Methods')) { + const allowMethodsHeader = response.headers.get('Access-Control-Allow-Methods'); + if (allowMethodsHeader !== '*') { + for (const method of allowMethodsHeader.split(',')) { + allowMethods.push(method.trim().toUpperCase()); + } + } + } + + if (allowMethods.length !== 0 && !allowMethods.includes(this.request.method)) { + return false; + } + + // TODO: Add support for more Access-Control-Allow-* headers. + + return true; + } + + /** + * Sends request. + * + * @returns Response. + */ + private sendRequest(): Promise { return new Promise((resolve, reject) => { - const taskManager = this.ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(() => this.abort()); + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(() => + this.onAsyncTaskManagerAbort() + ); if (this.resolve) { throw new Error('Fetch already sent.'); } this.resolve = (response: IResponse | Promise): void => { - taskManager.endTask(taskID); + // We can end up here when closing down the browser frame and there is an ongoing request. + // Therefore we need to check if browserFrame.page.context is still available. + if ( + !this.disableCache && + response instanceof Response && + this.#browserFrame.page && + this.#browserFrame.page.context + ) { + response[PropertySymbol.cachedResponse] = + this.#browserFrame.page.context.responseCache.add(this.request, { + ...response, + headers: this.responseHeaders, + body: response[PropertySymbol.buffer], + waitingForBody: !response[PropertySymbol.buffer] && !!response.body + }); + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); resolve(response); }; this.reject = (error: Error): void => { - taskManager.endTask(taskID); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); reject(error); }; - this.prepareRequest(); - this.validateRequest(); - - if (this.request._url.protocol === 'data:') { - const result = DataURIParser.parse(this.request.url); - this.response = new Response(result.buffer, { - headers: { 'Content-Type': result.type } - }); - resolve(this.response); - return; - } - - if (this.request.signal.aborted) { - this.abort(); - return; - } - this.request.signal.addEventListener('abort', this.listeners.onSignalAbort); - const send = (this.request._url.protocol === 'https:' ? HTTPS : HTTP).request; + const send = (this.request[PropertySymbol.url].protocol === 'https:' ? HTTPS : HTTP).request; - this.nodeRequest = send(this.request._url.href, { + this.nodeRequest = send(this.request[PropertySymbol.url].href, { method: this.request.method, - headers: this.getRequestHeaders() + headers: FetchRequestHeaderUtility.getRequestHeaders({ + browserFrame: this.#browserFrame, + window: this.#window, + request: this.request + }), + agent: false, + rejectUnauthorized: true, + key: + this.request[PropertySymbol.url].protocol === 'https:' + ? FetchHTTPSCertificate.key + : undefined, + cert: + this.request[PropertySymbol.url].protocol === 'https:' + ? FetchHTTPSCertificate.cert + : undefined }); this.nodeRequest.on('error', this.onError.bind(this)); @@ -182,10 +451,12 @@ export default class Fetch { /** * Event listener for signal "abort" event. + * + * @param event Event. */ - private onSignalAbort(): void { + private onSignalAbort(event: Event): void { this.finalizeRequest(); - this.abort(); + this.abort((event.target)?.reason); } /** @@ -195,7 +466,7 @@ export default class Fetch { */ private onError(error: Error): void { this.finalizeRequest(); - this.ownerDocument.defaultView.console.error(error); + this.#window.console.error(error); this.reject( new DOMException( `Fetch to "${this.request.url}" failed. Error: ${error.message}`, @@ -204,6 +475,23 @@ export default class Fetch { ); } + /** + * Triggered when the async task manager aborts. + */ + private onAsyncTaskManagerAbort(): void { + const error = new DOMException('The operation was aborted.', DOMExceptionNameEnum.abortError); + + if (this.request.body) { + this.request.body.destroy(error); + } + + if (!this.response || !this.response.body) { + return; + } + + this.response.body.destroy(error); + } + /** * Event listener for request "response" event. * @@ -216,10 +504,13 @@ export default class Fetch { !nodeResponse.headers['content-length']; this.nodeRequest.setTimeout(0); + this.responseHeaders = FetchResponseHeaderUtility.parseResponseHeaders({ + browserFrame: this.#browserFrame, + requestURL: this.request[PropertySymbol.url], + rawHeaders: nodeResponse.rawHeaders + }); - const headers = this.getResponseHeaders(nodeResponse); - - if (this.handleRedirectResponse(nodeResponse, headers)) { + if (this.handleRedirectResponse(nodeResponse, this.responseHeaders)) { return; } @@ -236,10 +527,10 @@ export default class Fetch { const responseOptions = { status: nodeResponse.statusCode, statusText: nodeResponse.statusMessage, - headers + headers: this.responseHeaders }; - const contentEncodingHeader = headers.get('Content-Encoding'); + const contentEncodingHeader = this.responseHeaders.get('Content-Encoding'); if ( this.request.method === 'HEAD' || @@ -247,29 +538,29 @@ export default class Fetch { nodeResponse.statusCode === 204 || nodeResponse.statusCode === 304 ) { - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); return; } - // Be less strict when decoding compressed responses. - // Sometimes servers send slightly invalid responses that are still accepted by common browsers. - // "cURL" always uses Z_SYNC_FLUSH. - const zlibOptions = { - flush: Zlib.constants.Z_SYNC_FLUSH, - finishFlush: Zlib.constants.Z_SYNC_FLUSH - }; - // For GZip if (contentEncodingHeader === 'gzip' || contentEncodingHeader === 'x-gzip') { + // Be less strict when decoding compressed responses. + // Sometimes servers send slightly invalid responses that are still accepted by common browsers. + // "cURL" always uses Z_SYNC_FLUSH. + const zlibOptions = { + flush: Zlib.constants.Z_SYNC_FLUSH, + finishFlush: Zlib.constants.Z_SYNC_FLUSH + }; + body = Stream.pipeline(body, Zlib.createGunzip(zlibOptions), (error: Error) => { if (error) { // Ignore error as it is forwarded to the response body. } }); - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -301,7 +592,7 @@ export default class Fetch { }); } - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -309,7 +600,7 @@ export default class Fetch { raw.on('end', () => { // Some old IIS servers return zero-length OK deflate responses, so 'data' is never emitted. if (!this.response) { - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -325,7 +616,7 @@ export default class Fetch { // Ignore error as it is forwarded to the response body. } }); - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -333,7 +624,7 @@ export default class Fetch { } // Otherwise, use response as is - this.response = new Response(body, responseOptions); + this.response = new this.#window.Response(body, responseOptions); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -347,7 +638,7 @@ export default class Fetch { * @returns True if redirect response was handled, false otherwise. */ private handleRedirectResponse(nodeResponse: IncomingMessage, responseHeaders: Headers): boolean { - if (!this.isRedirect(nodeResponse.statusCode)) { + if (!FetchResponseRedirectUtility.isRedirect(nodeResponse.statusCode)) { return false; } @@ -391,7 +682,7 @@ export default class Fetch { return false; } - if (this.redirectCount >= MAX_REDIRECT_COUNT) { + if (FetchResponseRedirectUtility.isMaxRedirectsReached(this.redirectCount)) { this.finalizeRequest(); this.reject( new DOMException( @@ -403,21 +694,6 @@ export default class Fetch { } const headers = new Headers(this.request.headers); - let body: Stream.Readable | Buffer | null = this.request._bodyBuffer; - - if (!body && this.request.body) { - // Piping a used request body is not possible. - if (this.request.bodyUsed) { - throw new DOMException( - 'It is not possible to pipe a body after it is used.', - DOMExceptionNameEnum.networkError - ); - } - - body = new Stream.PassThrough(); - this.request.body.pipe(body); - } - const requestInit: IRequestInit = { method: this.request.method, signal: this.request.signal, @@ -425,14 +701,13 @@ export default class Fetch { referrerPolicy: this.request.referrerPolicy, credentials: this.request.credentials, headers, - body + body: this.request[PropertySymbol.bodyBuffer] }; - // TODO: Maybe we need to add support for OPTIONS request with 'Access-Control-Allow-*' headers? if ( this.request.credentials === 'omit' || (this.request.credentials === 'same-origin' && - FetchCORSUtility.isCORS(this.ownerDocument.location, locationURL)) + FetchCORSUtility.isCORS(this.#window.location, locationURL)) ) { headers.delete('authorization'); headers.delete('www-authenticate'); @@ -440,17 +715,6 @@ export default class Fetch { headers.delete('cookie2'); } - if (nodeResponse.statusCode !== 303 && this.request.body && !this.request._bodyBuffer) { - this.finalizeRequest(); - this.reject( - new DOMException( - 'Cannot follow redirect with body being a readable stream.', - DOMExceptionNameEnum.networkError - ) - ); - return true; - } - if (this.request.signal.aborted) { this.abort(); return true; @@ -469,16 +733,22 @@ export default class Fetch { requestInit.referrerPolicy = responseReferrerPolicy; } - const fetch = new (this.constructor)({ - ownerDocument: this.ownerDocument, + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.#window, url: locationURL, init: requestInit, redirectCount: this.redirectCount + 1, - contentType: !shouldBecomeGetRequest ? this.request._contentType : undefined + contentType: !shouldBecomeGetRequest + ? this.request[PropertySymbol.contentType] + : undefined }); this.finalizeRequest(); - this.resolve(fetch.send()); + fetch + .send() + .then((response) => this.resolve(response)) + .catch((error) => this.reject(error)); return true; default: this.finalizeRequest(); @@ -491,146 +761,6 @@ export default class Fetch { } } - /** - * Prepares the request before being sent. - */ - private prepareRequest(): void { - if (!this.request.referrerPolicy) { - (this.request.referrerPolicy) = 'strict-origin-when-cross-origin'; - } - - if (this.request.referrer && this.request.referrer !== 'no-referrer') { - this.request._referrer = FetchRequestReferrerUtility.getSentReferrer( - this.ownerDocument, - this.request - ); - } else { - this.request._referrer = 'no-referrer'; - } - } - - /** - * Validates the request. - * - * @throws {Error} Throws an error if the request is invalid. - */ - private validateRequest(): void { - if (!SUPPORTED_SCHEMAS.includes(this.request._url.protocol)) { - throw new DOMException( - `Failed to fetch from "${ - this.request.url - }": URL scheme "${this.request._url.protocol.replace(/:$/, '')}" is not supported.`, - DOMExceptionNameEnum.notSupportedError - ); - } - } - - /** - * Returns request headers. - * - * @returns Headers. - */ - private getRequestHeaders(): { [key: string]: string } { - const headers = new Headers(this.request.headers); - const document = this.ownerDocument; - const isCORS = FetchCORSUtility.isCORS(document.location, this.request._url); - - // TODO: Maybe we need to add support for OPTIONS request with 'Access-Control-Allow-*' headers? - if ( - this.request.credentials === 'omit' || - (this.request.credentials === 'same-origin' && isCORS) - ) { - headers.delete('authorization'); - headers.delete('www-authenticate'); - } - - headers.set('Accept-Encoding', 'gzip, deflate, br'); - headers.set('Connection', 'close'); - - if (!headers.has('User-Agent')) { - headers.set('User-Agent', document.defaultView.navigator.userAgent); - } - - if (this.request._referrer instanceof URL) { - headers.set('Referer', this.request._referrer.href); - } - - if ( - this.request.credentials === 'include' || - (this.request.credentials === 'same-origin' && !isCORS) - ) { - const cookie = document.defaultView.document._cookie.getCookieString( - this.ownerDocument.defaultView.location, - false - ); - if (cookie) { - headers.set('Cookie', cookie); - } - } - - if (!headers.has('Accept')) { - headers.set('Accept', '*/*'); - } - - if (!headers.has('Content-Length') && this.request._contentLength !== null) { - headers.set('Content-Length', String(this.request._contentLength)); - } - - if (!headers.has('Content-Type') && this.request._contentType) { - headers.set('Content-Type', this.request._contentType); - } - - // We need to convert the headers to Node request headers. - const httpRequestHeaders = {}; - - for (const header of Object.values(headers._entries)) { - httpRequestHeaders[header.name] = header.value; - } - - return httpRequestHeaders; - } - - /** - * Returns "true" if redirect. - * - * @param statusCode Status code. - * @returns "true" if redirect. - */ - private isRedirect(statusCode: number): boolean { - return REDIRECT_STATUS_CODES.includes(statusCode); - } - - /** - * Appends headers to response. - * - * @param nodeResponse HTTP request. - * @returns Headers. - */ - private getResponseHeaders(nodeResponse: IncomingMessage): Headers { - const headers = new Headers(); - let key = null; - - for (const header of nodeResponse.rawHeaders) { - if (!key) { - key = header; - } else { - const lowerKey = key.toLowerCase(); - - // Handles setting cookie headers to the document. - // "set-cookie" and "set-cookie2" are not allowed in response headers according to spec. - if (lowerKey === 'set-cookie' || lowerKey === 'set-cookie2') { - (this.ownerDocument['_cookie']).addCookieString(this.request._url, header); - } else { - headers.append(key, header); - } - - key = null; - } - } - - return headers; - } - /** * Finalizes the request. */ @@ -641,9 +771,14 @@ export default class Fetch { /** * Aborts the request. + * + * @param reason Reason. */ - private abort(): void { - const error = new DOMException('The operation was aborted.', DOMExceptionNameEnum.abortError); + private abort(reason?: string): void { + const error = new DOMException( + 'The operation was aborted.' + (reason ? ' ' + reason : ''), + DOMExceptionNameEnum.abortError + ); if (this.request.body) { this.request.body.destroy(error); @@ -656,7 +791,7 @@ export default class Fetch { return; } - this.response.body.emit('error', error); + this.response.body.destroy(error); if (this.reject) { this.reject(error); diff --git a/packages/happy-dom/src/fetch/Headers.ts b/packages/happy-dom/src/fetch/Headers.ts index 3d549b65f..0cc8ec207 100644 --- a/packages/happy-dom/src/fetch/Headers.ts +++ b/packages/happy-dom/src/fetch/Headers.ts @@ -1,4 +1,5 @@ import DOMException from '../exception/DOMException.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import IHeaders from './types/IHeaders.js'; import IHeadersInit from './types/IHeadersInit.js'; @@ -9,7 +10,7 @@ import IHeadersInit from './types/IHeadersInit.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/Headers */ export default class Headers implements IHeaders { - public _entries: { [k: string]: { name: string; value: string } } = {}; + public [PropertySymbol.entries]: { [k: string]: { name: string; value: string } } = {}; /** * Constructor. @@ -19,7 +20,7 @@ export default class Headers implements IHeaders { constructor(init?: IHeadersInit) { if (init) { if (init instanceof Headers) { - this._entries = JSON.parse(JSON.stringify(init._entries)); + this[PropertySymbol.entries] = JSON.parse(JSON.stringify(init[PropertySymbol.entries])); } else if (Array.isArray(init)) { for (const entry of init) { if (entry.length !== 2) { @@ -46,10 +47,10 @@ export default class Headers implements IHeaders { */ public append(name: string, value: string): void { const lowerName = name.toLowerCase(); - if (this._entries[lowerName]) { - this._entries[lowerName].value += `, ${value}`; + if (this[PropertySymbol.entries][lowerName]) { + this[PropertySymbol.entries][lowerName].value += `, ${value}`; } else { - this._entries[lowerName] = { + this[PropertySymbol.entries][lowerName] = { name, value }; @@ -62,7 +63,7 @@ export default class Headers implements IHeaders { * @param name Name. */ public delete(name: string): void { - delete this._entries[name.toLowerCase()]; + delete this[PropertySymbol.entries][name.toLowerCase()]; } /** @@ -72,7 +73,7 @@ export default class Headers implements IHeaders { * @returns Value. */ public get(name: string): string | null { - return this._entries[name.toLowerCase()]?.value || null; + return this[PropertySymbol.entries][name.toLowerCase()]?.value || null; } /** @@ -82,7 +83,7 @@ export default class Headers implements IHeaders { * @param value Value. */ public set(name: string, value: string): void { - this._entries[name.toLowerCase()] = { + this[PropertySymbol.entries][name.toLowerCase()] = { name, value }; @@ -95,7 +96,7 @@ export default class Headers implements IHeaders { * @returns "true" if the Headers object contains the key. */ public has(name: string): boolean { - return !!this._entries[name.toLowerCase()]; + return !!this[PropertySymbol.entries][name.toLowerCase()]; } /** @@ -104,8 +105,8 @@ export default class Headers implements IHeaders { * @param callback Callback. */ public forEach(callback: (name: string, value: string, thisArg: IHeaders) => void): void { - for (const key of Object.keys(this._entries)) { - callback(this._entries[key].value, this._entries[key].name, this); + for (const header of Object.values(this[PropertySymbol.entries])) { + callback(header.value, header.name, this); } } @@ -115,7 +116,7 @@ export default class Headers implements IHeaders { * @returns Iterator. */ public *keys(): IterableIterator { - for (const header of Object.values(this._entries)) { + for (const header of Object.values(this[PropertySymbol.entries])) { yield header.name; } } @@ -126,7 +127,7 @@ export default class Headers implements IHeaders { * @returns Iterator. */ public *values(): IterableIterator { - for (const header of Object.values(this._entries)) { + for (const header of Object.values(this[PropertySymbol.entries])) { yield header.value; } } @@ -137,7 +138,7 @@ export default class Headers implements IHeaders { * @returns Iterator. */ public *entries(): IterableIterator<[string, string]> { - for (const header of Object.values(this._entries)) { + for (const header of Object.values(this[PropertySymbol.entries])) { yield [header.name, header.value]; } } @@ -148,7 +149,7 @@ export default class Headers implements IHeaders { * @returns Iterator. */ public *[Symbol.iterator](): IterableIterator<[string, string]> { - for (const header of Object.values(this._entries)) { + for (const header of Object.values(this[PropertySymbol.entries])) { yield [header.name, header.value]; } } diff --git a/packages/happy-dom/src/fetch/Request.ts b/packages/happy-dom/src/fetch/Request.ts index fbefbe0b2..5b3451449 100644 --- a/packages/happy-dom/src/fetch/Request.ts +++ b/packages/happy-dom/src/fetch/Request.ts @@ -1,4 +1,5 @@ import IBlob from '../file/IBlob.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IDocument from '../nodes/document/IDocument.js'; import IRequestInit from './types/IRequestInit.js'; import URL from '../url/URL.js'; @@ -20,6 +21,8 @@ import FetchRequestHeaderUtility from './utilities/FetchRequestHeaderUtility.js' import IRequestCredentials from './types/IRequestCredentials.js'; import FormData from '../form-data/FormData.js'; import MultipartFormDataParser from './multipart/MultipartFormDataParser.js'; +import AsyncTaskManager from '../async-task-manager/AsyncTaskManager.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; /** * Fetch request. @@ -30,10 +33,6 @@ import MultipartFormDataParser from './multipart/MultipartFormDataParser.js'; * @see https://fetch.spec.whatwg.org/#request-class */ export default class Request implements IRequest { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; - public readonly _ownerDocument: IDocument = null; - // Public properties public readonly method: string; public readonly body: Stream.Readable | null; @@ -45,20 +44,32 @@ export default class Request implements IRequest { public readonly credentials: IRequestCredentials; // Internal properties - public readonly _contentLength: number | null = null; - public readonly _contentType: string | null = null; - public _referrer: '' | 'no-referrer' | 'client' | URL = 'client'; - public readonly _url: URL; - public readonly _bodyBuffer: Buffer | null; + public [PropertySymbol.contentLength]: number | null = null; + public [PropertySymbol.contentType]: string | null = null; + public [PropertySymbol.referrer]: '' | 'no-referrer' | 'client' | URL = 'client'; + public [PropertySymbol.url]: URL; + public [PropertySymbol.bodyBuffer]: Buffer | null; + + // Private properties + readonly #window: IBrowserWindow; + readonly #asyncTaskManager: AsyncTaskManager; /** * Constructor. * + * @param injected Injected properties. + * @param injected.window * @param input Input. + * @param injected.asyncTaskManager * @param [init] Init. */ - constructor(input: IRequestInfo, init?: IRequestInit) { - this._ownerDocument = (this.constructor)._ownerDocument; + constructor( + injected: { window: IBrowserWindow; asyncTaskManager: AsyncTaskManager }, + input: IRequestInfo, + init?: IRequestInit + ) { + this.#window = injected.window; + this.#asyncTaskManager = injected.asyncTaskManager; if (!input) { throw new TypeError(`Failed to contruct 'Request': 1 argument required, only 0 present.`); @@ -67,12 +78,12 @@ export default class Request implements IRequest { this.method = (init?.method || (input).method || 'GET').toUpperCase(); const { stream, buffer, contentType, contentLength } = FetchBodyUtility.getBodyStream( - input instanceof Request && (input._bodyBuffer || input.body) - ? input._bodyBuffer || FetchBodyUtility.cloneRequestBodyStream(input) + input instanceof Request && (input[PropertySymbol.bodyBuffer] || input.body) + ? input[PropertySymbol.bodyBuffer] || FetchBodyUtility.cloneBodyStream(input) : init?.body ); - this._bodyBuffer = buffer; + this[PropertySymbol.bodyBuffer] = buffer; this.body = stream; this.credentials = init?.credentials || (input).credentials || 'same-origin'; this.headers = new Headers(init?.headers || (input).headers || {}); @@ -80,18 +91,18 @@ export default class Request implements IRequest { FetchRequestHeaderUtility.removeForbiddenHeaders(this.headers); if (contentLength) { - this._contentLength = contentLength; + this[PropertySymbol.contentLength] = contentLength; } else if (!this.body && (this.method === 'POST' || this.method === 'PUT')) { - this._contentLength = 0; + this[PropertySymbol.contentLength] = 0; } if (contentType) { if (!this.headers.has('Content-Type')) { this.headers.set('Content-Type', contentType); } - this._contentType = contentType; - } else if (input instanceof Request && input._contentType) { - this._contentType = input._contentType; + this[PropertySymbol.contentType] = contentType; + } else if (input instanceof Request && input[PropertySymbol.contentType]) { + this[PropertySymbol.contentType] = input[PropertySymbol.contentType]; } this.redirect = init?.redirect || (input).redirect || 'follow'; @@ -99,28 +110,28 @@ export default class Request implements IRequest { (init?.referrerPolicy || (input).referrerPolicy || '').toLowerCase() ); this.signal = init?.signal || (input).signal || new AbortSignal(); - this._referrer = FetchRequestReferrerUtility.getInitialReferrer( - this._ownerDocument, + this[PropertySymbol.referrer] = FetchRequestReferrerUtility.getInitialReferrer( + injected.window, init?.referrer !== null && init?.referrer !== undefined ? init?.referrer : (input).referrer ); if (input instanceof URL) { - this._url = input; + this[PropertySymbol.url] = input; } else { try { if (input instanceof Request && input.url) { - this._url = new URL(input.url, this._ownerDocument.location); + this[PropertySymbol.url] = new URL(input.url, injected.window.location); } else { - this._url = new URL(input, this._ownerDocument.location); + this[PropertySymbol.url] = new URL(input, injected.window.location); } } catch (error) { throw new DOMException( `Failed to construct 'Request. Invalid URL "${input}" on document location '${ - this._ownerDocument.location + injected.window.location }'.${ - this._ownerDocument.location.origin === 'null' + injected.window.location.origin === 'null' ? ' Relative URLs are not permitted on current document location.' : '' }`, @@ -129,27 +140,35 @@ export default class Request implements IRequest { } } + FetchRequestValidationUtility.validateMethod(this); FetchRequestValidationUtility.validateBody(this); - FetchRequestValidationUtility.validateURL(this._url); + FetchRequestValidationUtility.validateURL(this[PropertySymbol.url]); FetchRequestValidationUtility.validateReferrerPolicy(this.referrerPolicy); FetchRequestValidationUtility.validateRedirect(this.redirect); } + /** + * Returns owner document. + */ + protected get [PropertySymbol.ownerDocument](): IDocument { + throw new Error('[PropertySymbol.ownerDocument] getter needs to be implemented by sub-class.'); + } + /** * Returns referrer. * * @returns Referrer. */ public get referrer(): string { - if (!this._referrer || this._referrer === 'no-referrer') { + if (!this[PropertySymbol.referrer] || this[PropertySymbol.referrer] === 'no-referrer') { return ''; } - if (this._referrer === 'client') { + if (this[PropertySymbol.referrer] === 'client') { return 'about:client'; } - return this._referrer.toString(); + return this[PropertySymbol.referrer].toString(); } /** @@ -158,7 +177,7 @@ export default class Request implements IRequest { * @returns URL. */ public get url(): string { - return this._url.href; + return this[PropertySymbol.url].href; } /** @@ -185,18 +204,17 @@ export default class Request implements IRequest { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(() => this.signal._abort()); + const taskID = this.#asyncTaskManager.startTask(() => this.signal[PropertySymbol.abort]()); let buffer: Buffer; try { buffer = await FetchBodyUtility.consumeBodyStream(this.body); } catch (error) { - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); throw error; } - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } @@ -207,7 +225,7 @@ export default class Request implements IRequest { * @returns Blob. */ public async blob(): Promise { - const type = this.headers.get('content-type') || ''; + const type = this.headers.get('Content-Type') || ''; const buffer = await this.arrayBuffer(); return new Blob([buffer], { type }); @@ -228,18 +246,17 @@ export default class Request implements IRequest { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(() => this.signal._abort()); + const taskID = this.#asyncTaskManager.startTask(() => this.signal[PropertySymbol.abort]()); let buffer: Buffer; try { buffer = await FetchBodyUtility.consumeBodyStream(this.body); } catch (error) { - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); throw error; } - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); return buffer; } @@ -259,18 +276,17 @@ export default class Request implements IRequest { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(() => this.signal._abort()); + const taskID = this.#asyncTaskManager.startTask(() => this.signal[PropertySymbol.abort]()); let buffer: Buffer; try { buffer = await FetchBodyUtility.consumeBodyStream(this.body); } catch (error) { - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); throw error; } - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); return new TextDecoder().decode(buffer); } @@ -300,19 +316,18 @@ export default class Request implements IRequest { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(() => this.signal._abort()); + const taskID = this.#asyncTaskManager.startTask(() => this.signal[PropertySymbol.abort]()); let formData: FormData; try { - const type = this._contentType; - formData = await MultipartFormDataParser.streamToFormData(this.body, type); + const type = this[PropertySymbol.contentType]; + formData = (await MultipartFormDataParser.streamToFormData(this.body, type)).formData; } catch (error) { - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); throw error; } - taskManager.endTask(taskID); + this.#asyncTaskManager.endTask(taskID); return formData; } @@ -322,7 +337,7 @@ export default class Request implements IRequest { * * @returns Clone. */ - public clone(): IRequest { - return new Request(this); + public clone(): Request { + return new this.#window.Request(this); } } diff --git a/packages/happy-dom/src/fetch/ResourceFetch.ts b/packages/happy-dom/src/fetch/ResourceFetch.ts index 7621d94a6..f296eb416 100644 --- a/packages/happy-dom/src/fetch/ResourceFetch.ts +++ b/packages/happy-dom/src/fetch/ResourceFetch.ts @@ -1,49 +1,79 @@ import DOMException from '../exception/DOMException.js'; -import IDocument from '../nodes/document/IDocument.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import URL from '../url/URL.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import Fetch from './Fetch.js'; +import SyncFetch from './SyncFetch.js'; /** * Helper class for performing fetch of resources. */ export default class ResourceFetch { + private window: IBrowserWindow; + #browserFrame: IBrowserFrame; + + /** + * Constructor. + * + * @param options Options. + * @param options.browserFrame Browser frame. + * @param options.window Window. + */ + constructor(options: { browserFrame: IBrowserFrame; window: IBrowserWindow }) { + this.#browserFrame = options.browserFrame; + this.window = options.window; + } + /** * Returns resource data asynchronously. * - * @param document Document. * @param url URL. * @returns Response. */ - public static async fetch(document: IDocument, url: string): Promise { - const response = await document.defaultView.fetch(url); + public async fetch(url: string): Promise { + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.window, + url, + disableCrossOriginPolicy: true + }); + const response = await fetch.send(); + if (!response.ok) { throw new DOMException( - `Failed to perform request to "${url}". Status code: ${response.status}` + `Failed to perform request to "${new URL(url, this.window.location.href).href}". Status ${ + response.status + } ${response.statusText}.` ); } + return await response.text(); } /** * Returns resource data synchronously. * - * @param document Document. * @param url URL. * @returns Response. */ - public static fetchSync(document: IDocument, url: string): string { - // We want to only load SyncRequest when it is needed to improve performance and not have direct dependencies to server side packages. - const absoluteURL = new URL(url, document.defaultView.location).href; + public fetchSync(url: string): string { + const fetch = new SyncFetch({ + browserFrame: this.#browserFrame, + window: this.window, + url, + disableCrossOriginPolicy: true + }); - const xhr = new document.defaultView.XMLHttpRequest(); - xhr.open('GET', absoluteURL, false); - xhr.send(); + const response = fetch.send(); - if (xhr.status !== 200) { + if (!response.ok) { throw new DOMException( - `Failed to perform request to "${absoluteURL}". Status code: ${xhr.status}` + `Failed to perform request to "${new URL(url, this.window.location.href).href}". Status ${ + response.status + } ${response.statusText}.` ); } - return xhr.responseText; + return response.body.toString(); } } diff --git a/packages/happy-dom/src/fetch/Response.ts b/packages/happy-dom/src/fetch/Response.ts index 72b19d2ae..078b53386 100644 --- a/packages/happy-dom/src/fetch/Response.ts +++ b/packages/happy-dom/src/fetch/Response.ts @@ -1,6 +1,6 @@ import IResponse from './types/IResponse.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IBlob from '../file/IBlob.js'; -import IDocument from '../nodes/document/IDocument.js'; import IResponseInit from './types/IResponseInit.js'; import IResponseBody from './types/IResponseBody.js'; import Headers from './Headers.js'; @@ -15,6 +15,9 @@ import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import { TextDecoder } from 'util'; import MultipartFormDataParser from './multipart/MultipartFormDataParser.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import ICachedResponse from './cache/response/ICachedResponse.js'; const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; @@ -27,9 +30,8 @@ const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; * @see https://developer.mozilla.org/en-US/docs/Web/API/Response/Response */ export default class Response implements IResponse { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; - public readonly _ownerDocument: IDocument = null; + // Needs to be injected by sub-class. + protected static [PropertySymbol.window]: IBrowserWindow; // Public properties public readonly body: Stream.Readable | null = null; @@ -42,26 +44,45 @@ export default class Response implements IResponse { public readonly statusText: string; public readonly ok: boolean; public readonly headers: IHeaders; + public [PropertySymbol.cachedResponse]: ICachedResponse | null = null; + public readonly [PropertySymbol.buffer]: Buffer | null = null; + readonly #window: IBrowserWindow; + readonly #browserFrame: IBrowserFrame; /** * Constructor. * + * @param injected Injected properties. * @param input Input. + * @param injected.window * @param body + * @param injected.browserFrame * @param [init] Init. */ - constructor(body?: IResponseBody, init?: IResponseInit) { - this._ownerDocument = (this.constructor)._ownerDocument; - + constructor( + injected: { window: IBrowserWindow; browserFrame: IBrowserFrame }, + body?: IResponseBody, + init?: IResponseInit + ) { + this.#window = injected.window; + this.#browserFrame = injected.browserFrame; this.status = init?.status !== undefined ? init.status : 200; this.statusText = init?.statusText || ''; this.ok = this.status >= 200 && this.status < 300; this.headers = new Headers(init?.headers); + // "Set-Cookie" and "Set-Cookie2" are not allowed in response headers according to spec. + this.headers.delete('Set-Cookie'); + this.headers.delete('Set-Cookie2'); + if (body) { - const { stream, contentType } = FetchBodyUtility.getBodyStream(body); + const { stream, buffer, contentType } = FetchBodyUtility.getBodyStream(body); this.body = stream; + if (buffer) { + this[PropertySymbol.buffer] = buffer; + } + if (contentType && !this.headers.has('Content-Type')) { this.headers.set('Content-Type', contentType); } @@ -92,18 +113,22 @@ export default class Response implements IResponse { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(); - let buffer: Buffer; + let buffer: Buffer | null = this[PropertySymbol.buffer]; - try { - buffer = await FetchBodyUtility.consumeBodyStream(this.body); - } catch (error) { - taskManager.endTask(taskID); - throw error; + if (!buffer) { + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); + + try { + buffer = await FetchBodyUtility.consumeBodyStream(this.body); + } catch (error) { + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + throw error; + } + + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); } - taskManager.endTask(taskID); + this.#storeBodyInCache(buffer); return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } @@ -114,7 +139,7 @@ export default class Response implements IResponse { * @returns Blob. */ public async blob(): Promise { - const type = this.headers.get('content-type') || ''; + const type = this.headers.get('Content-Type') || ''; const buffer = await this.arrayBuffer(); return new Blob([buffer], { type }); @@ -135,18 +160,20 @@ export default class Response implements IResponse { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(); - let buffer: Buffer; + let buffer: Buffer | null = this[PropertySymbol.buffer]; - try { - buffer = await FetchBodyUtility.consumeBodyStream(this.body); - } catch (error) { - taskManager.endTask(taskID); - throw error; + if (!buffer) { + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); + try { + buffer = await FetchBodyUtility.consumeBodyStream(this.body); + } catch (error) { + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + throw error; + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); } - taskManager.endTask(taskID); + this.#storeBodyInCache(buffer); return buffer; } @@ -166,18 +193,20 @@ export default class Response implements IResponse { (this.bodyUsed) = true; - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(); - let buffer: Buffer; + let buffer: Buffer | null = this[PropertySymbol.buffer]; - try { - buffer = await FetchBodyUtility.consumeBodyStream(this.body); - } catch (error) { - taskManager.endTask(taskID); - throw error; + if (!buffer) { + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); + try { + buffer = await FetchBodyUtility.consumeBodyStream(this.body); + } catch (error) { + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + throw error; + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); } - taskManager.endTask(taskID); + this.#storeBodyInCache(buffer); return new TextDecoder().decode(buffer); } @@ -198,42 +227,36 @@ export default class Response implements IResponse { * @returns Form data. */ public async formData(): Promise { - const contentType = this.headers.get('content-type'); - const taskManager = this._ownerDocument.defaultView.happyDOM.asyncTaskManager; - const taskID = taskManager.startTask(); + const contentType = this.headers.get('Content-Type'); - if (contentType.startsWith('application/x-www-form-urlencoded')) { + if (contentType?.startsWith('application/x-www-form-urlencoded')) { const formData = new FormData(); - let text: string; - - try { - text = await this.text(); - } catch (error) { - taskManager.endTask(taskID); - throw error; - } - + const text = await this.text(); const parameters = new URLSearchParams(text); for (const [name, value] of parameters) { formData.append(name, value); } - taskManager.endTask(taskID); - return formData; } + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(); let formData: FormData; + let buffer: Buffer; try { - formData = await MultipartFormDataParser.streamToFormData(this.body, contentType); + const result = await MultipartFormDataParser.streamToFormData(this.body, contentType); + formData = result.formData; + buffer = result.buffer; } catch (error) { - taskManager.endTask(taskID); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); throw error; } - taskManager.endTask(taskID); + this.#storeBodyInCache(buffer); + + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); return formData; } @@ -243,21 +266,36 @@ export default class Response implements IResponse { * * @returns Clone. */ - public clone(): IResponse { - const response = new Response(); + public clone(): Response { + const response = new this.#window.Response(this.body, { + status: this.status, + statusText: this.statusText, + headers: this.headers + }); (response.status) = this.status; (response.statusText) = this.statusText; (response.ok) = this.ok; - (response.headers) = new Headers(this.headers); - (response.body) = this.body; (response.bodyUsed) = this.bodyUsed; (response.redirected) = this.redirected; (response.type) = this.type; (response.url) = this.url; - return response; + return response; + } + + /** + * Stores body in cache. + * + * @param buffer Buffer. + */ + #storeBodyInCache(buffer: Buffer): void { + if (this[PropertySymbol.cachedResponse]?.response?.waitingForBody) { + this[PropertySymbol.cachedResponse].response.body = buffer; + this[PropertySymbol.cachedResponse].response.waitingForBody = false; + } } + /** * Returns a redirect response. * @@ -265,7 +303,7 @@ export default class Response implements IResponse { * @param status Status code. * @returns Response. */ - public static redirect(url: string, status = 302): IResponse { + public static redirect(url: string, status = 302): Response { if (!REDIRECT_STATUS_CODES.includes(status)) { throw new DOMException( 'Failed to create redirect response: Invalid redirect status code.', @@ -273,7 +311,7 @@ export default class Response implements IResponse { ); } - return new Response(null, { + return new this[PropertySymbol.window].Response(null, { headers: { location: new URL(url).toString() }, @@ -288,8 +326,8 @@ export default class Response implements IResponse { * @param status Status code. * @returns Response. */ - public static error(): IResponse { - const response = new Response(null, { status: 0, statusText: '' }); + public static error(): Response { + const response = new this[PropertySymbol.window].Response(null, { status: 0, statusText: '' }); (response.type) = 'error'; return response; } @@ -297,24 +335,25 @@ export default class Response implements IResponse { /** * Returns an JSON response. * + * @param injected Injected properties. * @param data Data. * @param [init] Init. * @returns Response. */ - public static json(data: object, init?: IResponseInit): IResponse { + public static json(data: object, init?: IResponseInit): Response { const body = JSON.stringify(data); if (body === undefined) { throw new TypeError('data is not JSON serializable'); } - const headers = new Headers(init && init.headers); + const headers = new this[PropertySymbol.window].Headers(init && init.headers); - if (!headers.has('content-type')) { - headers.set('content-type', 'application/json'); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); } - return new Response(body, { + return new this[PropertySymbol.window].Response(body, { status: 200, ...init, headers diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts new file mode 100644 index 000000000..5d3ba09cf --- /dev/null +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -0,0 +1,558 @@ +import IRequestInit from './types/IRequestInit.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import IRequestInfo from './types/IRequestInfo.js'; +import DOMException from '../exception/DOMException.js'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; +import URL from '../url/URL.js'; +import Request from './Request.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import ChildProcess from 'child_process'; +import ISyncResponse from './types/ISyncResponse.js'; +import Headers from './Headers.js'; +import CachedResponseStateEnum from './cache/response/CachedResponseStateEnum.js'; +import FetchRequestReferrerUtility from './utilities/FetchRequestReferrerUtility.js'; +import FetchRequestValidationUtility from './utilities/FetchRequestValidationUtility.js'; +import DataURIParser from './data-uri/DataURIParser.js'; +import SyncFetchScriptBuilder from './utilities/SyncFetchScriptBuilder.js'; +import FetchRequestHeaderUtility from './utilities/FetchRequestHeaderUtility.js'; +import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.js'; +import Zlib from 'zlib'; +import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js'; +import FetchCORSUtility from './utilities/FetchCORSUtility.js'; +import Fetch from './Fetch.js'; + +interface ISyncHTTPResponse { + error: string; + incomingMessage: { + statusCode: number; + statusMessage: string; + rawHeaders: string[]; + data: string; + }; +} + +/** + * Handles synchrounous fetch requests. + */ +export default class SyncFetch { + private request: Request; + private redirectCount = 0; + private disableCache: boolean; + private disableCrossOriginPolicy: boolean; + #browserFrame: IBrowserFrame; + #window: IBrowserWindow; + + /** + * Constructor. + * + * @param options Options. + * @param options.browserFrame Browser frame. + * @param options.window Window. + * @param options.url URL. + * @param [options.init] Init. + * @param [options.redirectCount] Redirect count. + * @param [options.contentType] Content Type. + * @param [options.disableCache] Disables the use of cached responses. It will still store the response in the cache. + * @param [options.disableCrossOriginPolicy] Disables the Cross-Origin policy. + */ + constructor(options: { + browserFrame: IBrowserFrame; + window: IBrowserWindow; + url: IRequestInfo; + init?: IRequestInit; + redirectCount?: number; + contentType?: string; + disableCache?: boolean; + disableCrossOriginPolicy?: boolean; + }) { + this.#browserFrame = options.browserFrame; + this.#window = options.window; + this.request = + typeof options.url === 'string' || options.url instanceof URL + ? new options.browserFrame.window.Request(options.url, options.init) + : options.url; + if (options.contentType) { + (this.request[PropertySymbol.contentType]) = options.contentType; + } + this.redirectCount = options.redirectCount ?? 0; + this.disableCache = options.disableCache ?? false; + this.disableCrossOriginPolicy = options.disableCrossOriginPolicy ?? false; + } + + /** + * Sends request. + * + * @returns Response. + */ + public send(): ISyncResponse { + FetchRequestReferrerUtility.prepareRequest(this.#window.location, this.request); + FetchRequestValidationUtility.validateSchema(this.request); + + if (this.request.signal.aborted) { + throw new DOMException('The operation was aborted.', DOMExceptionNameEnum.abortError); + } + + if (this.request[PropertySymbol.url].protocol === 'data:') { + const result = DataURIParser.parse(this.request.url); + return { + status: 200, + statusText: 'OK', + ok: true, + url: this.request.url, + redirected: false, + headers: new Headers({ 'Content-Type': result.type }), + body: result.buffer + }; + } + + // Security check for "https" to "http" requests. + if ( + this.request[PropertySymbol.url].protocol === 'http:' && + this.#window.location.protocol === 'https:' + ) { + throw new DOMException( + `Mixed Content: The page at '${ + this.#window.location.href + }' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${ + this.request.url + }'. This request has been blocked; the content must be served over HTTPS.`, + DOMExceptionNameEnum.securityError + ); + } + + const cachedResponse = this.getCachedResponse(); + + if (cachedResponse) { + return cachedResponse; + } + + if (!this.compliesWithCrossOriginPolicy()) { + this.#window.console.warn( + `Cross-Origin Request Blocked: The Same Origin Policy dissallows reading the remote resource at "${this.request.url}".` + ); + throw new DOMException( + `Cross-Origin Request Blocked: The Same Origin Policy dissallows reading the remote resource at "${this.request.url}".`, + DOMExceptionNameEnum.networkError + ); + } + + return this.sendRequest(); + } + + /** + * Returns cached response. + * + * @returns Response. + */ + public getCachedResponse(): ISyncResponse | null { + if (this.disableCache) { + return null; + } + + let cachedResponse = this.#browserFrame.page.context.responseCache.get(this.request); + + if (!cachedResponse || cachedResponse.response.waitingForBody) { + return null; + } + + if (cachedResponse.state === CachedResponseStateEnum.stale) { + const headers = new Headers(cachedResponse.request.headers); + + if (cachedResponse.etag) { + headers.set('If-None-Match', cachedResponse.etag); + } else { + if (!cachedResponse.lastModified) { + return null; + } + headers.set('If-Modified-Since', new Date(cachedResponse.lastModified).toUTCString()); + } + + if (cachedResponse.etag || !cachedResponse.staleWhileRevalidate) { + const fetch = new SyncFetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.request.url, + init: { headers, method: cachedResponse.request.method }, + disableCache: true, + disableCrossOriginPolicy: true + }); + + const validateResponse = fetch.send(); + const body = validateResponse.status !== 304 ? validateResponse.body : null; + + cachedResponse = this.#browserFrame.page.context.responseCache.add(this.request, { + ...validateResponse, + body, + waitingForBody: false + }); + + if (validateResponse.status !== 304) { + return validateResponse; + } + } else { + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.request.url, + init: { headers, method: cachedResponse.request.method }, + disableCache: true, + disableCrossOriginPolicy: true + }); + fetch.send().then((response) => { + response.buffer().then((body: Buffer) => { + this.#browserFrame.page.context.responseCache.add(this.request, { + ...response, + body, + waitingForBody: false + }); + }); + }); + } + } + + if (!cachedResponse || cachedResponse.response.waitingForBody) { + return null; + } + + return { + status: cachedResponse.response.status, + statusText: cachedResponse.response.statusText, + ok: true, + url: cachedResponse.response.url, + // TODO: Do we need to add support for redirected responses to the cache? + redirected: false, + headers: cachedResponse.response.headers, + body: cachedResponse.response.body + }; + } + + /** + * Checks if the request complies with the Cross-Origin policy. + * + * @returns True if it complies with the policy. + */ + private compliesWithCrossOriginPolicy(): boolean { + if ( + this.disableCrossOriginPolicy || + !FetchCORSUtility.isCORS(this.#window.location, this.request[PropertySymbol.url]) + ) { + return true; + } + + const cachedPreflightResponse = this.#browserFrame.page.context.preflightResponseCache.get( + this.request + ); + + if (cachedPreflightResponse) { + if ( + cachedPreflightResponse.allowOrigin !== '*' && + cachedPreflightResponse.allowOrigin !== this.#window.location.origin + ) { + return false; + } + + if ( + cachedPreflightResponse.allowMethods.length !== 0 && + !cachedPreflightResponse.allowMethods.includes(this.request.method) + ) { + return false; + } + + return true; + } + + const requestHeaders = []; + + for (const [header] of this.request.headers) { + requestHeaders.push(header); + } + + const fetch = new SyncFetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.request.url, + init: { + method: 'OPTIONS', + headers: new Headers({ + 'Access-Control-Request-Method': this.request.method, + 'Access-Control-Request-Headers': requestHeaders.join(', ') + }) + }, + disableCache: true, + disableCrossOriginPolicy: true + }); + + const response = fetch.send(); + + if (!response.ok) { + return false; + } + + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + + if (!allowOrigin) { + return false; + } + + if (allowOrigin !== '*' && allowOrigin !== this.#window.location.origin) { + return false; + } + + const allowMethods: string[] = []; + + if (response.headers.has('Access-Control-Allow-Methods')) { + const allowMethodsHeader = response.headers.get('Access-Control-Allow-Methods'); + if (allowMethodsHeader !== '*') { + for (const method of allowMethodsHeader.split(',')) { + allowMethods.push(method.trim().toUpperCase()); + } + } + } + + if (allowMethods.length !== 0 && !allowMethods.includes(this.request.method)) { + return false; + } + + // TODO: Add support for more Access-Control-Allow-* headers. + + return true; + } + + /** + * Sends request. + * + * @returns Response. + */ + public sendRequest(): ISyncResponse { + if (!this.request[PropertySymbol.bodyBuffer] && this.request.body) { + throw new DOMException( + `Streams are not supported as request body for synchrounous requests.`, + DOMExceptionNameEnum.notSupportedError + ); + } + + const script = SyncFetchScriptBuilder.getScript({ + url: this.request[PropertySymbol.url], + method: this.request.method, + headers: FetchRequestHeaderUtility.getRequestHeaders({ + browserFrame: this.#browserFrame, + window: this.#window, + request: this.request + }), + body: this.request[PropertySymbol.bodyBuffer] + }); + + // Start the other Node Process, executing this string + const content = ChildProcess.execFileSync(process.argv[0], ['-e', script], { + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB. + }); + + // If content length is 0, then there was an error + if (!content.length) { + throw new DOMException( + `Synchronous fetch to "${this.request.url}" failed.`, + DOMExceptionNameEnum.networkError + ); + } + + const { error, incomingMessage } = JSON.parse(content.toString()); + + if (error) { + throw new DOMException( + `Synchronous fetch to "${this.request.url}" failed. Error: ${error}`, + DOMExceptionNameEnum.networkError + ); + } + + const headers = FetchResponseHeaderUtility.parseResponseHeaders({ + browserFrame: this.#browserFrame, + requestURL: this.request[PropertySymbol.url], + rawHeaders: incomingMessage.rawHeaders + }); + + const response: ISyncResponse = { + status: incomingMessage.statusCode, + statusText: incomingMessage.statusMessage, + ok: incomingMessage.statusCode >= 200 && incomingMessage.statusCode < 300, + url: this.request.url, + redirected: this.redirectCount > 0, + headers, + body: this.parseResponseBody({ + headers, + status: incomingMessage.statusCode, + body: Buffer.from(incomingMessage.data, 'base64') + }) + }; + + const redirectedResponse = this.handleRedirectResponse(response) || response; + + if (!this.disableCache && !redirectedResponse.redirected) { + this.#browserFrame.page.context.responseCache.add(this.request, { + status: redirectedResponse.status, + statusText: redirectedResponse.statusText, + url: redirectedResponse.url, + headers: redirectedResponse.headers, + body: redirectedResponse.body, + waitingForBody: false + }); + } + + return redirectedResponse; + } + + /** + * Parses response body. + * + * @param options Options. + * @param options.headers Headers. + * @param options.status Status. + * @param options.body Body. + * @returns Parsed body. + */ + private parseResponseBody(options: { headers: Headers; status: number; body: Buffer }): Buffer { + const contentEncodingHeader = options.headers.get('Content-Encoding'); + + if ( + this.request.method === 'HEAD' || + contentEncodingHeader === null || + options.status === 204 || + options.status === 304 + ) { + return options.body; + } + + try { + // For GZip + if (contentEncodingHeader === 'gzip' || contentEncodingHeader === 'x-gzip') { + // Be less strict when decoding compressed responses by using Z_SYNC_FLUSH. + // Sometimes servers send slightly invalid responses that are still accepted by common browsers. + // "cURL" always uses Z_SYNC_FLUSH. + return Zlib.gunzipSync(options.body, { + flush: Zlib.constants.Z_SYNC_FLUSH, + finishFlush: Zlib.constants.Z_SYNC_FLUSH + }); + } + + // For Deflate + if (contentEncodingHeader === 'deflate' || contentEncodingHeader === 'x-deflate') { + return Zlib.inflateSync(options.body); + } + + // For BR + if (contentEncodingHeader === 'br') { + return Zlib.brotliDecompressSync(options.body); + } + } catch (error) { + throw new DOMException( + `Failed to read response body. Error: ${error.message}.`, + DOMExceptionNameEnum.encodingError + ); + } + + return options.body; + } + + /** + * Handles redirect response. + * + * @param response Response. + * @returns Redirected response or null. + */ + private handleRedirectResponse(response: ISyncResponse): ISyncResponse | null { + if (!FetchResponseRedirectUtility.isRedirect(response.status)) { + return null; + } + + switch (this.request.redirect) { + case 'error': + throw new DOMException( + `URI requested responds with a redirect, redirect mode is set to "error": ${this.request.url}`, + DOMExceptionNameEnum.abortError + ); + case 'manual': + return null; + case 'follow': + const locationHeader = response.headers.get('Location'); + const shouldBecomeGetRequest = + response.status === 303 || + ((response.status === 301 || response.status === 302) && this.request.method === 'POST'); + let locationURL: URL = null; + + if (locationHeader !== null) { + try { + locationURL = new URL(locationHeader, this.request.url); + } catch { + throw new DOMException( + `URI requested responds with an invalid redirect URL: ${locationHeader}`, + DOMExceptionNameEnum.uriMismatchError + ); + } + } + + if (locationURL === null) { + return null; + } + + if (FetchResponseRedirectUtility.isMaxRedirectsReached(this.redirectCount)) { + throw new DOMException( + `Maximum redirects reached at: ${this.request.url}`, + DOMExceptionNameEnum.networkError + ); + } + + const headers = new Headers(this.request.headers); + const requestInit: IRequestInit = { + method: this.request.method, + signal: this.request.signal, + referrer: this.request.referrer, + referrerPolicy: this.request.referrerPolicy, + credentials: this.request.credentials, + headers, + body: this.request[PropertySymbol.bodyBuffer] + }; + + if ( + this.request.credentials === 'omit' || + (this.request.credentials === 'same-origin' && + FetchCORSUtility.isCORS(this.#window.location, locationURL)) + ) { + headers.delete('authorization'); + headers.delete('www-authenticate'); + headers.delete('cookie'); + headers.delete('cookie2'); + } + + if (shouldBecomeGetRequest) { + requestInit.method = 'GET'; + requestInit.body = undefined; + headers.delete('Content-Length'); + headers.delete('Content-Type'); + } + + const responseReferrerPolicy = + FetchRequestReferrerUtility.getReferrerPolicyFromHeader(headers); + if (responseReferrerPolicy) { + requestInit.referrerPolicy = responseReferrerPolicy; + } + + const fetch = new SyncFetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: locationURL, + init: requestInit, + redirectCount: this.redirectCount + 1, + contentType: !shouldBecomeGetRequest + ? this.request[PropertySymbol.contentType] + : undefined + }); + + return fetch.send(); + default: + throw new DOMException( + `Redirect option '${this.request.redirect}' is not a valid value of RequestRedirect` + ); + } + } +} diff --git a/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightRequest.ts b/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightRequest.ts new file mode 100644 index 000000000..30b308805 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightRequest.ts @@ -0,0 +1,7 @@ +import IHeaders from '../../types/IHeaders.js'; + +export default interface ICachablePreflightRequest { + url: string; + method: string; + headers: IHeaders; +} diff --git a/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightResponse.ts b/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightResponse.ts new file mode 100644 index 000000000..b978a32d1 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/preflight/ICachablePreflightResponse.ts @@ -0,0 +1,7 @@ +import IHeaders from '../../types/IHeaders.js'; + +export default interface ICachablePreflightResponse { + status: number; + url: string; + headers: IHeaders; +} diff --git a/packages/happy-dom/src/fetch/cache/preflight/ICachedPreflightResponse.ts b/packages/happy-dom/src/fetch/cache/preflight/ICachedPreflightResponse.ts new file mode 100644 index 000000000..c7f46f9a6 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/preflight/ICachedPreflightResponse.ts @@ -0,0 +1,5 @@ +export default interface ICachedPreflightResponse { + allowOrigin: string; + allowMethods: string[]; + expires: number; +} diff --git a/packages/happy-dom/src/fetch/cache/preflight/IPreflightResponseCache.ts b/packages/happy-dom/src/fetch/cache/preflight/IPreflightResponseCache.ts new file mode 100644 index 000000000..129995610 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/preflight/IPreflightResponseCache.ts @@ -0,0 +1,33 @@ +import ICachedPreflightResponse from './ICachedPreflightResponse.js'; +import ICachablePreflightRequest from './ICachablePreflightRequest.js'; +import ICachablePreflightResponse from './ICachablePreflightResponse.js'; + +/** + * Fetch response cache. + */ +export default interface IPreflightResponseCache { + /** + * Returns cached response. + * + * @param request Request. + * @returns Cached response. + */ + get(request: ICachablePreflightRequest): ICachedPreflightResponse | null; + + /** + * Adds a cached response. + * + * @param request Request. + * @param response Response. + * @returns Cached response. + */ + add( + request: ICachablePreflightRequest, + response: ICachablePreflightResponse + ): ICachedPreflightResponse | null; + + /** + * Clears the cache. + */ + clear(): void; +} diff --git a/packages/happy-dom/src/fetch/cache/preflight/PreflightResponseCache.ts b/packages/happy-dom/src/fetch/cache/preflight/PreflightResponseCache.ts new file mode 100644 index 000000000..0e8bd4065 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/preflight/PreflightResponseCache.ts @@ -0,0 +1,94 @@ +import IPreflightResponseCache from './IPreflightResponseCache.js'; +import ICachablePreflightRequest from './ICachablePreflightRequest.js'; +import ICachedPreflightResponse from './ICachedPreflightResponse.js'; +import ICachablePreflightResponse from './ICachablePreflightResponse.js'; + +/** + * Fetch preflight response cache. + * + * @see https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request + */ +export default class PreflightResponseCache implements IPreflightResponseCache { + #entries: { [url: string]: ICachedPreflightResponse } = {}; + + /** + * Returns cached response. + * + * @param request Request. + * @returns Cached response. + */ + public get(request: ICachablePreflightRequest): ICachedPreflightResponse | null { + const cachedResponse = this.#entries[request.url]; + + if (cachedResponse) { + if (cachedResponse.expires < Date.now()) { + delete this.#entries[request.url]; + return null; + } + return cachedResponse; + } + + return null; + } + + /** + * Adds a cache entity. + * + * @param request Request. + * @param response Response. + * @returns Cached response. + */ + public add( + request: ICachablePreflightRequest, + response: ICachablePreflightResponse + ): ICachedPreflightResponse { + delete this.#entries[request.url]; + + if (request.headers.get('Cache-Control')?.includes('no-cache')) { + return null; + } + + if (response.status < 200 || response.status >= 300) { + return null; + } + + const maxAge = response.headers.get('Access-Control-Max-Age'); + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + + if (!maxAge || !allowOrigin) { + return null; + } + + const allowMethods: string[] = []; + + if (response.headers.has('Access-Control-Allow-Methods')) { + const allowMethodsHeader = response.headers.get('Access-Control-Allow-Methods'); + if (allowMethodsHeader !== '*') { + for (const method of response.headers.get('Access-Control-Allow-Methods').split(',')) { + allowMethods.push(method.trim().toUpperCase()); + } + } + } + + const cachedResponse: ICachedPreflightResponse = { + allowOrigin, + allowMethods, + expires: Date.now() + parseInt(maxAge) * 1000 + }; + + if (isNaN(cachedResponse.expires) || cachedResponse.expires < Date.now()) { + return null; + } + + this.#entries[request.url] = cachedResponse; + + return cachedResponse; + } + + /** + * Clears the cache. + */ + public clear(): void { + this.#entries = {}; + } +} diff --git a/packages/happy-dom/src/fetch/cache/response/CachedResponseStateEnum.ts b/packages/happy-dom/src/fetch/cache/response/CachedResponseStateEnum.ts new file mode 100644 index 000000000..ba53848b1 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/CachedResponseStateEnum.ts @@ -0,0 +1,5 @@ +enum CachedResponseStateEnum { + fresh = 'fresh', + stale = 'stale' +} +export default CachedResponseStateEnum; diff --git a/packages/happy-dom/src/fetch/cache/response/ICachableRequest.ts b/packages/happy-dom/src/fetch/cache/response/ICachableRequest.ts new file mode 100644 index 000000000..c83202c58 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/ICachableRequest.ts @@ -0,0 +1,7 @@ +import IHeaders from '../../types/IHeaders.js'; + +export default interface ICachableRequest { + url: string; + method: string; + headers: IHeaders; +} diff --git a/packages/happy-dom/src/fetch/cache/response/ICachableResponse.ts b/packages/happy-dom/src/fetch/cache/response/ICachableResponse.ts new file mode 100644 index 000000000..9737f928b --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/ICachableResponse.ts @@ -0,0 +1,10 @@ +import IHeaders from '../../types/IHeaders.js'; + +export default interface ICachableResponse { + status: number; + statusText: string; + url: string; + headers: IHeaders; + body: Buffer | null; + waitingForBody: boolean; +} diff --git a/packages/happy-dom/src/fetch/cache/response/ICachedResponse.ts b/packages/happy-dom/src/fetch/cache/response/ICachedResponse.ts new file mode 100644 index 000000000..e2de6f669 --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/ICachedResponse.ts @@ -0,0 +1,36 @@ +import CachedResponseStateEnum from './CachedResponseStateEnum.js'; +import IHeaders from '../../types/IHeaders.js'; + +export default interface ICachedResponse { + /** Response. */ + response: { + status: number; + statusText: string; + url: string; + headers: IHeaders; + // We need to wait for the body to be populated if set to "true". + waitingForBody: boolean; + body: Buffer | null; + }; + /** Request. */ + request: { + headers: IHeaders; + method: string; + }; + /** Cache update time in milliseconds. */ + cacheUpdateTime: number; + /** Last modified time in milliseconds. */ + lastModified: number | null; + /** Vary headers. */ + vary: { [header: string]: string }; + /** Expire time in milliseconds. */ + expires: number | null; + /** ETag */ + etag: string | null; + /** Must revalidate using "If-Modified-Since" request when expired. Not supported yet. */ + mustRevalidate: boolean; + /** Stale while revalidate using "If-Modified-Since" request when expired */ + staleWhileRevalidate: boolean; + /** Used when "mustRevalidate" or "staleWhileRevalidate" is true. */ + state: CachedResponseStateEnum; +} diff --git a/packages/happy-dom/src/fetch/cache/response/IResponseCache.ts b/packages/happy-dom/src/fetch/cache/response/IResponseCache.ts new file mode 100644 index 000000000..eef08b8db --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/IResponseCache.ts @@ -0,0 +1,34 @@ +import ICachedResponse from './ICachedResponse.js'; +import ICachableRequest from './ICachableRequest.js'; +import ICachableResponse from './ICachableResponse.js'; + +/** + * Fetch response cache. + */ +export default interface IResponseCache { + /** + * Returns cached response. + * + * @param request Request. + * @returns Cached response. + */ + get(request: ICachableRequest): ICachedResponse | null; + + /** + * Adds a cached response. + * + * @param request Request. + * @param response Response. + * @returns Cached response. + */ + add(request: ICachableRequest, response: ICachableResponse): ICachedResponse | null; + + /** + * Clears the cache. + * + * @param [options] Options. + * @param [options.url] URL. + * @param [options.toTime] Time in MS. + */ + clear(options?: { url?: string; toTime?: number }): void; +} diff --git a/packages/happy-dom/src/fetch/cache/response/ResponseCache.ts b/packages/happy-dom/src/fetch/cache/response/ResponseCache.ts new file mode 100644 index 000000000..213490c0d --- /dev/null +++ b/packages/happy-dom/src/fetch/cache/response/ResponseCache.ts @@ -0,0 +1,221 @@ +import IResponseCache from './IResponseCache.js'; +import ICachedResponse from './ICachedResponse.js'; +import CachedResponseStateEnum from './CachedResponseStateEnum.js'; +import ICachableRequest from './ICachableRequest.js'; +import ICachableResponse from './ICachableResponse.js'; +import Headers from '../../Headers.js'; + +const UPDATE_RESPONSE_HEADERS = ['Cache-Control', 'Last-Modified', 'Vary', 'ETag']; + +/** + * Fetch response cache. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching + * @see https://www.mnot.net/cache_docs/ + */ +export default class ResponseCache implements IResponseCache { + #entries: { [url: string]: ICachedResponse[] } = {}; + + /** + * Returns cached response. + * + * @param request Request. + * @returns Cached response. + */ + public get(request: ICachableRequest): ICachedResponse | null { + if (request.headers.get('Cache-Control')?.includes('no-cache')) { + return null; + } + + const url = request.url; + + if (this.#entries[url]) { + for (let i = 0, max = this.#entries[url].length; i < max; i++) { + const entry = this.#entries[url][i]; + let isMatch = entry.request.method === request.method; + if (isMatch) { + for (const header of Object.keys(entry.vary)) { + const requestHeader = request.headers.get(header); + if (requestHeader !== null && entry.vary[header] !== requestHeader) { + isMatch = false; + break; + } + } + } + if (isMatch) { + if (entry.expires && entry.expires < Date.now()) { + if (entry.lastModified) { + entry.state = CachedResponseStateEnum.stale; + } else if (!entry.etag) { + this.#entries[url].splice(i, 1); + return null; + } + } + return entry; + } + } + } + return null; + } + + /** + * Adds a cache entity. + * + * @param request Request. + * @param response Response. + * @returns Cached response. + */ + public add(request: ICachableRequest, response: ICachableResponse): ICachedResponse { + // We should only cache GET and HEAD requests. + if ( + (request.method !== 'GET' && request.method !== 'HEAD') || + request.headers.get('Cache-Control')?.includes('no-cache') + ) { + return null; + } + + const url = request.url; + let cachedResponse = this.get(request); + + if (response.status === 304) { + if (!cachedResponse) { + throw new Error('ResponseCache: Cached response not found.'); + } + + for (const name of UPDATE_RESPONSE_HEADERS) { + if (response.headers.has(name)) { + cachedResponse.response.headers.set(name, response.headers.get(name)); + } + } + + cachedResponse.cacheUpdateTime = Date.now(); + cachedResponse.state = CachedResponseStateEnum.fresh; + } else { + if (cachedResponse) { + const index = this.#entries[url].indexOf(cachedResponse); + if (index !== -1) { + this.#entries[url].splice(index, 1); + } + } + + cachedResponse = { + response: { + status: response.status, + statusText: response.statusText, + url: response.url, + headers: new Headers(response.headers), + // We need to wait for the body to be consumed and then populated if set to true (e.g. by using Response.text()). + waitingForBody: response.waitingForBody, + body: response.body ?? null + }, + request: { + headers: request.headers, + method: request.method + }, + vary: {}, + expires: null, + etag: null, + cacheUpdateTime: Date.now(), + lastModified: null, + mustRevalidate: false, + staleWhileRevalidate: false, + state: CachedResponseStateEnum.fresh + }; + + this.#entries[url] = this.#entries[url] || []; + this.#entries[url].push(cachedResponse); + } + + if (response.headers.has('Cache-Control')) { + const age = response.headers.get('Age'); + + for (const part of response.headers.get('Cache-Control').split(',')) { + const [key, value] = part.trim().split('='); + switch (key) { + case 'max-age': + cachedResponse.expires = + Date.now() + parseInt(value) * 1000 - (age ? parseInt(age) * 1000 : 0); + break; + case 'no-cache': + case 'no-store': + const index = this.#entries[url].indexOf(cachedResponse); + if (index !== -1) { + this.#entries[url].splice(index, 1); + } + return null; + case 'must-revalidate': + cachedResponse.mustRevalidate = true; + break; + case 'stale-while-revalidate': + cachedResponse.staleWhileRevalidate = true; + break; + } + } + } + + if (response.headers.has('Last-Modified')) { + cachedResponse.lastModified = Date.parse(response.headers.get('Last-Modified')); + } + + if (response.headers.has('Vary')) { + for (const header of response.headers.get('Vary').split(',')) { + const name = header.trim(); + const value = request.headers.get(name); + if (value) { + cachedResponse.vary[name] = value; + } + } + } + + if (response.headers.has('ETag')) { + cachedResponse.etag = response.headers.get('ETag'); + } + + if (!cachedResponse.expires) { + const expires = response.headers.get('Expires'); + if (expires) { + cachedResponse.expires = Date.parse(expires); + } + } + + // Cache is invalid if it has expired and doesn't have an ETag. + if (!cachedResponse.etag && (!cachedResponse.expires || cachedResponse.expires < Date.now())) { + const index = this.#entries[url].indexOf(cachedResponse); + if (index !== -1) { + this.#entries[url].splice(index, 1); + } + return null; + } + + return cachedResponse; + } + + /** + * Clears the cache. + * + * @param [options] Options. + * @param [options.url] URL. + * @param [options.toTime] Removes all entries that are older than this time. Time in MS. + */ + public clear(options?: { url?: string; toTime?: number }): void { + if (options) { + if (options.toTime) { + for (const key of options.url ? [options.url] : Object.keys(this.#entries)) { + if (this.#entries[key]) { + for (let i = 0, max = this.#entries[key].length; i < max; i++) { + if (this.#entries[key][i].cacheUpdateTime < options.toTime) { + this.#entries[key].splice(i, 1); + i--; + max--; + } + } + } + } + } else if (options.url) { + delete this.#entries[options.url]; + } + } else { + this.#entries = {}; + } + } +} diff --git a/packages/happy-dom/src/fetch/certificate/FetchHTTPSCertificate.ts b/packages/happy-dom/src/fetch/certificate/FetchHTTPSCertificate.ts new file mode 100644 index 000000000..af8d7744e --- /dev/null +++ b/packages/happy-dom/src/fetch/certificate/FetchHTTPSCertificate.ts @@ -0,0 +1,52 @@ +// SSL certificate generated for Happy DOM to be able to perform HTTPS requests +export default { + cert: `-----BEGIN CERTIFICATE----- +MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL +BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt +bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy +MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN +YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j +bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 +gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl +q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt +XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q +tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 +YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i +DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L +YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q +MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 +9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l +Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 +Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 +Y3FblSokcA== +-----END CERTIFICATE-----`, + key: `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF +GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 +XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 +bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj +o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 +/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT +6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 +m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ +/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd +NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH +aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo +XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv +FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ +GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 ++VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg +5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu ++CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ +jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo +2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT +PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg +xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL +PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK +M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD +2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 +3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw +gl5OpEjeliU7Mus0BVS858g= +-----END PRIVATE KEY-----` +}; diff --git a/packages/happy-dom/src/fetch/multipart/MultipartFormDataParser.ts b/packages/happy-dom/src/fetch/multipart/MultipartFormDataParser.ts index 3062c3cba..43bcdd0f8 100644 --- a/packages/happy-dom/src/fetch/multipart/MultipartFormDataParser.ts +++ b/packages/happy-dom/src/fetch/multipart/MultipartFormDataParser.ts @@ -1,4 +1,5 @@ import FormData from '../../form-data/FormData.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import Stream from 'stream'; import MultipartReader from './MultipartReader.js'; import DOMException from '../../exception/DOMException.js'; @@ -21,7 +22,7 @@ export default class MultipartFormDataParser { public static async streamToFormData( body: Stream.Readable, contentType: string - ): Promise { + ): Promise<{ formData: FormData; buffer: Buffer }> { if (!/multipart/i.test(contentType)) { throw new DOMException( `Failed to build FormData object: The "content-type" header isn't of type "multipart/form-data".`, @@ -39,12 +40,50 @@ export default class MultipartFormDataParser { } const reader = new MultipartReader(match[1] || match[2]); + const chunks = []; + let buffer: Buffer; + let bytes = 0; - for await (const chunk of body) { - reader.write(chunk); + try { + for await (const chunk of body) { + reader.write(chunk); + bytes += chunk.length; + chunks.push(chunk); + } + } catch (error) { + if (error instanceof DOMException) { + throw error; + } + throw new DOMException( + `Failed to read response body. Error: ${error.message}.`, + DOMExceptionNameEnum.encodingError + ); + } + + if ( + (body).readableEnded === false || + (body)['_readableState']?.ended === false + ) { + throw new DOMException( + `Premature close of server response.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + try { + buffer = + typeof chunks[0] === 'string' ? Buffer.from(chunks.join('')) : Buffer.concat(chunks, bytes); + } catch (error) { + throw new DOMException( + `Could not create Buffer from response body. Error: ${error.message}.`, + DOMExceptionNameEnum.invalidStateError + ); } - return reader.end(); + return { + formData: reader.end(), + buffer + }; } /** @@ -82,7 +121,7 @@ export default class MultipartFormDataParser { )}"\r\nContent-Type: ${value.type || 'application/octet-stream'}\r\n\r\n` ) ); - chunks.push(value._buffer); + chunks.push(value[PropertySymbol.buffer]); chunks.push(Buffer.from('\r\n')); } } diff --git a/packages/happy-dom/src/fetch/types/ISyncResponse.ts b/packages/happy-dom/src/fetch/types/ISyncResponse.ts new file mode 100644 index 000000000..53566ccb1 --- /dev/null +++ b/packages/happy-dom/src/fetch/types/ISyncResponse.ts @@ -0,0 +1,16 @@ +import IHeaders from './IHeaders.js'; + +/** + * Fetch response. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Response/Response + */ +export default interface ISyncResponse { + status: number; + statusText: string; + ok: boolean; + url: string; + redirected: boolean; + headers: IHeaders; + body: Buffer; +} diff --git a/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts index 60a8ef127..1e18c9a38 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts @@ -1,4 +1,5 @@ import MultipartFormDataParser from '../multipart/MultipartFormDataParser.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import Stream from 'stream'; import { URLSearchParams } from 'url'; import FormData from '../../form-data/FormData.js'; @@ -7,7 +8,6 @@ import DOMException from '../../exception/DOMException.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import IRequestBody from '../types/IRequestBody.js'; import IResponseBody from '../types/IResponseBody.js'; -import Request from '../Request.js'; /** * Fetch body utility. @@ -39,7 +39,7 @@ export default class FetchBodyUtility { contentLength: buffer.length }; } else if (body instanceof Blob) { - const buffer = (body)._buffer; + const buffer = (body)[PropertySymbol.buffer]; return { buffer, stream: Stream.Readable.from(buffer), @@ -90,13 +90,21 @@ export default class FetchBodyUtility { } /** - * Clones a request body stream. + * Clones a request or body body stream. * - * @param request Request. - * @returns Stream. + * It is actually not cloning the stream. + * It creates a pass through stream and pipes the original stream to it. + * + * @param requestOrResponse Request or Response. + * @param requestOrResponse.body + * @param requestOrResponse.bodyUsed + * @returns New stream. */ - public static cloneRequestBodyStream(request: Request): Stream.Readable { - if (request.bodyUsed) { + public static cloneBodyStream(requestOrResponse: { + body: Stream.Readable; + bodyUsed: boolean; + }): Stream.Readable { + if (requestOrResponse.bodyUsed) { throw new DOMException( `Failed to clone body stream of request: Request body is already used.`, DOMExceptionNameEnum.invalidStateError @@ -106,11 +114,11 @@ export default class FetchBodyUtility { const p1 = new Stream.PassThrough(); const p2 = new Stream.PassThrough(); - request.body.pipe(p1); - request.body.pipe(p2); + requestOrResponse.body.pipe(p1); + requestOrResponse.body.pipe(p2); - // Sets the body of the cloned request to the first pass through stream. - (request.body) = p1; + // Sets the body of the cloned request/response to the first pass through stream. + (requestOrResponse.body) = p1; // Returns the clone. return p2; diff --git a/packages/happy-dom/src/fetch/utilities/FetchCORSUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchCORSUtility.ts index c15f3daf2..a3c8e75b8 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchCORSUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchCORSUtility.ts @@ -1,3 +1,5 @@ +import { URL } from 'url'; + /** * Fetch CORS utility. */ @@ -8,7 +10,14 @@ export default class FetchCORSUtility { * @param originURL Origin URL. * @param targetURL Target URL. */ - public static isCORS(originURL, targetURL): boolean { + public static isCORS(originURL: URL | string, targetURL: URL | string): boolean { + originURL = typeof originURL === 'string' ? new URL(originURL) : originURL; + targetURL = typeof targetURL === 'string' ? new URL(targetURL) : targetURL; + + if (targetURL.protocol === 'about:' || targetURL.protocol === 'javascript:') { + return false; + } + return ( (originURL.hostname !== targetURL.hostname && !originURL.hostname.endsWith(targetURL.hostname)) || diff --git a/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts index f7bbb3b9f..233263f64 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts @@ -1,5 +1,11 @@ +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import CookieStringUtility from '../../cookie/urilities/CookieStringUtility.js'; import Headers from '../Headers.js'; +import Request from '../Request.js'; import IHeaders from '../types/IHeaders.js'; +import FetchCORSUtility from './FetchCORSUtility.js'; const FORBIDDEN_HEADER_NAMES = [ 'accept-charset', @@ -8,6 +14,7 @@ const FORBIDDEN_HEADER_NAMES = [ 'access-control-request-method', 'connection', 'content-length', + 'content-transfer-encoding', 'cookie', 'cookie2', 'date', @@ -34,14 +41,99 @@ export default class FetchRequestHeaderUtility { * @param headers Headers. */ public static removeForbiddenHeaders(headers: IHeaders): void { - for (const key of Object.keys((headers)._entries)) { + for (const key of Object.keys((headers)[PropertySymbol.entries])) { if ( FORBIDDEN_HEADER_NAMES.includes(key) || key.startsWith('proxy-') || key.startsWith('sec-') ) { - delete (headers)._entries[key]; + delete (headers)[PropertySymbol.entries][key]; } } } + + /** + * Returns "true" if the header is forbidden. + * + * @param name Header name. + * @returns "true" if the header is forbidden. + */ + public static isHeaderForbidden(name: string): boolean { + return FORBIDDEN_HEADER_NAMES.includes(name.toLowerCase()); + } + + /** + * Returns request headers. + * + * @param options Options. + * @param options.browserFrame Browser frame. + * @param options.window Window. + * @param options.request Request. + * @returns Headers. + */ + public static getRequestHeaders(options: { + browserFrame: IBrowserFrame; + window: IBrowserWindow; + request: Request; + }): { [key: string]: string } { + const headers = new Headers(options.request.headers); + const isCORS = FetchCORSUtility.isCORS( + options.window.location, + options.request[PropertySymbol.url] + ); + + // TODO: Maybe we need to add support for OPTIONS request with 'Access-Control-Allow-*' headers? + if ( + options.request.credentials === 'omit' || + (options.request.credentials === 'same-origin' && isCORS) + ) { + headers.delete('authorization'); + headers.delete('www-authenticate'); + } + + headers.set('Accept-Encoding', 'gzip, deflate, br'); + headers.set('Connection', 'close'); + + if (!headers.has('User-Agent')) { + headers.set('User-Agent', options.window.navigator.userAgent); + } + + if (options.request[PropertySymbol.referrer] instanceof URL) { + headers.set('Referer', options.request[PropertySymbol.referrer].href); + } + + if ( + options.request.credentials === 'include' || + (options.request.credentials === 'same-origin' && !isCORS) + ) { + const cookies = options.browserFrame.page.context.cookieContainer.getCookies( + options.window.location, + false + ); + if (cookies.length > 0) { + headers.set('Cookie', CookieStringUtility.cookiesToString(cookies)); + } + } + + if (!headers.has('Accept')) { + headers.set('Accept', '*/*'); + } + + if (!headers.has('Content-Length') && options.request[PropertySymbol.contentLength] !== null) { + headers.set('Content-Length', String(options.request[PropertySymbol.contentLength])); + } + + if (!headers.has('Content-Type') && options.request[PropertySymbol.contentType]) { + headers.set('Content-Type', options.request[PropertySymbol.contentType]); + } + + // We need to convert the headers to Node request headers. + const httpRequestHeaders = {}; + + for (const header of Object.values(headers[PropertySymbol.entries])) { + httpRequestHeaders[header.name] = header.value; + } + + return httpRequestHeaders; + } } diff --git a/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts index 8de395d1e..79c861d96 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchRequestReferrerUtility.ts @@ -1,9 +1,10 @@ import URL from '../../url/URL.js'; -import IRequest from '../types/IRequest.js'; -import IDocument from '../../nodes/document/IDocument.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import { isIP } from 'net'; import Headers from '../Headers.js'; import IRequestReferrerPolicy from '../types/IRequestReferrerPolicy.js'; +import Request from '../Request.js'; const REQUEST_REFERRER_UNSUPPORTED_PROTOCOL_REGEXP = /^(about|blob|data):$/; const REFERRER_POLICIES: IRequestReferrerPolicy[] = [ @@ -22,6 +23,71 @@ const REFERRER_POLICIES: IRequestReferrerPolicy[] = [ * Fetch referrer utility. */ export default class FetchRequestReferrerUtility { + /** + * Prepares the request before being sent. + * + * @param originURL Origin URL. + * @param request Request. + */ + public static prepareRequest(originURL: URL, request: Request): void { + if (!request.referrerPolicy) { + (request.referrerPolicy) = 'strict-origin-when-cross-origin'; + } + + if (request.referrer && request.referrer !== 'no-referrer') { + request[PropertySymbol.referrer] = this.getSentReferrer(originURL, request); + } else { + request[PropertySymbol.referrer] = 'no-referrer'; + } + } + + /** + * Returns initial referrer. + * + * @param window Window. + * @param referrer Referrer. + * @returns Initial referrer. + */ + public static getInitialReferrer( + window: IBrowserWindow, + referrer: '' | 'no-referrer' | 'client' | string | URL + ): '' | 'no-referrer' | 'client' | URL { + if (referrer === '' || referrer === 'no-referrer' || referrer === 'client') { + return referrer; + } else if (referrer) { + const referrerURL = referrer instanceof URL ? referrer : new URL(referrer, window.location); + return referrerURL.origin === window.location.origin ? referrerURL : 'client'; + } + + return 'client'; + } + + /** + * Returns referrer policy from header. + * + * @see https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header + * @param headers Response headers + * @returns Policy. + */ + public static getReferrerPolicyFromHeader(headers: Headers): IRequestReferrerPolicy { + const referrerPolicyHeader = headers.get('Referrer-Policy'); + + if (!referrerPolicyHeader) { + return ''; + } + + const policyTokens = referrerPolicyHeader.split(/[,\s]+/); + let policy: IRequestReferrerPolicy = ''; + + for (const token of policyTokens) { + if (token && REFERRER_POLICIES.includes(token)) { + policy = token; + } + } + + return policy; + } + /** * Returns the request referrer to be used as the value for the "Referer" header. * @@ -29,23 +95,21 @@ export default class FetchRequestReferrerUtility { * https://github.com/node-fetch/node-fetch/blob/main/src/utils/referrer.js (MIT) * * @see https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer - * @param document Document. + * @param originURL Origin URL. * @param request Request. * @returns Request referrer. */ - public static getSentReferrer( - document: IDocument, - request: IRequest + private static getSentReferrer( + originURL: URL, + request: Request ): '' | 'no-referrer' | 'client' | URL { - if (request.referrer === 'about:client' && document.defaultView.location.origin === 'null') { + if (request.referrer === 'about:client' && originURL.origin === 'null') { return 'no-referrer'; } const requestURL = new URL(request.url); const referrerURL = - request.referrer === 'about:client' - ? new URL(document.defaultView.location.href) - : new URL(request.referrer); + request.referrer === 'about:client' ? new URL(originURL.href) : new URL(request.referrer); if (REQUEST_REFERRER_UNSUPPORTED_PROTOCOL_REGEXP.test(referrerURL.protocol)) { return 'no-referrer'; @@ -110,53 +174,6 @@ export default class FetchRequestReferrerUtility { return 'no-referrer'; } - /** - * Returns initial referrer. - * - * @param document Document. - * @param referrer Referrer. - * @returns Initial referrer. - */ - public static getInitialReferrer( - document: IDocument, - referrer: '' | 'no-referrer' | 'client' | string | URL - ): '' | 'no-referrer' | 'client' | URL { - if (referrer === '' || referrer === 'no-referrer' || referrer === 'client') { - return referrer; - } else if (referrer) { - const referrerURL = referrer instanceof URL ? referrer : new URL(referrer, document.location); - return referrerURL.origin === document.location.origin ? referrerURL : 'client'; - } - - return 'client'; - } - - /** - * Returns referrer policy from header. - * - * @see https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header - * @param headers Response headers - * @returns Policy. - */ - public static getReferrerPolicyFromHeader(headers: Headers): IRequestReferrerPolicy { - const referrerPolicyHeader = headers.get('Referrer-Policy'); - - if (!referrerPolicyHeader) { - return ''; - } - - const policyTokens = referrerPolicyHeader.split(/[,\s]+/); - let policy: IRequestReferrerPolicy = ''; - - for (const token of policyTokens) { - if (token && REFERRER_POLICIES.includes(token)) { - policy = token; - } - } - - return policy; - } - /** * Returns "true" if the request's referrer is potentially trustworthy. * diff --git a/packages/happy-dom/src/fetch/utilities/FetchRequestValidationUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchRequestValidationUtility.ts index a51d33b27..3f547e870 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchRequestValidationUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchRequestValidationUtility.ts @@ -1,9 +1,10 @@ import DOMException from '../../exception/DOMException.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import IRequestReferrerPolicy from '../types/IRequestReferrerPolicy.js'; import IRequestRedirect from '../types/IRequestRedirect.js'; import URL from '../../url/URL.js'; -import IRequest from '../types/IRequest.js'; +import Request from '../Request.js'; const VALID_REFERRER_POLICIES = [ '', @@ -19,17 +20,44 @@ const VALID_REFERRER_POLICIES = [ const VALID_REDIRECTS = ['error', 'manual', 'follow']; +const SUPPORTED_SCHEMAS = ['data:', 'http:', 'https:']; + +const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; +const REQUEST_METHOD_REGEXP = /^[A-Z]+$/; + /** * Fetch request validation utility. */ export default class FetchRequestValidationUtility { + /** + * Validates request method. + * + * @throws DOMException + * @param request Request. + */ + public static validateMethod(request: Request): void { + if (!request.method || FORBIDDEN_REQUEST_METHODS.includes(request.method)) { + throw new DOMException( + `'${request.method || ''}' is not a valid HTTP method.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + if (!REQUEST_METHOD_REGEXP.test(request.method)) { + throw new DOMException( + `'${request.method}' HTTP method is unsupported.`, + DOMExceptionNameEnum.invalidStateError + ); + } + } + /** * Validates request body. * * @throws DOMException * @param request Request. */ - public static validateBody(request: IRequest): void { + public static validateBody(request: Request): void { if (request.body && (request.method === 'GET' || request.method === 'HEAD')) { throw new DOMException( `Request with GET/HEAD method cannot have body.`, @@ -79,4 +107,22 @@ export default class FetchRequestValidationUtility { throw new DOMException(`Invalid redirect "${redirect}".`, DOMExceptionNameEnum.syntaxError); } } + + /** + * Validates request redirect. + * + * @throws DOMException + * @param request + * @param redirect Redirect. + */ + public static validateSchema(request: Request): void { + if (!SUPPORTED_SCHEMAS.includes(request[PropertySymbol.url].protocol)) { + throw new DOMException( + `Failed to fetch from "${request.url}": URL scheme "${request[ + PropertySymbol.url + ].protocol.replace(/:$/, '')}" is not supported.`, + DOMExceptionNameEnum.notSupportedError + ); + } + } } diff --git a/packages/happy-dom/src/fetch/utilities/FetchResponseHeaderUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchResponseHeaderUtility.ts new file mode 100644 index 000000000..fb5ecd005 --- /dev/null +++ b/packages/happy-dom/src/fetch/utilities/FetchResponseHeaderUtility.ts @@ -0,0 +1,47 @@ +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import CookieStringUtility from '../../cookie/urilities/CookieStringUtility.js'; +import Headers from '../Headers.js'; + +/** + * Fetch request validation utility. + */ +export default class FetchResponseHeaderUtility { + /** + * Appends headers to response. + * + * @param nodeResponse HTTP request. + * @param options + * @param options.browserFrame + * @param options.requestURL + * @param options.rawHeaders + * @returns Headers. + */ + public static parseResponseHeaders(options: { + browserFrame: IBrowserFrame; + requestURL: URL; + rawHeaders: string[]; + }): Headers { + const headers = new Headers(); + let key = null; + + for (const header of options.rawHeaders) { + if (!key) { + key = header; + } else { + const lowerName = key.toLowerCase(); + // Handles setting cookie headers to the document. + // "Set-Cookie" and "Set-Cookie2" are not allowed in response headers according to spec. + if (lowerName === 'set-cookie' || lowerName === 'set-cookie2') { + options.browserFrame.page.context.cookieContainer.addCookies([ + CookieStringUtility.stringToCookie(options.requestURL, header) + ]); + } else { + headers.append(key, header); + } + key = null; + } + } + + return headers; + } +} diff --git a/packages/happy-dom/src/fetch/utilities/FetchResponseRedirectUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchResponseRedirectUtility.ts new file mode 100644 index 000000000..be5a235aa --- /dev/null +++ b/packages/happy-dom/src/fetch/utilities/FetchResponseRedirectUtility.ts @@ -0,0 +1,27 @@ +const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; +const MAX_REDIRECT_COUNT = 20; + +/** + * Fetch request validation utility. + */ +export default class FetchResponseRedirectUtility { + /** + * Returns "true" if redirect. + * + * @param statusCode Status code. + * @returns "true" if redirect. + */ + public static isRedirect(statusCode: number): boolean { + return REDIRECT_STATUS_CODES.includes(statusCode); + } + + /** + * Returns "true" if max redirects is reached. + * + * @param redirectCount Redirect count. + * @returns "true" if max redirects is reached. + */ + public static isMaxRedirectsReached(redirectCount: number): boolean { + return redirectCount >= MAX_REDIRECT_COUNT; + } +} diff --git a/packages/happy-dom/src/fetch/utilities/SyncFetchScriptBuilder.ts b/packages/happy-dom/src/fetch/utilities/SyncFetchScriptBuilder.ts new file mode 100644 index 000000000..869f7b999 --- /dev/null +++ b/packages/happy-dom/src/fetch/utilities/SyncFetchScriptBuilder.ts @@ -0,0 +1,71 @@ +import FetchHTTPSCertificate from '../certificate/FetchHTTPSCertificate.js'; + +/** + * Synchronous fetch script builder. + */ +export default class SyncFetchScriptBuilder { + /** + * Sends a synchronous request. + * + * @param request Request. + * @param request.url + * @param request.method + * @param request.headers + * @param request.body + * @returns Script. + */ + public static getScript(request: { + url: URL; + method: string; + headers: { [name: string]: string }; + body?: Buffer | null; + }): string { + const sortedHeaders = {}; + const headerNames = Object.keys(request.headers).sort(); + for (const name of headerNames) { + sortedHeaders[name] = request.headers[name]; + } + + return ` + const sendRequest = require('http${ + request.url.protocol === 'https:' ? 's' : '' + }').request; + const options = ${JSON.stringify( + { + method: request.method, + headers: sortedHeaders, + agent: false, + rejectUnauthorized: true, + key: request.url.protocol === 'https:' ? FetchHTTPSCertificate.key : undefined, + cert: request.url.protocol === 'https:' ? FetchHTTPSCertificate.cert : undefined + }, + null, + 4 + )}; + const request = sendRequest('${request.url.href}', options, (incomingMessage) => { + let data = Buffer.alloc(0); + incomingMessage.on('data', (chunk) => { + data = Buffer.concat([data, Buffer.from(chunk)]); + }); + incomingMessage.on('end', () => { + console.log(JSON.stringify({ + error: null, + incomingMessage: { + statusCode: incomingMessage.statusCode, + statusMessage: incomingMessage.statusMessage, + rawHeaders: incomingMessage.rawHeaders, + data: data.toString('base64') + } + })); + }); + incomingMessage.on('error', (error) => { + console.log(JSON.stringify({ error: error.message, incomingMessage: null })); + }); + }); + request.write(Buffer.from('${ + request.body ? request.body.toString('base64') : '' + }', 'base64')); + request.end(); + `; + } +} diff --git a/packages/happy-dom/src/file/Blob.ts b/packages/happy-dom/src/file/Blob.ts index 5812eecfa..db07d9445 100644 --- a/packages/happy-dom/src/file/Blob.ts +++ b/packages/happy-dom/src/file/Blob.ts @@ -1,4 +1,5 @@ import IBlob from './IBlob.js'; +import * as PropertySymbol from '../PropertySymbol.js'; /** * Reference: @@ -8,8 +9,8 @@ import IBlob from './IBlob.js'; * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/file-api/Blob-impl.js (MIT licensed). */ export default class Blob implements IBlob { - public _buffer: Buffer = null; public readonly type: string = ''; + public [PropertySymbol.buffer]: Buffer = null; /** * Constructor. @@ -31,7 +32,7 @@ export default class Blob implements IBlob { if (bit instanceof ArrayBuffer) { buffer = Buffer.from(new Uint8Array(bit)); } else if (bit instanceof Blob) { - buffer = bit._buffer; + buffer = bit[PropertySymbol.buffer]; } else if (bit instanceof Buffer) { buffer = bit; } else if (ArrayBuffer.isView(bit)) { @@ -44,7 +45,7 @@ export default class Blob implements IBlob { } } - this._buffer = Buffer.concat(buffers); + this[PropertySymbol.buffer] = Buffer.concat(buffers); if (options && options.type && options.type.match(/^[\u0020-\u007E]*$/)) { this.type = String(options.type).toLowerCase(); @@ -57,7 +58,7 @@ export default class Blob implements IBlob { * @returns Size. */ public get size(): number { - return this._buffer.length; + return this[PropertySymbol.buffer].length; } /** @@ -100,12 +101,12 @@ export default class Blob implements IBlob { const span = Math.max(relativeEnd - relativeStart, 0); - const buffer = this._buffer; + const buffer = this[PropertySymbol.buffer]; const slicedBuffer = buffer.slice(relativeStart, relativeStart + span); const blob = new Blob([], { type: relativeContentType }); - (blob._buffer) = slicedBuffer; + (blob[PropertySymbol.buffer]) = slicedBuffer; return blob; } @@ -123,7 +124,7 @@ export default class Blob implements IBlob { * */ public async arrayBuffer(): Promise { - return new Uint8Array(this._buffer).buffer; + return new Uint8Array(this[PropertySymbol.buffer]).buffer; } /** @@ -132,7 +133,7 @@ export default class Blob implements IBlob { * @returns Text. */ public async text(): Promise { - return this._buffer.toString(); + return this[PropertySymbol.buffer].toString(); } /** diff --git a/packages/happy-dom/src/file/FileReader.ts b/packages/happy-dom/src/file/FileReader.ts index 3eaa32cb5..0b95bbc81 100644 --- a/packages/happy-dom/src/file/FileReader.ts +++ b/packages/happy-dom/src/file/FileReader.ts @@ -1,6 +1,7 @@ import WhatwgMIMEType from 'whatwg-mimetype'; +import * as PropertySymbol from '../PropertySymbol.js'; import WhatwgEncoding from 'whatwg-encoding'; -import IDocument from '../nodes/document/IDocument.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import ProgressEvent from '../event/events/ProgressEvent.js'; import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; @@ -18,8 +19,6 @@ import FileReaderEventTypeEnum from './FileReaderEventTypeEnum.js'; * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/file-api/FileReader-impl.js (MIT licensed). */ export default class FileReader extends EventTarget { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; public readonly error: Error = null; public readonly result: Buffer | ArrayBuffer | string = null; public readonly readyState: number = FileReaderReadyStateEnum.empty; @@ -29,17 +28,19 @@ export default class FileReader extends EventTarget { public readonly onloadstart: (event: ProgressEvent) => void = null; public readonly onloadend: (event: ProgressEvent) => void = null; public readonly onprogress: (event: ProgressEvent) => void = null; - public readonly _ownerDocument: IDocument = null; - private _isTerminated = false; - private _loadTimeout: NodeJS.Timeout = null; - private _parseTimeout: NodeJS.Timeout = null; + #isTerminated = false; + #loadTimeout: NodeJS.Timeout | null = null; + #parseTimeout: NodeJS.Timeout | null = null; + readonly #window: IBrowserWindow; /** * Constructor. + * + * @param window Window. */ - constructor() { + constructor(window: IBrowserWindow) { super(); - this._ownerDocument = (this.constructor)._ownerDocument; + this.#window = window; } /** @@ -48,7 +49,7 @@ export default class FileReader extends EventTarget { * @param blob Blob. */ public readAsArrayBuffer(blob: Blob): void { - this._readFile(blob, FileReaderFormatEnum.buffer); + this.#readFile(blob, FileReaderFormatEnum.buffer); } /** @@ -57,7 +58,7 @@ export default class FileReader extends EventTarget { * @param blob Blob. */ public readAsBinaryString(blob: Blob): void { - this._readFile(blob, FileReaderFormatEnum.binaryString); + this.#readFile(blob, FileReaderFormatEnum.binaryString); } /** @@ -66,7 +67,7 @@ export default class FileReader extends EventTarget { * @param blob Blob. */ public readAsDataURL(blob: Blob): void { - this._readFile(blob, FileReaderFormatEnum.dataURL); + this.#readFile(blob, FileReaderFormatEnum.dataURL); } /** @@ -76,7 +77,7 @@ export default class FileReader extends EventTarget { * @param [encoding] Encoding. */ public readAsText(blob: Blob, encoding: string = null): void { - this._readFile( + this.#readFile( blob, FileReaderFormatEnum.text, WhatwgEncoding.labelToName(encoding) || 'UTF-8' @@ -87,10 +88,8 @@ export default class FileReader extends EventTarget { * Aborts the file reader. */ public abort(): void { - const window = this._ownerDocument.defaultView; - - window.clearTimeout(this._loadTimeout); - window.clearTimeout(this._parseTimeout); + this.#window.clearTimeout(this.#loadTimeout); + this.#window.clearTimeout(this.#parseTimeout); if ( this.readyState === FileReaderReadyStateEnum.empty || @@ -105,7 +104,7 @@ export default class FileReader extends EventTarget { (this.result) = null; } - this._isTerminated = true; + this.#isTerminated = true; this.dispatchEvent(new ProgressEvent(FileReaderEventTypeEnum.abort)); this.dispatchEvent(new ProgressEvent(FileReaderEventTypeEnum.loadend)); } @@ -117,9 +116,7 @@ export default class FileReader extends EventTarget { * @param format Format. * @param [encoding] Encoding. */ - private _readFile(blob: Blob, format: FileReaderFormatEnum, encoding: string = null): void { - const window = this._ownerDocument.defaultView; - + #readFile(blob: Blob, format: FileReaderFormatEnum, encoding: string = null): void { if (this.readyState === FileReaderReadyStateEnum.loading) { throw new DOMException( 'The object is in an invalid state.', @@ -129,15 +126,15 @@ export default class FileReader extends EventTarget { (this.readyState) = FileReaderReadyStateEnum.loading; - this._loadTimeout = window.setTimeout(() => { - if (this._isTerminated) { - this._isTerminated = false; + this.#loadTimeout = this.#window.setTimeout(() => { + if (this.#isTerminated) { + this.#isTerminated = false; return; } this.dispatchEvent(new ProgressEvent(FileReaderEventTypeEnum.loadstart)); - let data = blob._buffer; + let data = blob[PropertySymbol.buffer]; if (!data) { data = Buffer.alloc(0); } @@ -150,9 +147,9 @@ export default class FileReader extends EventTarget { }) ); - this._parseTimeout = window.setTimeout(() => { - if (this._isTerminated) { - this._isTerminated = false; + this.#parseTimeout = this.#window.setTimeout(() => { + if (this.#isTerminated) { + this.#isTerminated = false; return; } diff --git a/packages/happy-dom/src/form-data/FormData.ts b/packages/happy-dom/src/form-data/FormData.ts index 126985ff2..aacf094fa 100644 --- a/packages/happy-dom/src/form-data/FormData.ts +++ b/packages/happy-dom/src/form-data/FormData.ts @@ -1,4 +1,5 @@ import Blob from '../file/Blob.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import File from '../file/File.js'; import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement.js'; import IHTMLFormElement from '../nodes/html-form-element/IHTMLFormElement.js'; @@ -18,7 +19,7 @@ const SUBMITTABLE_ELEMENTS = ['BUTTON', 'INPUT', 'OBJECT', 'SELECT', 'TEXTAREA'] * @see https://developer.mozilla.org/en-US/docs/Web/API/FormData */ export default class FormData implements Iterable<[string, string | File]> { - private _entries: FormDataEntry[] = []; + #entries: FormDataEntry[] = []; /** * Constructor. @@ -27,11 +28,15 @@ export default class FormData implements Iterable<[string, string | File]> { */ constructor(form?: IHTMLFormElement) { if (form) { - for (const name of Object.keys((form.elements)._namedItems)) { - let radioNodeList = (form.elements)._namedItems[name]; + for (const name of Object.keys( + (form[PropertySymbol.elements])[PropertySymbol.namedItems] + )) { + let radioNodeList = (form[PropertySymbol.elements])[ + PropertySymbol.namedItems + ][name]; if ( - radioNodeList[0].tagName === 'INPUT' && + radioNodeList[0][PropertySymbol.tagName] === 'INPUT' && (radioNodeList[0].type === 'checkbox' || radioNodeList[0].type === 'radio') ) { const newRadioNodeList = new RadioNodeList(); @@ -45,12 +50,12 @@ export default class FormData implements Iterable<[string, string | File]> { } for (const node of radioNodeList) { - if (node.name && SUBMITTABLE_ELEMENTS.includes(node.tagName)) { - if (node.tagName === 'INPUT' && node.type === 'file') { - if ((node).files.length === 0) { + if (node.name && SUBMITTABLE_ELEMENTS.includes(node[PropertySymbol.tagName])) { + if (node[PropertySymbol.tagName] === 'INPUT' && node.type === 'file') { + if ((node)[PropertySymbol.files].length === 0) { this.append(node.name, new File([], '', { type: 'application/octet-stream' })); } else { - for (const file of (node).files) { + for (const file of (node)[PropertySymbol.files]) { this.append(node.name, file); } } @@ -69,7 +74,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @param callback Callback. */ public forEach(callback: (key: string, value: string | File, thisArg: FormData) => void): void { - for (const entry of this._entries) { + for (const entry of this.#entries) { callback.call(this, entry.value, entry.name, this); } } @@ -82,9 +87,9 @@ export default class FormData implements Iterable<[string, string | File]> { * @param [filename] Filename. */ public append(name: string, value: string | Blob | File, filename?: string): void { - this._entries.push({ + this.#entries.push({ name, - value: this._parseValue(value, filename) + value: this.#parseValue(value, filename) }); } @@ -95,12 +100,12 @@ export default class FormData implements Iterable<[string, string | File]> { */ public delete(name: string): void { const newEntries: FormDataEntry[] = []; - for (const entry of this._entries) { + for (const entry of this.#entries) { if (entry.name !== name) { newEntries.push(entry); } } - this._entries = newEntries; + this.#entries = newEntries; } /** @@ -110,7 +115,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns Value. */ public get(name: string): string | File | null { - for (const entry of this._entries) { + for (const entry of this.#entries) { if (entry.name === name) { return entry.value; } @@ -126,7 +131,7 @@ export default class FormData implements Iterable<[string, string | File]> { */ public getAll(name: string): Array { const values: Array = []; - for (const entry of this._entries) { + for (const entry of this.#entries) { if (entry.name === name) { values.push(entry.value); } @@ -141,7 +146,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns "true" if the FormData object contains the key. */ public has(name: string): boolean { - for (const entry of this._entries) { + for (const entry of this.#entries) { if (entry.name === name) { return true; } @@ -157,9 +162,9 @@ export default class FormData implements Iterable<[string, string | File]> { * @param [filename] Filename. */ public set(name: string, value: string | Blob | File, filename?: string): void { - for (const entry of this._entries) { + for (const entry of this.#entries) { if (entry.name === name) { - entry.value = this._parseValue(value, filename); + entry.value = this.#parseValue(value, filename); return; } } @@ -172,7 +177,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns Iterator. */ public *keys(): IterableIterator { - for (const entry of this._entries) { + for (const entry of this.#entries) { yield entry.name; } } @@ -183,7 +188,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns Iterator. */ public *values(): IterableIterator { - for (const entry of this._entries) { + for (const entry of this.#entries) { yield entry.value; } } @@ -194,7 +199,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns Iterator. */ public *entries(): IterableIterator<[string, string | File]> { - for (const entry of this._entries) { + for (const entry of this.#entries) { yield [entry.name, entry.value]; } } @@ -205,7 +210,7 @@ export default class FormData implements Iterable<[string, string | File]> { * @returns Iterator. */ public *[Symbol.iterator](): IterableIterator<[string, string | File]> { - for (const entry of this._entries) { + for (const entry of this.#entries) { yield [entry.name, entry.value]; } } @@ -217,17 +222,17 @@ export default class FormData implements Iterable<[string, string | File]> { * @param [filename] Filename. * @returns Parsed value. */ - private _parseValue(value: string | Blob | File, filename?: string): string | File { + #parseValue(value: string | Blob | File, filename?: string): string | File { if (value instanceof Blob && !(value instanceof File)) { const file = new File([], 'blob', { type: value.type }); - file._buffer = value._buffer; + file[PropertySymbol.buffer] = value[PropertySymbol.buffer]; return file; } if (value instanceof File) { if (filename) { const file = new File([], filename, { type: value.type, lastModified: value.lastModified }); - file._buffer = value._buffer; + file[PropertySymbol.buffer] = value[PropertySymbol.buffer]; return file; } return value; diff --git a/packages/happy-dom/src/history/History.ts b/packages/happy-dom/src/history/History.ts index 6ad7dd33e..bc2871fd0 100644 --- a/packages/happy-dom/src/history/History.ts +++ b/packages/happy-dom/src/history/History.ts @@ -9,7 +9,7 @@ import HistoryScrollRestorationEnum from './HistoryScrollRestorationEnum.js'; export default class History { public readonly length = 0; public readonly state = null; - private _scrollRestoration = HistoryScrollRestorationEnum.auto; + #scrollRestoration = HistoryScrollRestorationEnum.auto; /** * Returns scroll restoration. @@ -17,7 +17,7 @@ export default class History { * @returns Sroll restoration. */ public get scrollRestoration(): HistoryScrollRestorationEnum { - return this._scrollRestoration; + return this.#scrollRestoration; } /** @@ -26,9 +26,9 @@ export default class History { * @param scrollRestoration Sroll restoration. */ public set scrollRestoration(scrollRestoration: HistoryScrollRestorationEnum) { - this._scrollRestoration = HistoryScrollRestorationEnum[scrollRestoration] + this.#scrollRestoration = HistoryScrollRestorationEnum[scrollRestoration] ? scrollRestoration - : this._scrollRestoration; + : this.#scrollRestoration; } /** diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index a7615c9d1..e7dc4db50 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -1,325 +1,355 @@ -import GlobalWindow from './window/GlobalWindow.js'; -import IWindow from './window/IWindow.js'; -import Window from './window/Window.js'; +import { URLSearchParams } from 'url'; +import Browser from './browser/Browser.js'; +import BrowserContext from './browser/BrowserContext.js'; +import BrowserFrame from './browser/BrowserFrame.js'; +import BrowserPage from './browser/BrowserPage.js'; +import DetachedBrowser from './browser/detached-browser/DetachedBrowser.js'; +import DetachedBrowserContext from './browser/detached-browser/DetachedBrowserContext.js'; +import DetachedBrowserFrame from './browser/detached-browser/DetachedBrowserFrame.js'; +import DetachedBrowserPage from './browser/detached-browser/DetachedBrowserPage.js'; +import BrowserErrorCaptureEnum from './browser/enums/BrowserErrorCaptureEnum.js'; +import BrowserNavigationCrossOriginPolicyEnum from './browser/enums/BrowserNavigationCrossOriginPolicyEnum.js'; +import Clipboard from './clipboard/Clipboard.js'; +import ClipboardItem from './clipboard/ClipboardItem.js'; +import VirtualConsole from './console/VirtualConsole.js'; +import VirtualConsolePrinter from './console/VirtualConsolePrinter.js'; +import VirtualConsoleLogLevelEnum from './console/enums/VirtualConsoleLogLevelEnum.js'; +import VirtualConsoleLogTypeEnum from './console/enums/VirtualConsoleLogTypeEnum.js'; +import CSSRule from './css/CSSRule.js'; +import CSSStyleSheet from './css/CSSStyleSheet.js'; +import CSSStyleDeclaration from './css/declaration/CSSStyleDeclaration.js'; +import CSSContainerRule from './css/rules/CSSContainerRule.js'; +import CSSFontFaceRule from './css/rules/CSSFontFaceRule.js'; +import CSSKeyframeRule from './css/rules/CSSKeyframeRule.js'; +import CSSKeyframesRule from './css/rules/CSSKeyframesRule.js'; +import CSSMediaRule from './css/rules/CSSMediaRule.js'; +import CSSStyleRule from './css/rules/CSSStyleRule.js'; +import CSSSupportsRule from './css/rules/CSSSupportsRule.js'; +import CustomElementRegistry from './custom-element/CustomElementRegistry.js'; +import DOMParser from './dom-parser/DOMParser.js'; import DataTransfer from './event/DataTransfer.js'; import DataTransferItem from './event/DataTransferItem.js'; import DataTransferItemList from './event/DataTransferItemList.js'; -import { URLSearchParams } from 'url'; -import URL from './url/URL.js'; -import Location from './location/Location.js'; -import MutationObserver from './mutation-observer/MutationObserver.js'; -import MutationRecord from './mutation-observer/MutationRecord.js'; -import ResizeObserver from './resize-observer/ResizeObserver.js'; -import Blob from './file/Blob.js'; -import File from './file/File.js'; -import FileReader from './file/FileReader.js'; -import DOMException from './exception/DOMException.js'; -import History from './history/History.js'; -import CSSStyleDeclaration from './css/declaration/CSSStyleDeclaration.js'; -import Screen from './screen/Screen.js'; -import AsyncTaskManager from './async-task-manager/AsyncTaskManager.js'; -import NodeFilter from './tree-walker/NodeFilter.js'; import Event from './event/Event.js'; +import EventPhaseEnum from './event/EventPhaseEnum.js'; import EventTarget from './event/EventTarget.js'; -import IEventInit from './event/IEventInit.js'; -import IEventListener from './event/IEventListener.js'; -import IUIEventInit from './event/IUIEventInit.js'; import UIEvent from './event/UIEvent.js'; +import AnimationEvent from './event/events/AnimationEvent.js'; +import ClipboardEvent from './event/events/ClipboardEvent.js'; +import CustomEvent from './event/events/CustomEvent.js'; import ErrorEvent from './event/events/ErrorEvent.js'; import FocusEvent from './event/events/FocusEvent.js'; -import CustomEvent from './event/events/CustomEvent.js'; -import AnimationEvent from './event/events/AnimationEvent.js'; -import IAnimationEventInit from './event/events/IAnimationEventInit.js'; -import ICustomEventInit from './event/events/ICustomEventInit.js'; -import IErrorEventInit from './event/events/IErrorEventInit.js'; -import IFocusEventInit from './event/events/IFocusEventInit.js'; -import IInputEventInit from './event/events/IInputEventInit.js'; -import IKeyboardEventInit from './event/events/IKeyboardEventInit.js'; -import IMouseEventInit from './event/events/IMouseEventInit.js'; -import IProgressEventInit from './event/events/IProgressEventInit.js'; -import ISubmitEventInit from './event/events/ISubmitEventInit.js'; -import IWheelEventInit from './event/events/IWheelEventInit.js'; import InputEvent from './event/events/InputEvent.js'; import KeyboardEvent from './event/events/KeyboardEvent.js'; import MouseEvent from './event/events/MouseEvent.js'; import ProgressEvent from './event/events/ProgressEvent.js'; import SubmitEvent from './event/events/SubmitEvent.js'; import WheelEvent from './event/events/WheelEvent.js'; -import DOMParser from './dom-parser/DOMParser.js'; +import DOMException from './exception/DOMException.js'; +import AbortController from './fetch/AbortController.js'; +import AbortSignal from './fetch/AbortSignal.js'; +import Headers from './fetch/Headers.js'; +import Request from './fetch/Request.js'; +import Response from './fetch/Response.js'; +import Blob from './file/Blob.js'; +import File from './file/File.js'; +import FileReader from './file/FileReader.js'; +import FormData from './form-data/FormData.js'; +import History from './history/History.js'; +import Location from './location/Location.js'; +import MutationObserver from './mutation-observer/MutationObserver.js'; +import MutationRecord from './mutation-observer/MutationRecord.js'; +import Attr from './nodes/attr/Attr.js'; +import Comment from './nodes/comment/Comment.js'; +import DocumentFragment from './nodes/document-fragment/DocumentFragment.js'; +import DocumentType from './nodes/document-type/DocumentType.js'; import Document from './nodes/document/Document.js'; -import IDocument from './nodes/document/IDocument.js'; -import HTMLDocument from './nodes/html-document/HTMLDocument.js'; -import XMLDocument from './nodes/xml-document/XMLDocument.js'; -import SVGDocument from './nodes/svg-document/SVGDocument.js'; +import DOMRect from './nodes/element/DOMRect.js'; import Element from './nodes/element/Element.js'; -import IElement from './nodes/element/IElement.js'; -import IHTMLCollection from './nodes/element/IHTMLCollection.js'; import HTMLCollection from './nodes/element/HTMLCollection.js'; -import HTMLFormControlsCollection from './nodes/html-form-element/HTMLFormControlsCollection.js'; -import IHTMLFormControlsCollection from './nodes/html-form-element/IHTMLFormControlsCollection.js'; +import HTMLAudioElement from './nodes/html-audio-element/HTMLAudioElement.js'; +import HTMLBaseElement from './nodes/html-base-element/HTMLBaseElement.js'; +import HTMLDialogElement from './nodes/html-dialog-element/HTMLDialogElement.js'; +import HTMLDocument from './nodes/html-document/HTMLDocument.js'; import HTMLElement from './nodes/html-element/HTMLElement.js'; -import IHTMLElement from './nodes/html-element/IHTMLElement.js'; -import HTMLTemplateElement from './nodes/html-template-element/HTMLTemplateElement.js'; -import IHTMLTemplateElement from './nodes/html-template-element/IHTMLTemplateElement.js'; +import HTMLFormControlsCollection from './nodes/html-form-element/HTMLFormControlsCollection.js'; import HTMLFormElement from './nodes/html-form-element/HTMLFormElement.js'; -import IHTMLFormElement from './nodes/html-form-element/IHTMLFormElement.js'; -import HTMLInputElement from './nodes/html-input-element/HTMLInputElement.js'; -import IHTMLInputElement from './nodes/html-input-element/IHTMLInputElement.js'; -import HTMLTextAreaElement from './nodes/html-text-area-element/HTMLTextAreaElement.js'; -import IHTMLTextAreaElement from './nodes/html-text-area-element/IHTMLTextAreaElement.js'; +import HTMLIFrameElement from './nodes/html-iframe-element/HTMLIFrameElement.js'; import HTMLImageElement from './nodes/html-image-element/HTMLImageElement.js'; -import IHTMLImageElement from './nodes/html-image-element/IHTMLImageElement.js'; import Image from './nodes/html-image-element/Image.js'; -import HTMLScriptElement from './nodes/html-script-element/HTMLScriptElement.js'; -import HTMLLinkElement from './nodes/html-link-element/HTMLLinkElement.js'; -import IHTMLLinkElement from './nodes/html-link-element/IHTMLLinkElement.js'; -import HTMLStyleElement from './nodes/html-style-element/HTMLStyleElement.js'; -import IHTMLStyleElement from './nodes/html-style-element/IHTMLStyleElement.js'; -import HTMLSlotElement from './nodes/html-slot-element/HTMLSlotElement.js'; -import IHTMLSlotElement from './nodes/html-slot-element/IHTMLSlotElement.js'; +import FileList from './nodes/html-input-element/FileList.js'; +import HTMLInputElement from './nodes/html-input-element/HTMLInputElement.js'; import HTMLLabelElement from './nodes/html-label-element/HTMLLabelElement.js'; -import IHTMLLabelElement from './nodes/html-label-element/IHTMLLabelElement.js'; -import HTMLMetaElement from './nodes/html-meta-element/HTMLMetaElement.js'; -import IHTMLMetaElement from './nodes/html-meta-element/IHTMLMetaElement.js'; -import IHTMLMediaElement from './nodes/html-media-element/IHTMLMediaElement.js'; +import HTMLLinkElement from './nodes/html-link-element/HTMLLinkElement.js'; import HTMLMediaElement from './nodes/html-media-element/HTMLMediaElement.js'; -import HTMLAudioElement from './nodes/html-audio-element/HTMLAudioElement.js'; -import IHTMLAudioElement from './nodes/html-audio-element/IHTMLAudioElement.js'; +import HTMLMetaElement from './nodes/html-meta-element/HTMLMetaElement.js'; +import HTMLScriptElement from './nodes/html-script-element/HTMLScriptElement.js'; +import HTMLSlotElement from './nodes/html-slot-element/HTMLSlotElement.js'; +import HTMLStyleElement from './nodes/html-style-element/HTMLStyleElement.js'; +import HTMLTemplateElement from './nodes/html-template-element/HTMLTemplateElement.js'; +import HTMLTextAreaElement from './nodes/html-text-area-element/HTMLTextAreaElement.js'; import HTMLVideoElement from './nodes/html-video-element/HTMLVideoElement.js'; -import IHTMLVideoElement from './nodes/html-video-element/IHTMLVideoElement.js'; -import HTMLBaseElement from './nodes/html-base-element/HTMLBaseElement.js'; -import IHTMLBaseElement from './nodes/html-base-element/IHTMLBaseElement.js'; -import HTMLIFrameElement from './nodes/html-iframe-element/HTMLIFrameElement.js'; -import IHTMLIFrameElement from './nodes/html-iframe-element/IHTMLIFrameElement.js'; +import Node from './nodes/node/Node.js'; +import ProcessingInstruction from './nodes/processing-instruction/ProcessingInstruction.js'; +import ShadowRoot from './nodes/shadow-root/ShadowRoot.js'; +import SVGDocument from './nodes/svg-document/SVGDocument.js'; import SVGElement from './nodes/svg-element/SVGElement.js'; -import ISVGElement from './nodes/svg-element/ISVGElement.js'; import SVGGraphicsElement from './nodes/svg-element/SVGGraphicsElement.js'; -import ISVGGraphicsElement from './nodes/svg-element/ISVGGraphicsElement.js'; import SVGSVGElement from './nodes/svg-element/SVGSVGElement.js'; -import ISVGSVGElement from './nodes/svg-element/ISVGSVGElement.js'; -import DocumentFragment from './nodes/document-fragment/DocumentFragment.js'; -import IDocumentFragment from './nodes/document-fragment/IDocumentFragment.js'; -import ShadowRoot from './nodes/shadow-root/ShadowRoot.js'; -import IShadowRoot from './nodes/shadow-root/IShadowRoot.js'; -import Node from './nodes/node/Node.js'; -import INode from './nodes/node/INode.js'; -import INodeList from './nodes/node/INodeList.js'; import Text from './nodes/text/Text.js'; -import IText from './nodes/text/IText.js'; -import Comment from './nodes/comment/Comment.js'; -import IComment from './nodes/comment/IComment.js'; -import DocumentType from './nodes/document-type/DocumentType.js'; -import IDocumentType from './nodes/document-type/IDocumentType.js'; +import XMLDocument from './nodes/xml-document/XMLDocument.js'; +import PermissionStatus from './permissions/PermissionStatus.js'; +import Permissions from './permissions/Permissions.js'; +import Range from './range/Range.js'; +import ResizeObserver from './resize-observer/ResizeObserver.js'; +import Screen from './screen/Screen.js'; +import Selection from './selection/Selection.js'; +import Storage from './storage/Storage.js'; +import NodeFilter from './tree-walker/NodeFilter.js'; import NodeIterator from './tree-walker/NodeIterator.js'; import TreeWalker from './tree-walker/TreeWalker.js'; -import CustomElementRegistry from './custom-element/CustomElementRegistry.js'; +import URL from './url/URL.js'; +import BrowserWindow from './window/BrowserWindow.js'; +import GlobalWindow from './window/GlobalWindow.js'; +import Window from './window/Window.js'; import XMLParser from './xml-parser/XMLParser.js'; import XMLSerializer from './xml-serializer/XMLSerializer.js'; -import CSSStyleSheet from './css/CSSStyleSheet.js'; -import CSSRule from './css/CSSRule.js'; -import CSSContainerRule from './css/rules/CSSContainerRule.js'; -import CSSFontFaceRule from './css/rules/CSSFontFaceRule.js'; -import CSSKeyframeRule from './css/rules/CSSKeyframeRule.js'; -import CSSKeyframesRule from './css/rules/CSSKeyframesRule.js'; -import CSSMediaRule from './css/rules/CSSMediaRule.js'; -import CSSStyleRule from './css/rules/CSSStyleRule.js'; -import CSSSupportsRule from './css/rules/CSSSupportsRule.js'; -import Storage from './storage/Storage.js'; -import DOMRect from './nodes/element/DOMRect.js'; -import Selection from './selection/Selection.js'; -import Range from './range/Range.js'; -import HTMLDialogElement from './nodes/html-dialog-element/HTMLDialogElement.js'; -import IHTMLDialogElement from './nodes/html-dialog-element/IHTMLDialogElement.js'; -import Attr from './nodes/attr/Attr.js'; -import IAttr from './nodes/attr/IAttr.js'; -import ProcessingInstruction from './nodes/processing-instruction/ProcessingInstruction.js'; -import IProcessingInstruction from './nodes/processing-instruction/IProcessingInstruction.js'; -import FileList from './nodes/html-input-element/FileList.js'; -import IFileList from './nodes/html-input-element/IFileList.js'; -import AbortController from './fetch/AbortController.js'; -import AbortSignal from './fetch/AbortSignal.js'; -import Request from './fetch/Request.js'; -import IRequest from './fetch/types/IRequest.js'; -import Response from './fetch/Response.js'; -import IResponse from './fetch/types/IResponse.js'; -import Headers from './fetch/Headers.js'; -import IHeaders from './fetch/types/IHeaders.js'; -import FormData from './form-data/FormData.js'; -import EventPhaseEnum from './event/EventPhaseEnum.js'; -import VirtualConsoleLogLevelEnum from './console/enums/VirtualConsoleLogLevelEnum.js'; -import VirtualConsoleLogTypeEnum from './console/enums/VirtualConsoleLogTypeEnum.js'; -import IVirtualConsoleLogEntry from './console/types/IVirtualConsoleLogEntry.js'; -import IVirtualConsoleLogGroup from './console/types/IVirtualConsoleLogGroup.js'; -import IVirtualConsolePrinter from './console/types/IVirtualConsolePrinter.js'; -import VirtualConsole from './console/VirtualConsole.js'; -import VirtualConsolePrinter from './console/VirtualConsolePrinter.js'; -import Permissions from './permissions/Permissions.js'; -import PermissionStatus from './permissions/PermissionStatus.js'; -import Clipboard from './clipboard/Clipboard.js'; -import ClipboardItem from './clipboard/ClipboardItem.js'; -import ClipboardEvent from './event/events/ClipboardEvent.js'; -import IClipboardEventInit from './event/events/IClipboardEventInit.js'; -export { - GlobalWindow, - Window, - IWindow, - DataTransfer, - DataTransferItem, - DataTransferItemList, - URL, - Location, - MutationObserver, - MutationRecord, - ResizeObserver, - Blob, - File, - FileReader, - DOMException, - History, - CSSStyleDeclaration, - Screen, - AsyncTaskManager, - NodeFilter, - Event, - EventTarget, - IEventInit, - IEventListener, - IUIEventInit, - UIEvent, - ErrorEvent, - FocusEvent, - AnimationEvent, +import type IEventInit from './event/IEventInit.js'; +import type IEventListener from './event/IEventListener.js'; +import type IUIEventInit from './event/IUIEventInit.js'; +import type IAnimationEventInit from './event/events/IAnimationEventInit.js'; +import type IClipboardEventInit from './event/events/IClipboardEventInit.js'; +import type ICustomEventInit from './event/events/ICustomEventInit.js'; +import type IErrorEventInit from './event/events/IErrorEventInit.js'; +import type IFocusEventInit from './event/events/IFocusEventInit.js'; +import type IInputEventInit from './event/events/IInputEventInit.js'; +import type IKeyboardEventInit from './event/events/IKeyboardEventInit.js'; +import type IMouseEventInit from './event/events/IMouseEventInit.js'; +import type IProgressEventInit from './event/events/IProgressEventInit.js'; +import type ISubmitEventInit from './event/events/ISubmitEventInit.js'; +import type IWheelEventInit from './event/events/IWheelEventInit.js'; +import type IHeaders from './fetch/types/IHeaders.js'; +import type IRequest from './fetch/types/IRequest.js'; +import type IResponse from './fetch/types/IResponse.js'; +import type IAttr from './nodes/attr/IAttr.js'; +import type IComment from './nodes/comment/IComment.js'; +import type IDocumentFragment from './nodes/document-fragment/IDocumentFragment.js'; +import type IDocumentType from './nodes/document-type/IDocumentType.js'; +import type IDocument from './nodes/document/IDocument.js'; +import type IElement from './nodes/element/IElement.js'; +import type IHTMLCollection from './nodes/element/IHTMLCollection.js'; +import type IHTMLAudioElement from './nodes/html-audio-element/IHTMLAudioElement.js'; +import type IHTMLBaseElement from './nodes/html-base-element/IHTMLBaseElement.js'; +import type IHTMLDialogElement from './nodes/html-dialog-element/IHTMLDialogElement.js'; +import type IHTMLElement from './nodes/html-element/IHTMLElement.js'; +import type IHTMLFormControlsCollection from './nodes/html-form-element/IHTMLFormControlsCollection.js'; +import type IHTMLFormElement from './nodes/html-form-element/IHTMLFormElement.js'; +import type IHTMLIFrameElement from './nodes/html-iframe-element/IHTMLIFrameElement.js'; +import type IHTMLImageElement from './nodes/html-image-element/IHTMLImageElement.js'; +import type IFileList from './nodes/html-input-element/IFileList.js'; +import type IHTMLInputElement from './nodes/html-input-element/IHTMLInputElement.js'; +import type IHTMLLabelElement from './nodes/html-label-element/IHTMLLabelElement.js'; +import type IHTMLLinkElement from './nodes/html-link-element/IHTMLLinkElement.js'; +import type IHTMLMediaElement from './nodes/html-media-element/IHTMLMediaElement.js'; +import type IHTMLMetaElement from './nodes/html-meta-element/IHTMLMetaElement.js'; +import type IHTMLSlotElement from './nodes/html-slot-element/IHTMLSlotElement.js'; +import type IHTMLStyleElement from './nodes/html-style-element/IHTMLStyleElement.js'; +import type IHTMLTemplateElement from './nodes/html-template-element/IHTMLTemplateElement.js'; +import type IHTMLTextAreaElement from './nodes/html-text-area-element/IHTMLTextAreaElement.js'; +import type IHTMLVideoElement from './nodes/html-video-element/IHTMLVideoElement.js'; +import type INode from './nodes/node/INode.js'; +import type INodeList from './nodes/node/INodeList.js'; +import type IProcessingInstruction from './nodes/processing-instruction/IProcessingInstruction.js'; +import type IShadowRoot from './nodes/shadow-root/IShadowRoot.js'; +import type ISVGElement from './nodes/svg-element/ISVGElement.js'; +import type ISVGGraphicsElement from './nodes/svg-element/ISVGGraphicsElement.js'; +import type ISVGSVGElement from './nodes/svg-element/ISVGSVGElement.js'; +import type IText from './nodes/text/IText.js'; +import type IBrowserWindow from './window/IBrowserWindow.js'; +import type IWindow from './window/IWindow.js'; +import type IBrowser from './browser/types/IBrowser.js'; +import type IBrowserContext from './browser/types/IBrowserContext.js'; +import type IBrowserFrame from './browser/types/IBrowserFrame.js'; +import type IBrowserPage from './browser/types/IBrowserPage.js'; +import type ICrossOriginBrowserWindow from './window/ICrossOriginBrowserWindow.js'; + +export type { IAnimationEventInit, - ICustomEventInit, - CustomEvent, - IErrorEventInit, - IFocusEventInit, - IInputEventInit, - IKeyboardEventInit, - IMouseEventInit, - IProgressEventInit, - ISubmitEventInit, - IWheelEventInit, - InputEvent, - KeyboardEvent, - MouseEvent, - ProgressEvent, - SubmitEvent, - WheelEvent, - ClipboardEvent, + IAttr, + IBrowserWindow, + IBrowser, + IBrowserContext, + IBrowserFrame, + IBrowserPage, + ICrossOriginBrowserWindow, IClipboardEventInit, - DOMParser, - Document, + IComment, + ICustomEventInit, IDocument, - HTMLDocument, - XMLDocument, - SVGDocument, - Element, + IDocumentFragment, + IDocumentType, IElement, + IErrorEventInit, + IEventInit, + IEventListener, + IFileList, + IFocusEventInit, + IHTMLAudioElement, + IHTMLBaseElement, IHTMLCollection, - HTMLCollection, - HTMLFormControlsCollection, - IHTMLFormControlsCollection, - HTMLElement, + IHTMLDialogElement, IHTMLElement, - HTMLTemplateElement, - IHTMLTemplateElement, - HTMLFormElement, + IHTMLFormControlsCollection, IHTMLFormElement, - HTMLInputElement, - IHTMLInputElement, - HTMLTextAreaElement, - IHTMLTextAreaElement, - HTMLImageElement, + IHTMLIFrameElement, IHTMLImageElement, - Image, - HTMLScriptElement, - HTMLLinkElement, - IHTMLLinkElement, - HTMLStyleElement, - IHTMLStyleElement, - HTMLSlotElement, - IHTMLSlotElement, - HTMLLabelElement, + IHTMLInputElement, IHTMLLabelElement, - HTMLMetaElement, - IHTMLMetaElement, - HTMLMediaElement, + IHTMLLinkElement, IHTMLMediaElement, - HTMLAudioElement, - IHTMLAudioElement, - HTMLVideoElement, + IHTMLMetaElement, + IHTMLSlotElement, + IHTMLStyleElement, + IHTMLTemplateElement, + IHTMLTextAreaElement, IHTMLVideoElement, - HTMLBaseElement, - IHTMLBaseElement, - HTMLIFrameElement, - IHTMLIFrameElement, - SVGElement, + IHeaders, + IInputEventInit, + IKeyboardEventInit, + IMouseEventInit, + INode, + INodeList, + IProcessingInstruction, + IProgressEventInit, + IRequest, + IResponse, ISVGElement, - SVGGraphicsElement, ISVGGraphicsElement, - SVGSVGElement, ISVGSVGElement, - DocumentFragment, - IDocumentFragment, - ShadowRoot, IShadowRoot, - Node, - INode, - INodeList, - Text, + ISubmitEventInit, IText, - Comment, - IComment, - DocumentType, - IDocumentType, - NodeIterator, - TreeWalker, - CustomElementRegistry, - XMLParser, - XMLSerializer, - CSSStyleSheet, - CSSRule, + IUIEventInit, + IWheelEventInit, + IWindow +}; + +export { + AbortController, + AbortSignal, + AnimationEvent, + Attr, + Blob, + Browser, + BrowserContext, + BrowserErrorCaptureEnum, + BrowserFrame, + BrowserNavigationCrossOriginPolicyEnum, + BrowserPage, + BrowserWindow, CSSContainerRule, CSSFontFaceRule, CSSKeyframeRule, CSSKeyframesRule, CSSMediaRule, + CSSRule, + CSSStyleDeclaration, CSSStyleRule, + CSSStyleSheet, CSSSupportsRule, - Storage, + Clipboard, + ClipboardEvent, + ClipboardItem, + Comment, + CustomElementRegistry, + CustomEvent, + DOMException, + DOMParser, DOMRect, - URLSearchParams, - Selection, - Range, + DataTransfer, + DataTransferItem, + DataTransferItemList, + DetachedBrowser, + DetachedBrowserContext, + DetachedBrowserFrame, + DetachedBrowserPage, + Document, + DocumentFragment, + DocumentType, + Element, + ErrorEvent, + Event, + EventPhaseEnum, + EventTarget, + File, + FileList, + FileReader, + FocusEvent, + FormData, + GlobalWindow, + HTMLAudioElement, + HTMLBaseElement, + HTMLCollection, HTMLDialogElement, - IHTMLDialogElement, - Attr, - IAttr, + HTMLDocument, + HTMLElement, + HTMLFormControlsCollection, + HTMLFormElement, + HTMLIFrameElement, + HTMLImageElement, + HTMLInputElement, + HTMLLabelElement, + HTMLLinkElement, + HTMLMediaElement, + HTMLMetaElement, + HTMLScriptElement, + HTMLSlotElement, + HTMLStyleElement, + HTMLTemplateElement, + HTMLTextAreaElement, + HTMLVideoElement, + Headers, + History, + Image, + InputEvent, + KeyboardEvent, + Location, + MouseEvent, + MutationObserver, + MutationRecord, + Node, + NodeFilter, + NodeIterator, + PermissionStatus, + Permissions, ProcessingInstruction, - IProcessingInstruction, - FileList, - IFileList, - AbortController, - AbortSignal, + ProgressEvent, + Range, Request, - IRequest, + ResizeObserver, Response, - IResponse, - Headers, - IHeaders, - FormData, - EventPhaseEnum, + SVGDocument, + SVGElement, + SVGGraphicsElement, + SVGSVGElement, + Screen, + Selection, + ShadowRoot, + Storage, + SubmitEvent, + Text, + TreeWalker, + UIEvent, + URL, + URLSearchParams, + VirtualConsole, VirtualConsoleLogLevelEnum, VirtualConsoleLogTypeEnum, - IVirtualConsoleLogEntry, - IVirtualConsoleLogGroup, - IVirtualConsolePrinter, - VirtualConsole, VirtualConsolePrinter, - Permissions, - PermissionStatus, - Clipboard, - ClipboardItem + WheelEvent, + Window, + XMLDocument, + XMLParser, + XMLSerializer }; diff --git a/packages/happy-dom/src/location/Location.ts b/packages/happy-dom/src/location/Location.ts index f32e47d46..59ec3d2e2 100644 --- a/packages/happy-dom/src/location/Location.ts +++ b/packages/happy-dom/src/location/Location.ts @@ -1,38 +1,29 @@ import URL from '../url/URL.js'; -import DOMException from '../exception/DOMException.js'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; /** - * + * Location. */ export default class Location extends URL { + #browserFrame: IBrowserFrame; + /** * Constructor. + * + * @param browserFrame Browser frame. + * @param url URL. */ - constructor() { - super('about:blank'); + constructor(browserFrame: IBrowserFrame, url: string) { + super(url); + this.#browserFrame = browserFrame; } /** * Override set href. */ // @ts-ignore - public set href(value: string) { - try { - super.href = this.hostname ? new URL(value, this).href : value; - } catch (e) { - if (this.hostname) { - throw new DOMException( - `Failed to construct URL from string "${value}".`, - DOMExceptionNameEnum.uriMismatchError - ); - } else { - throw new DOMException( - `Failed to construct URL from string "${value}" relative to URL "${super.href}".`, - DOMExceptionNameEnum.uriMismatchError - ); - } - } + public set href(url: string) { + this.#browserFrame.goto(url).catch((error) => this.#browserFrame.page.console.error(error)); } /** @@ -54,10 +45,7 @@ export default class Location extends URL { /** * Loads the resource at the URL provided in parameter. * - * Note: Will do the same thing as "replace()" as server-dom does not support loading the URL. - * - * @param url - * @see this.replace() + * @param url URL. */ public assign(url: string): void { this.href = url; @@ -65,10 +53,10 @@ export default class Location extends URL { /** * Reloads the resource from the current URL. - * - * Note: Will do nothing as reloading is not supported in server-dom. */ public reload(): void { - // Do nothing + this.#browserFrame + .goto(this.href) + .catch((error) => this.#browserFrame.page.console.error(error)); } } diff --git a/packages/happy-dom/src/match-media/MediaQueryItem.ts b/packages/happy-dom/src/match-media/MediaQueryItem.ts index 2ca6eea66..421e271a3 100644 --- a/packages/happy-dom/src/match-media/MediaQueryItem.ts +++ b/packages/happy-dom/src/match-media/MediaQueryItem.ts @@ -1,5 +1,6 @@ import CSSMeasurementConverter from '../css/declaration/measurement-converter/CSSMeasurementConverter.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import WindowBrowserSettingsReader from '../window/WindowBrowserSettingsReader.js'; import IMediaQueryRange from './IMediaQueryRange.js'; import IMediaQueryRule from './IMediaQueryRule.js'; import MediaQueryTypeEnum from './MediaQueryTypeEnum.js'; @@ -13,7 +14,7 @@ export default class MediaQueryItem { public rules: IMediaQueryRule[]; public ranges: IMediaQueryRange[]; private rootFontSize: string | number | null = null; - private ownerWindow: IWindow; + private ownerWindow: IBrowserWindow; /** * Constructor. @@ -27,7 +28,7 @@ export default class MediaQueryItem { * @param [options.ranges] Ranges. */ constructor(options: { - ownerWindow: IWindow; + ownerWindow: IBrowserWindow; rootFontSize?: string | number | null; mediaTypes?: MediaQueryTypeEnum[]; not?: boolean; @@ -114,7 +115,7 @@ export default class MediaQueryItem { if (mediaType === MediaQueryTypeEnum.all) { return true; } - return mediaType === this.ownerWindow.happyDOM.settings.device.mediaType; + return mediaType === WindowBrowserSettingsReader.getSettings(this.ownerWindow).device.mediaType; } /** @@ -246,7 +247,10 @@ export default class MediaQueryItem { ? this.ownerWindow.innerWidth > this.ownerWindow.innerHeight : this.ownerWindow.innerWidth < this.ownerWindow.innerHeight; case 'prefers-color-scheme': - return rule.value === this.ownerWindow.happyDOM.settings.device.prefersColorScheme; + return ( + rule.value === + WindowBrowserSettingsReader.getSettings(this.ownerWindow).device.prefersColorScheme + ); case 'any-hover': case 'hover': if (rule.value === 'none') { @@ -313,7 +317,10 @@ export default class MediaQueryItem { * @returns Value in pixels. */ private toPixels(value: string): number | null { - if (!this.ownerWindow.happyDOM.settings.disableComputedStyleRendering && value.endsWith('em')) { + if ( + !WindowBrowserSettingsReader.getSettings(this.ownerWindow).disableComputedStyleRendering && + value.endsWith('em') + ) { this.rootFontSize = this.rootFontSize || parseFloat( diff --git a/packages/happy-dom/src/match-media/MediaQueryList.ts b/packages/happy-dom/src/match-media/MediaQueryList.ts index 2b51e0e67..642f1c7fe 100644 --- a/packages/happy-dom/src/match-media/MediaQueryList.ts +++ b/packages/happy-dom/src/match-media/MediaQueryList.ts @@ -1,6 +1,7 @@ import EventTarget from '../event/EventTarget.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import Event from '../event/Event.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import IEventListener from '../event/IEventListener.js'; import MediaQueryListEvent from '../event/events/MediaQueryListEvent.js'; import IMediaQueryItem from './MediaQueryItem.js'; @@ -14,10 +15,10 @@ import MediaQueryParser from './MediaQueryParser.js'; */ export default class MediaQueryList extends EventTarget { public onchange: (event: Event) => void = null; - private _ownerWindow: IWindow; - private _items: IMediaQueryItem[] | null = null; - private _media: string; - private _rootFontSize: string | number | null = null; + #ownerWindow: IBrowserWindow; + #items: IMediaQueryItem[] | null = null; + #media: string; + #rootFontSize: string | number | null = null; /** * Constructor. @@ -27,11 +28,15 @@ export default class MediaQueryList extends EventTarget { * @param options.media Media. * @param [options.rootFontSize] Root font size. */ - constructor(options: { ownerWindow: IWindow; media: string; rootFontSize?: string | number }) { + constructor(options: { + ownerWindow: IBrowserWindow; + media: string; + rootFontSize?: string | number; + }) { super(); - this._ownerWindow = options.ownerWindow; - this._media = options.media; - this._rootFontSize = options.rootFontSize || null; + this.#ownerWindow = options.ownerWindow; + this.#media = options.media; + this.#rootFontSize = options.rootFontSize || null; } /** @@ -40,15 +45,15 @@ export default class MediaQueryList extends EventTarget { * @returns Media. */ public get media(): string { - this._items = - this._items || + this.#items = + this.#items || MediaQueryParser.parse({ - ownerWindow: this._ownerWindow, - mediaQuery: this._media, - rootFontSize: this._rootFontSize + ownerWindow: this.#ownerWindow, + mediaQuery: this.#media, + rootFontSize: this.#rootFontSize }); - return this._items.map((item) => item.toString()).join(', '); + return this.#items.map((item) => item.toString()).join(', '); } /** @@ -57,15 +62,15 @@ export default class MediaQueryList extends EventTarget { * @returns Matches. */ public get matches(): boolean { - this._items = - this._items || + this.#items = + this.#items || MediaQueryParser.parse({ - ownerWindow: this._ownerWindow, - mediaQuery: this._media, - rootFontSize: this._rootFontSize + ownerWindow: this.#ownerWindow, + mediaQuery: this.#media, + rootFontSize: this.#rootFontSize }); - for (const item of this._items) { + for (const item of this.#items) { if (!item.matches()) { return false; } @@ -108,8 +113,8 @@ export default class MediaQueryList extends EventTarget { this.dispatchEvent(new MediaQueryListEvent('change', { matches, media: this.media })); } }; - listener['_windowResizeListener'] = resizeListener; - this._ownerWindow.addEventListener('resize', resizeListener); + listener[PropertySymbol.windowResizeListener] = resizeListener; + this.#ownerWindow.addEventListener('resize', resizeListener); } } @@ -121,8 +126,11 @@ export default class MediaQueryList extends EventTarget { listener: IEventListener | ((event: Event) => void) ): void { super.removeEventListener(type, listener); - if (type === 'change' && listener['_windowResizeListener']) { - this._ownerWindow.removeEventListener('resize', listener['_windowResizeListener']); + if (type === 'change' && listener[PropertySymbol.windowResizeListener]) { + this.#ownerWindow.removeEventListener( + 'resize', + listener[PropertySymbol.windowResizeListener] + ); } } } diff --git a/packages/happy-dom/src/match-media/MediaQueryParser.ts b/packages/happy-dom/src/match-media/MediaQueryParser.ts index fd4566b97..3b9913070 100644 --- a/packages/happy-dom/src/match-media/MediaQueryParser.ts +++ b/packages/happy-dom/src/match-media/MediaQueryParser.ts @@ -1,6 +1,6 @@ import MediaQueryItem from './MediaQueryItem.js'; import MediaQueryTypeEnum from './MediaQueryTypeEnum.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; /** * Media query RegExp. @@ -44,7 +44,7 @@ export default class MediaQueryParser { * @returns Media query items. */ public static parse(options: { - ownerWindow: IWindow; + ownerWindow: IBrowserWindow; mediaQuery: string; rootFontSize?: string | number | null; }): MediaQueryItem[] { diff --git a/packages/happy-dom/src/mutation-observer/MutationListener.ts b/packages/happy-dom/src/mutation-observer/MutationListener.ts index b27678e32..2de8d46fd 100644 --- a/packages/happy-dom/src/mutation-observer/MutationListener.ts +++ b/packages/happy-dom/src/mutation-observer/MutationListener.ts @@ -1,12 +1,88 @@ import IMutationObserverInit from './IMutationObserverInit.js'; import MutationObserver from './MutationObserver.js'; import MutationRecord from './MutationRecord.js'; +import INode from '../nodes/node/INode.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; /** - * MutationObserverListener is a model for what to listen for on a Node. + * Mutation Observer Listener. */ export default class MutationListener { - public options: IMutationObserverInit = null; - public observer: MutationObserver = null; - public callback: (record: MutationRecord[], observer: MutationObserver) => void = null; + public readonly target: INode; + public options: IMutationObserverInit; + #window: IBrowserWindow; + #observer: MutationObserver; + #callback: (record: MutationRecord[], observer: MutationObserver) => void; + #records: MutationRecord[] = []; + #immediate: NodeJS.Immediate | null = null; + + /** + * Constructor. + * + * @param init Options. + * @param init.window Window. + * @param init.options Options. + * @param init.target Target. + * @param init.observer Observer. + * @param init.callback Callback. + */ + constructor(init: { + window: IBrowserWindow; + options: IMutationObserverInit; + target: INode; + observer: MutationObserver; + callback: (record: MutationRecord[], observer: MutationObserver) => void; + }) { + this.options = init.options; + this.target = init.target; + this.#window = init.window; + this.#observer = init.observer; + this.#callback = init.callback; + } + + /** + * Reports mutations. + * + * @param record Record. + */ + public report(record: MutationRecord): void { + this.#records.push(record); + if (this.#immediate) { + this.#window.cancelAnimationFrame(this.#immediate); + } + this.#immediate = this.#window.requestAnimationFrame(() => { + const records = this.#records; + if (records.length > 0) { + this.#records = []; + this.#callback(records, this.#observer); + } + }); + } + + /** + * Destroys the listener. + */ + public takeRecords(): MutationRecord[] { + if (this.#immediate) { + this.#window.cancelAnimationFrame(this.#immediate); + } + const records = this.#records; + this.#records = []; + return records; + } + + /** + * Destroys the listener. + */ + public destroy(): void { + if (this.#immediate) { + this.#window.cancelAnimationFrame(this.#immediate); + } + (this.options) = null; + (this.target) = null; + (this.#observer) = null; + (this.#callback) = null; + (this.#immediate) = null; + (this.#records) = null; + } } diff --git a/packages/happy-dom/src/mutation-observer/MutationObserver.ts b/packages/happy-dom/src/mutation-observer/MutationObserver.ts index 414158e82..620afef55 100644 --- a/packages/happy-dom/src/mutation-observer/MutationObserver.ts +++ b/packages/happy-dom/src/mutation-observer/MutationObserver.ts @@ -1,9 +1,11 @@ import DOMException from '../exception/DOMException.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import INode from '../nodes/node/INode.js'; import Node from '../nodes/node/Node.js'; import IMutationObserverInit from './IMutationObserverInit.js'; -import MutationObserverListener from './MutationListener.js'; +import MutationListener from './MutationListener.js'; import MutationRecord from './MutationRecord.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; /** * The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. @@ -11,9 +13,9 @@ import MutationRecord from './MutationRecord.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver */ export default class MutationObserver { - private callback: (records: MutationRecord[], observer: MutationObserver) => void; - private target: INode = null; - private listener: MutationObserverListener = null; + #callback: (records: MutationRecord[], observer: MutationObserver) => void; + #listeners: MutationListener[] = []; + #window: IBrowserWindow | null = null; /** * Constructor. @@ -21,7 +23,7 @@ export default class MutationObserver { * @param callback Callback. */ constructor(callback: (records: MutationRecord[], observer: MutationObserver) => void) { - this.callback = callback; + this.#callback = callback; } /** @@ -33,39 +35,90 @@ export default class MutationObserver { public observe(target: INode, options: IMutationObserverInit): void { if (!target) { throw new DOMException( - 'Failed to observer. The first parameter "target" should be of type "Node".' + `Failed to execute 'observe' on 'MutationObserver': The first parameter "target" should be of type "Node".` ); } + if (!options || (!options.childList && !options.attributes && !options.characterData)) { + throw new DOMException( + `Failed to execute 'observe' on 'MutationObserver': The options object must set at least one of 'attributes', 'characterData', or 'childList' to true.` + ); + } + + if (!this.#window) { + this.#window = target[PropertySymbol.ownerDocument] + ? target[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + : target[PropertySymbol.ownerWindow]; + } + + // Makes sure that attribute names are lower case. + // TODO: Is this correct? options = Object.assign({}, options, { attributeFilter: options.attributeFilter ? options.attributeFilter.map((name) => name.toLowerCase()) : null }); - this.target = target; - this.listener = new MutationObserverListener(); - this.listener.options = options; - this.listener.callback = this.callback.bind(this); - this.listener.observer = this; + /** + * @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#reusing_mutationobservers + */ + for (const listener of this.#listeners) { + if (listener.target === target) { + listener.options = options; + return; + } + } + + const listener = new MutationListener({ + window: this.#window, + options, + callback: this.#callback.bind(this), + observer: this, + target + }); + + this.#listeners.push(listener); - (target)._observe(this.listener); + // Stores all observers on the window object, so that they can be disconnected when the window is closed. + this.#window[PropertySymbol.mutationObservers].push(this); + + // Starts observing target node. + (target)[PropertySymbol.observe](listener); } /** * Disconnects. */ public disconnect(): void { - if (this.target) { - (this.target)._unobserve(this.listener); - this.target = null; + if (this.#listeners.length === 0) { + return; } + + const mutationObservers = this.#window[PropertySymbol.mutationObservers]; + const index = mutationObservers.indexOf(this); + + if (index !== -1) { + mutationObservers.splice(index, 1); + } + + for (const listener of this.#listeners) { + (listener.target)[PropertySymbol.unobserve](listener); + listener.destroy(); + } + + this.#listeners = []; } /** - * Takes records. + * Returns a list of all matching DOM changes that have been detected but not yet processed by the observer's callback function, leaving the mutation queue empty. + * + * @returns Records. */ - public takeRecords(): [] { - return []; + public takeRecords(): MutationRecord[] { + let records = []; + for (const listener of this.#listeners) { + records = records.concat(listener.takeRecords()); + } + return records; } } diff --git a/packages/happy-dom/src/mutation-observer/MutationRecord.ts b/packages/happy-dom/src/mutation-observer/MutationRecord.ts index 2d1b456d5..b5eb97983 100644 --- a/packages/happy-dom/src/mutation-observer/MutationRecord.ts +++ b/packages/happy-dom/src/mutation-observer/MutationRecord.ts @@ -15,4 +15,13 @@ export default class MutationRecord { public attributeName: string = null; public attributeNamespace: string = null; public oldValue: string = null; + + /** + * Constructor. + * + * @param init Options to initialize the mutation record. + */ + constructor(init?: Partial) { + Object.assign(this, init); + } } diff --git a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts index d7522c70b..20243f1ff 100644 --- a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts +++ b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts @@ -1,4 +1,5 @@ import INamedNodeMap from './INamedNodeMap.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IAttr from '../nodes/attr/IAttr.js'; import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; @@ -11,7 +12,7 @@ import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; export default class NamedNodeMap implements INamedNodeMap { [index: number]: IAttr; public length = 0; - protected _namedItems: { [k: string]: IAttr } = {}; + protected [PropertySymbol.namedItems]: { [k: string]: IAttr } = {}; /** * Returns string. @@ -49,7 +50,7 @@ export default class NamedNodeMap implements INamedNodeMap { * @returns Item. */ public getNamedItem(name: string): IAttr | null { - return this._namedItems[name] || null; + return this[PropertySymbol.namedItems][name] || null; } /** @@ -62,12 +63,16 @@ export default class NamedNodeMap implements INamedNodeMap { public getNamedItemNS(namespace: string, localName: string): IAttr | null { const attribute = this.getNamedItem(localName); - if (attribute && attribute.namespaceURI === namespace && attribute.localName === localName) { + if ( + attribute && + attribute[PropertySymbol.namespaceURI] === namespace && + attribute.localName === localName + ) { return attribute; } for (let i = 0, max = this.length; i < max; i++) { - if (this[i].namespaceURI === namespace && this[i].localName === localName) { + if (this[i][PropertySymbol.namespaceURI] === namespace && this[i].localName === localName) { return this[i]; } } @@ -82,7 +87,7 @@ export default class NamedNodeMap implements INamedNodeMap { * @returns Replaced item. */ public setNamedItem(item: IAttr): IAttr | null { - return this._setNamedItemWithoutConsequences(item); + return this[PropertySymbol.setNamedItemWithoutConsequences](item); } /** @@ -104,7 +109,7 @@ export default class NamedNodeMap implements INamedNodeMap { * @returns Removed item. */ public removeNamedItem(name: string): IAttr { - const item = this._removeNamedItem(name); + const item = this[PropertySymbol.removeNamedItem](name); if (!item) { throw new DOMException( `Failed to execute 'removeNamedItem' on 'NamedNodeMap': No item with name '${name}' was found.`, @@ -124,7 +129,7 @@ export default class NamedNodeMap implements INamedNodeMap { public removeNamedItemNS(namespace: string, localName: string): IAttr | null { const attribute = this.getNamedItemNS(namespace, localName); if (attribute) { - return this.removeNamedItem(attribute.name); + return this.removeNamedItem(attribute[PropertySymbol.name]); } return null; } @@ -135,21 +140,21 @@ export default class NamedNodeMap implements INamedNodeMap { * @param item Item. * @returns Replaced item. */ - public _setNamedItemWithoutConsequences(item: IAttr): IAttr | null { - if (item.name) { - const replacedItem = this._namedItems[item.name] || null; + public [PropertySymbol.setNamedItemWithoutConsequences](item: IAttr): IAttr | null { + if (item[PropertySymbol.name]) { + const replacedItem = this[PropertySymbol.namedItems][item[PropertySymbol.name]] || null; - this._namedItems[item.name] = item; + this[PropertySymbol.namedItems][item[PropertySymbol.name]] = item; if (replacedItem) { - this._removeNamedItemIndex(replacedItem); + this[PropertySymbol.removeNamedItemIndex](replacedItem); } this[this.length] = item; this.length++; - if (this._isValidPropertyName(item.name)) { - this[item.name] = item; + if (this[PropertySymbol.isValidPropertyName](item[PropertySymbol.name])) { + this[item[PropertySymbol.name]] = item; } return replacedItem; @@ -163,8 +168,8 @@ export default class NamedNodeMap implements INamedNodeMap { * @param name Name of item. * @returns Removed item, or null if it didn't exist. */ - public _removeNamedItem(name: string): IAttr | null { - return this._removeNamedItemWithoutConsequences(name); + public [PropertySymbol.removeNamedItem](name: string): IAttr | null { + return this[PropertySymbol.removeNamedItemWithoutConsequences](name); } /** @@ -173,20 +178,20 @@ export default class NamedNodeMap implements INamedNodeMap { * @param name Name of item. * @returns Removed item, or null if it didn't exist. */ - public _removeNamedItemWithoutConsequences(name: string): IAttr | null { - const removedItem = this._namedItems[name] || null; + public [PropertySymbol.removeNamedItemWithoutConsequences](name: string): IAttr | null { + const removedItem = this[PropertySymbol.namedItems][name] || null; if (!removedItem) { return null; } - this._removeNamedItemIndex(removedItem); + this[PropertySymbol.removeNamedItemIndex](removedItem); if (this[name] === removedItem) { delete this[name]; } - delete this._namedItems[name]; + delete this[PropertySymbol.namedItems][name]; return removedItem; } @@ -196,7 +201,7 @@ export default class NamedNodeMap implements INamedNodeMap { * * @param item Item. */ - protected _removeNamedItemIndex(item: IAttr): void { + protected [PropertySymbol.removeNamedItemIndex](item: IAttr): void { for (let i = 0; i < this.length; i++) { if (this[i] === item) { for (let b = i; b < this.length; b++) { @@ -218,7 +223,7 @@ export default class NamedNodeMap implements INamedNodeMap { * @param name Name. * @returns True if the property name is valid. */ - protected _isValidPropertyName(name: string): boolean { + protected [PropertySymbol.isValidPropertyName](name: string): boolean { return ( !this.constructor.prototype.hasOwnProperty(name) && (isNaN(Number(name)) || name.includes('.')) diff --git a/packages/happy-dom/src/navigator/Navigator.ts b/packages/happy-dom/src/navigator/Navigator.ts index 65bfec7e5..a9300746d 100644 --- a/packages/happy-dom/src/navigator/Navigator.ts +++ b/packages/happy-dom/src/navigator/Navigator.ts @@ -1,8 +1,9 @@ import MimeTypeArray from './MimeTypeArray.js'; import PluginArray from './PluginArray.js'; -import IWindow from '../window/IWindow.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; import Permissions from '../permissions/Permissions.js'; import Clipboard from '../clipboard/Clipboard.js'; +import WindowBrowserSettingsReader from '../window/WindowBrowserSettingsReader.js'; import Blob from '../file/Blob.js'; import FormData from '../form-data/FormData.js'; @@ -15,7 +16,7 @@ import FormData from '../form-data/FormData.js'; * https://html.spec.whatwg.org/multipage/system-state.html#dom-navigator. */ export default class Navigator { - #ownerWindow: IWindow; + #ownerWindow: IBrowserWindow; #clipboard: Clipboard; #permissions: Permissions; @@ -24,7 +25,7 @@ export default class Navigator { * * @param ownerWindow Owner window. */ - constructor(ownerWindow: IWindow) { + constructor(ownerWindow: IBrowserWindow) { this.#ownerWindow = ownerWindow; this.#clipboard = new Clipboard(ownerWindow); this.#permissions = new Permissions(); @@ -76,7 +77,7 @@ export default class Navigator { * Maximum number of simultaneous touch contact points are supported by the current device. */ public get maxTouchPoints(): number { - return 0; + return WindowBrowserSettingsReader.getSettings(this.#ownerWindow).navigator.maxTouchPoints; } /** @@ -153,7 +154,7 @@ export default class Navigator { * "appCodeName/appVersion number (Platform; Security; OS-or-CPU; Localization; rv: revision-version-number) product/productSub Application-Name Application-Name-version". */ public get userAgent(): string { - return this.#ownerWindow.happyDOM.settings.navigator.userAgent; + return WindowBrowserSettingsReader.getSettings(this.#ownerWindow).navigator.userAgent; } /** diff --git a/packages/happy-dom/src/nodes/NodeFactory.ts b/packages/happy-dom/src/nodes/NodeFactory.ts new file mode 100644 index 000000000..80a8dde76 --- /dev/null +++ b/packages/happy-dom/src/nodes/NodeFactory.ts @@ -0,0 +1,34 @@ +import IDocument from '../nodes/document/IDocument.js'; + +/** + * Node factory used for setting the owner document to nodes. + */ +export default class NodeFactory { + public static ownerDocuments: IDocument[] = []; + + /** + * Creates a node instance with the given owner document. + * + * @param ownerDocument Owner document. + * @param nodeClass Node class. + * @param [args] Node arguments. + * @returns Node instance. + */ + public static createNode( + ownerDocument: IDocument, + nodeClass: new (...args) => T, + ...args: any[] + ): T { + this.ownerDocuments.push(ownerDocument); + return new nodeClass(...args); + } + + /** + * Pulls an owner document from the queue. + * + * @returns Document. + */ + public static pullOwnerDocument(): IDocument { + return this.ownerDocuments.pop(); + } +} diff --git a/packages/happy-dom/src/nodes/attr/Attr.ts b/packages/happy-dom/src/nodes/attr/Attr.ts index 9531f8cf8..08da10517 100644 --- a/packages/happy-dom/src/nodes/attr/Attr.ts +++ b/packages/happy-dom/src/nodes/attr/Attr.ts @@ -1,6 +1,8 @@ import IElement from '../element/IElement.js'; import Node from '../node/Node.js'; import IAttr from './IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * Attribute node interface. @@ -8,20 +10,57 @@ import IAttr from './IAttr.js'; * Reference: https://developer.mozilla.org/en-US/docs/Web/API/Attr. */ export default class Attr extends Node implements IAttr { - public readonly nodeType = Node.ATTRIBUTE_NODE; - public value: string = null; - public name: string = null; - public namespaceURI: string = null; + public [PropertySymbol.nodeType] = NodeTypeEnum.attributeNode; + public [PropertySymbol.namespaceURI]: string | null = null; + public [PropertySymbol.name]: string | null = null; + public [PropertySymbol.value]: string | null = null; + public [PropertySymbol.specified] = true; + public [PropertySymbol.ownerElement]: IElement | null = null; /** - * @deprecated + * Returns specified. + * + * @returns Specified. + */ + public get specified(): boolean { + return this[PropertySymbol.specified]; + } + + /** + * Returns owner element. + * + * @returns Owner element. + */ + public get ownerElement(): IElement | null { + return this[PropertySymbol.ownerElement]; + } + + /** + * Returns value. + * + * @returns Value. + */ + public get value(): string { + return this[PropertySymbol.value]; + } + + /** + * Sets value. + * + * @param value Value. */ - public readonly ownerElement: IElement = null; + public set value(value: string) { + this[PropertySymbol.value] = value; + } /** - * @deprecated + * Returns name. + * + * @returns Name. */ - public readonly specified = true; + public get name(): string { + return this[PropertySymbol.name]; + } /** * Returns local name. @@ -29,7 +68,7 @@ export default class Attr extends Node implements IAttr { * @returns Local name. */ public get localName(): string { - return this.name ? this.name.split(':').reverse()[0] : null; + return this[PropertySymbol.name] ? this[PropertySymbol.name].split(':').reverse()[0] : null; } /** @@ -38,13 +77,22 @@ export default class Attr extends Node implements IAttr { * @returns Prefix. */ public get prefix(): string { - return this.name ? this.name.split(':')[0] : null; + return this[PropertySymbol.name] ? this[PropertySymbol.name].split(':')[0] : null; } /** * @override */ public get textContent(): string { - return this.value; + return this[PropertySymbol.value]; + } + + /** + * Returns namespace URI. + * + * @returns Namespace URI. + */ + public get namespaceURI(): string | null { + return this[PropertySymbol.namespaceURI]; } } diff --git a/packages/happy-dom/src/nodes/character-data/CharacterData.ts b/packages/happy-dom/src/nodes/character-data/CharacterData.ts index 1045cdf23..a78401e07 100644 --- a/packages/happy-dom/src/nodes/character-data/CharacterData.ts +++ b/packages/happy-dom/src/nodes/character-data/CharacterData.ts @@ -1,4 +1,5 @@ import Node from '../node/Node.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import CharacterDataUtility from './CharacterDataUtility.js'; import ICharacterData from './ICharacterData.js'; import IElement from '../element/IElement.js'; @@ -14,7 +15,7 @@ import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; * https://developer.mozilla.org/en-US/docs/Web/API/CharacterData. */ export default abstract class CharacterData extends Node implements ICharacterData { - protected _data = ''; + public [PropertySymbol.data] = ''; /** * Constructor. @@ -25,7 +26,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa super(); if (data) { - this._data = data; + this[PropertySymbol.data] = data; } } @@ -35,7 +36,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa * @returns Text content. */ public get length(): number { - return this._data.length; + return this[PropertySymbol.data].length; } /** @@ -44,7 +45,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa * @returns Text content. */ public get data(): string { - return this._data; + return this[PropertySymbol.data]; } /** @@ -53,22 +54,24 @@ export default abstract class CharacterData extends Node implements ICharacterDa * @param textContent Text content. */ public set data(data: string) { - const oldValue = this._data; - this._data = String(data); + const oldValue = this[PropertySymbol.data]; + this[PropertySymbol.data] = String(data); - if (this.isConnected) { - this.ownerDocument['_cacheID']++; + if (this[PropertySymbol.isConnected]) { + this[PropertySymbol.ownerDocument][PropertySymbol.cacheID]++; } // MutationObserver - if (this._observers.length > 0) { - for (const observer of this._observers) { + if (this[PropertySymbol.observers].length > 0) { + for (const observer of this[PropertySymbol.observers]) { if (observer.options.characterData) { - const record = new MutationRecord(); - record.target = this; - record.type = MutationTypeEnum.characterData; - record.oldValue = observer.options.characterDataOldValue ? oldValue : null; - observer.callback([record], observer.observer); + observer.report( + new MutationRecord({ + target: this, + type: MutationTypeEnum.characterData, + oldValue: observer.options.characterDataOldValue ? oldValue : null + }) + ); } } } @@ -80,7 +83,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa * @returns Text content. */ public get textContent(): string { - return this._data; + return this[PropertySymbol.data]; } /** @@ -98,7 +101,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa * @returns Node value. */ public get nodeValue(): string { - return this._data; + return this[PropertySymbol.data]; } /** @@ -221,7 +224,7 @@ export default abstract class CharacterData extends Node implements ICharacterDa */ public cloneNode(deep = false): ICharacterData { const clone = super.cloneNode(deep); - clone._data = this._data; + clone[PropertySymbol.data] = this[PropertySymbol.data]; return clone; } } diff --git a/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts b/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts index 96052093f..8c174856f 100644 --- a/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts +++ b/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts @@ -1,4 +1,5 @@ import DOMException from '../../exception/DOMException.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import XMLParser from '../../xml-parser/XMLParser.js'; import DocumentFragment from '../document-fragment/DocumentFragment.js'; import Document from '../document/Document.js'; @@ -16,8 +17,8 @@ export default class ChildNodeUtility { * @param childNode Child node. */ public static remove(childNode: IChildNode): void { - if (childNode.parentNode) { - childNode.parentNode.removeChild(childNode); + if (childNode[PropertySymbol.parentNode]) { + childNode[PropertySymbol.parentNode].removeChild(childNode); } } @@ -28,7 +29,7 @@ export default class ChildNodeUtility { * @param nodes List of Node or DOMString. */ public static replaceWith(childNode: IChildNode, ...nodes: (INode | string)[]): void { - const parent = childNode.parentNode; + const parent = childNode[PropertySymbol.parentNode]; if (!parent) { throw new DOMException('This element has no parent node.'); @@ -37,8 +38,8 @@ export default class ChildNodeUtility { for (const node of nodes) { if (typeof node === 'string') { const newChildNodes = (( - XMLParser.parse(childNode.ownerDocument, node) - ))._childNodes.slice(); + XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) + ))[PropertySymbol.childNodes].slice(); for (const newChildNode of newChildNodes) { parent.insertBefore(newChildNode, childNode); } @@ -57,7 +58,7 @@ export default class ChildNodeUtility { * @param nodes List of Node or DOMString. */ public static before(childNode: IChildNode, ...nodes: (string | INode)[]): void { - const parent = childNode.parentNode; + const parent = childNode[PropertySymbol.parentNode]; if (!parent) { return; @@ -66,8 +67,8 @@ export default class ChildNodeUtility { for (const node of nodes) { if (typeof node === 'string') { const newChildNodes = (( - XMLParser.parse(childNode.ownerDocument, node) - ))._childNodes.slice(); + XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) + ))[PropertySymbol.childNodes].slice(); for (const newChildNode of newChildNodes) { parent.insertBefore(newChildNode, childNode); } @@ -84,7 +85,7 @@ export default class ChildNodeUtility { * @param nodes List of Node or DOMString. */ public static after(childNode: IChildNode, ...nodes: (string | INode)[]): void { - const parent = childNode.parentNode; + const parent = childNode[PropertySymbol.parentNode]; if (!parent) { return; @@ -95,8 +96,8 @@ export default class ChildNodeUtility { for (const node of nodes) { if (typeof node === 'string') { const newChildNodes = (( - XMLParser.parse(childNode.ownerDocument, node) - ))._childNodes.slice(); + XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) + ))[PropertySymbol.childNodes].slice(); for (const newChildNode of newChildNodes) { if (!nextSibling) { parent.appendChild(newChildNode); diff --git a/packages/happy-dom/src/nodes/child-node/NonDocumentChildNodeUtility.ts b/packages/happy-dom/src/nodes/child-node/NonDocumentChildNodeUtility.ts index 3e919fe01..64b510f69 100644 --- a/packages/happy-dom/src/nodes/child-node/NonDocumentChildNodeUtility.ts +++ b/packages/happy-dom/src/nodes/child-node/NonDocumentChildNodeUtility.ts @@ -1,6 +1,7 @@ -import Element from '../element/Element.js'; import IElement from '../element/IElement.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; import INonDocumentTypeChildNode from './INonDocumentTypeChildNode.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * Non Document Child node utility. @@ -14,7 +15,7 @@ export default class NonDocumentChildNodeUtility { */ public static previousElementSibling(childNode: INonDocumentTypeChildNode): IElement { let sibling = childNode.previousSibling; - while (sibling && sibling.nodeType !== Element.ELEMENT_NODE) { + while (sibling && sibling[PropertySymbol.nodeType] !== NodeTypeEnum.elementNode) { sibling = sibling.previousSibling; } return sibling; @@ -28,7 +29,7 @@ export default class NonDocumentChildNodeUtility { */ public static nextElementSibling(childNode: INonDocumentTypeChildNode): IElement { let sibling = childNode.nextSibling; - while (sibling && sibling.nodeType !== Element.ELEMENT_NODE) { + while (sibling && sibling[PropertySymbol.nodeType] !== NodeTypeEnum.elementNode) { sibling = sibling.nextSibling; } return sibling; diff --git a/packages/happy-dom/src/nodes/comment/Comment.ts b/packages/happy-dom/src/nodes/comment/Comment.ts index 9057a5cb0..07d96b5c7 100644 --- a/packages/happy-dom/src/nodes/comment/Comment.ts +++ b/packages/happy-dom/src/nodes/comment/Comment.ts @@ -1,12 +1,13 @@ -import Node from '../node/Node.js'; import CharacterData from '../character-data/CharacterData.js'; import IComment from './IComment.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * Comment node. */ export default class Comment extends CharacterData implements IComment { - public readonly nodeType = Node.COMMENT_NODE; + public [PropertySymbol.nodeType] = NodeTypeEnum.commentNode; /** * Node name. diff --git a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts index 9b918f8da..475b704e2 100644 --- a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts +++ b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts @@ -1,4 +1,5 @@ import Node from '../node/Node.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IElement from '../element/IElement.js'; import QuerySelector from '../../query-selector/QuerySelector.js'; import ParentNodeUtility from '../parent-node/ParentNodeUtility.js'; @@ -8,20 +9,21 @@ import IHTMLCollection from '../element/IHTMLCollection.js'; import ElementUtility from '../element/ElementUtility.js'; import HTMLCollection from '../element/HTMLCollection.js'; import INodeList from '../node/INodeList.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * DocumentFragment. */ export default class DocumentFragment extends Node implements IDocumentFragment { - public nodeType = Node.DOCUMENT_FRAGMENT_NODE; - public readonly _children: IHTMLCollection = new HTMLCollection(); - public _rootNode: INode = this; + public readonly [PropertySymbol.children]: IHTMLCollection = new HTMLCollection(); + public [PropertySymbol.rootNode]: INode = this; + public [PropertySymbol.nodeType] = NodeTypeEnum.documentFragmentNode; /** * Returns the document fragment children. */ public get children(): IHTMLCollection { - return this._children; + return this[PropertySymbol.children]; } /** @@ -30,7 +32,7 @@ export default class DocumentFragment extends Node implements IDocumentFragment * @returns Element. */ public get childElementCount(): number { - return this._children.length; + return this[PropertySymbol.children].length; } /** @@ -39,7 +41,7 @@ export default class DocumentFragment extends Node implements IDocumentFragment * @returns Element. */ public get firstElementChild(): IElement { - return this._children[0] ?? null; + return this[PropertySymbol.children][0] ?? null; } /** @@ -48,7 +50,7 @@ export default class DocumentFragment extends Node implements IDocumentFragment * @returns Element. */ public get lastElementChild(): IElement { - return this._children[this._children.length - 1] ?? null; + return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; } /** @@ -58,8 +60,11 @@ export default class DocumentFragment extends Node implements IDocumentFragment */ public get textContent(): string { let result = ''; - for (const childNode of this._childNodes) { - if (childNode.nodeType === Node.ELEMENT_NODE || childNode.nodeType === Node.TEXT_NODE) { + for (const childNode of this[PropertySymbol.childNodes]) { + if ( + childNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode || + childNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode + ) { result += childNode.textContent; } } @@ -72,11 +77,11 @@ export default class DocumentFragment extends Node implements IDocumentFragment * @param textContent Text content. */ public set textContent(textContent: string) { - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } if (textContent) { - this.appendChild(this.ownerDocument.createTextNode(textContent)); + this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(textContent)); } } @@ -148,9 +153,9 @@ export default class DocumentFragment extends Node implements IDocumentFragment const clone = super.cloneNode(deep); if (deep) { - for (const node of clone._childNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - clone._children.push(node); + for (const node of clone[PropertySymbol.childNodes]) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { + clone[PropertySymbol.children].push(node); } } } diff --git a/packages/happy-dom/src/nodes/document-type/DocumentType.ts b/packages/happy-dom/src/nodes/document-type/DocumentType.ts index 92121158e..13244147e 100644 --- a/packages/happy-dom/src/nodes/document-type/DocumentType.ts +++ b/packages/happy-dom/src/nodes/document-type/DocumentType.ts @@ -1,13 +1,42 @@ import Node from '../node/Node.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * DocumentType. */ export default class DocumentType extends Node { - public readonly nodeType = Node.DOCUMENT_TYPE_NODE; - public name: string = null; - public publicId = ''; - public systemId = ''; + public [PropertySymbol.nodeType] = NodeTypeEnum.documentTypeNode; + public [PropertySymbol.name] = ''; + public [PropertySymbol.publicId] = ''; + public [PropertySymbol.systemId] = ''; + + /** + * Returns name. + * + * @returns Name. + */ + public get name(): string { + return this[PropertySymbol.name]; + } + + /** + * Returns public ID. + * + * @returns Public ID. + */ + public get publicId(): string { + return this[PropertySymbol.publicId]; + } + + /** + * Returns system ID. + * + * @returns System ID. + */ + public get systemId(): string { + return this[PropertySymbol.systemId]; + } /** * Node name. @@ -36,9 +65,9 @@ export default class DocumentType extends Node { */ public cloneNode(deep = false): DocumentType { const clone = super.cloneNode(deep); - clone.name = this.name; - clone.publicId = this.publicId; - clone.systemId = this.systemId; + clone[PropertySymbol.name] = this[PropertySymbol.name]; + clone[PropertySymbol.publicId] = this[PropertySymbol.publicId]; + clone[PropertySymbol.systemId] = this[PropertySymbol.systemId]; return clone; } } diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 1000dccff..b0db19604 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -1,8 +1,7 @@ import Element from '../element/Element.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLUnknownElement from '../html-unknown-element/HTMLUnknownElement.js'; -import Text from '../text/Text.js'; -import Comment from '../comment/Comment.js'; -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import Node from '../node/Node.js'; import NodeIterator from '../../tree-walker/NodeIterator.js'; import TreeWalker from '../../tree-walker/TreeWalker.js'; @@ -12,7 +11,6 @@ import Event from '../../event/Event.js'; import DOMImplementation from '../../dom-implementation/DOMImplementation.js'; import ElementTag from '../../config/ElementTag.js'; import INodeFilter from '../../tree-walker/INodeFilter.js'; -import Attr from '../attr/Attr.js'; import NamespaceURI from '../../config/NamespaceURI.js'; import DocumentType from '../document-type/DocumentType.js'; import ParentNodeUtility from '../parent-node/ParentNodeUtility.js'; @@ -20,7 +18,6 @@ import QuerySelector from '../../query-selector/QuerySelector.js'; import IDocument from './IDocument.js'; import CSSStyleSheet from '../../css/CSSStyleSheet.js'; import DOMException from '../../exception/DOMException.js'; -import CookieJar from '../../cookie/CookieJar.js'; import IElement from '../element/IElement.js'; import IHTMLScriptElement from '../html-script-element/IHTMLScriptElement.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; @@ -35,7 +32,6 @@ import IHTMLCollection from '../element/IHTMLCollection.js'; import IHTMLLinkElement from '../html-link-element/IHTMLLinkElement.js'; import IHTMLStyleElement from '../html-style-element/IHTMLStyleElement.js'; import DocumentReadyStateEnum from './DocumentReadyStateEnum.js'; -import DocumentReadyStateManager from './DocumentReadyStateManager.js'; import Location from '../../location/Location.js'; import Selection from '../../selection/Selection.js'; import IShadowRoot from '../shadow-root/IShadowRoot.js'; @@ -43,11 +39,13 @@ import Range from '../../range/Range.js'; import IHTMLBaseElement from '../html-base-element/IHTMLBaseElement.js'; import IAttr from '../attr/IAttr.js'; import IProcessingInstruction from '../processing-instruction/IProcessingInstruction.js'; -import ProcessingInstruction from '../processing-instruction/ProcessingInstruction.js'; import ElementUtility from '../element/ElementUtility.js'; import HTMLCollection from '../element/HTMLCollection.js'; import VisibilityStateEnum from './VisibilityStateEnum.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; +import CookieStringUtility from '../../cookie/urilities/CookieStringUtility.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import NodeFactory from '../NodeFactory.js'; const PROCESSING_INSTRUCTION_TARGET_REGEXP = /^[a-z][a-z0-9-]+$/; @@ -55,31 +53,28 @@ const PROCESSING_INSTRUCTION_TARGET_REGEXP = /^[a-z][a-z0-9-]+$/; * Document. */ export default class Document extends Node implements IDocument { - public static _defaultView: IWindow = null; - public static _windowClass: {} | null = null; - public nodeType = Node.DOCUMENT_NODE; - public adoptedStyleSheets: CSSStyleSheet[] = []; - public implementation: DOMImplementation; - public readonly readyState = DocumentReadyStateEnum.interactive; - public readonly isConnected: boolean = true; - public readonly defaultView: IWindow; - public readonly referrer = ''; - public readonly _windowClass: {} | null = null; - public readonly _readyStateManager: DocumentReadyStateManager; - public readonly _children: IHTMLCollection = new HTMLCollection(); - public _activeElement: IHTMLElement = null; - public _nextActiveElement: IHTMLElement = null; - public _currentScript: IHTMLScriptElement = null; - + // Internal properties + public [PropertySymbol.children]: IHTMLCollection = new HTMLCollection(); + public [PropertySymbol.activeElement]: IHTMLElement = null; + public [PropertySymbol.nextActiveElement]: IHTMLElement = null; + public [PropertySymbol.currentScript]: IHTMLScriptElement = null; + public [PropertySymbol.rootNode] = this; // Used as an unique identifier which is updated whenever the DOM gets modified. - public _cacheID = 0; - // Public in order to be accessible by the fetch and xhr. - public _cookie = new CookieJar(); - - protected _isFirstWrite = true; - protected _isFirstWriteAfterOpen = false; - - private _selection: Selection = null; + public [PropertySymbol.cacheID] = 0; + public [PropertySymbol.isFirstWrite] = true; + public [PropertySymbol.isFirstWriteAfterOpen] = false; + public [PropertySymbol.nodeType] = NodeTypeEnum.documentNode; + public [PropertySymbol.isConnected] = true; + public [PropertySymbol.adoptedStyleSheets]: CSSStyleSheet[] = []; + public [PropertySymbol.implementation] = new DOMImplementation(this); + public [PropertySymbol.readyState] = DocumentReadyStateEnum.interactive; + public [PropertySymbol.referrer] = ''; + public [PropertySymbol.defaultView]: IBrowserWindow | null = null; + public [PropertySymbol.ownerWindow]: IBrowserWindow; + + // Private properties + #selection: Selection = null; + #browserFrame: IBrowserFrame; // Events public onreadystatechange: (event: Event) => void = null; @@ -193,36 +188,77 @@ export default class Document extends Node implements IDocument { public onbeforematch: (event: Event) => void = null; /** - * Creates an instance of Document. + * Constructor. * + * @param injected Injected properties. + * @param injected.browserFrame Browser frame. + * @param injected.window Window. */ - constructor() { + constructor(injected: { browserFrame: IBrowserFrame; window: IBrowserWindow }) { super(); + this.#browserFrame = injected.browserFrame; + this[PropertySymbol.ownerWindow] = injected.window; + } - this.defaultView = (this.constructor)._defaultView; - this.implementation = new DOMImplementation(this); + /** + * Returns adopted style sheets. + * + * @returns Adopted style sheets. + */ + public get adoptedStyleSheets(): CSSStyleSheet[] { + return this[PropertySymbol.adoptedStyleSheets]; + } - this._windowClass = (this.constructor)._windowClass; - this._readyStateManager = new DocumentReadyStateManager(this.defaultView); - this._rootNode = this; + /** + * Sets adopted style sheets. + * + * @param value Adopted style sheets. + */ + public set adoptedStyleSheets(value: CSSStyleSheet[]) { + this[PropertySymbol.adoptedStyleSheets] = value; + } - const doctype = this.implementation.createDocumentType('html', '', ''); - const documentElement = this.createElement('html'); - const bodyElement = this.createElement('body'); - const headElement = this.createElement('head'); + /** + * Returns DOM implementation. + * + * @returns DOM implementation. + */ + public get implementation(): DOMImplementation { + return this[PropertySymbol.implementation]; + } - this.appendChild(doctype); - this.appendChild(documentElement); + /** + * Returns document ready state. + * + * @returns Document ready state. + */ + public get readyState(): DocumentReadyStateEnum { + return this[PropertySymbol.readyState]; + } - documentElement.appendChild(headElement); - documentElement.appendChild(bodyElement); + /** + * Returns referrer. + * + * @returns Referrer. + */ + public get referrer(): string { + return this[PropertySymbol.referrer]; + } + + /** + * Returns default view. + * + * @returns Default view. + */ + public get defaultView(): IBrowserWindow | null { + return this[PropertySymbol.defaultView]; } /** * Returns document children. */ public get children(): IHTMLCollection { - return this._children; + return this[PropertySymbol.children]; } /** @@ -286,7 +322,7 @@ export default class Document extends Node implements IDocument { * @returns Element. */ public get childElementCount(): number { - return this._children.length; + return this[PropertySymbol.children].length; } /** @@ -295,7 +331,7 @@ export default class Document extends Node implements IDocument { * @returns Element. */ public get firstElementChild(): IElement { - return this._children[0] ?? null; + return this[PropertySymbol.children][0] ?? null; } /** @@ -304,7 +340,7 @@ export default class Document extends Node implements IDocument { * @returns Element. */ public get lastElementChild(): IElement { - return this._children[this._children.length - 1] ?? null; + return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; } /** @@ -313,7 +349,12 @@ export default class Document extends Node implements IDocument { * @returns Cookie. */ public get cookie(): string { - return this._cookie.getCookieString(this.defaultView.location, true); + return CookieStringUtility.cookiesToString( + this.#browserFrame.page.context.cookieContainer.getCookies( + this[PropertySymbol.ownerWindow].location, + true + ) + ); } /** @@ -322,7 +363,9 @@ export default class Document extends Node implements IDocument { * @param cookie Cookie string. */ public set cookie(cookie: string) { - this._cookie.addCookieString(this.defaultView.location, cookie); + this.#browserFrame.page.context.cookieContainer.addCookies([ + CookieStringUtility.stringToCookie(this[PropertySymbol.ownerWindow].location, cookie) + ]); } /** @@ -349,7 +392,7 @@ export default class Document extends Node implements IDocument { * @returns Document type. */ public get doctype(): IDocumentType { - for (const node of this._childNodes) { + for (const node of this[PropertySymbol.childNodes]) { if (node instanceof DocumentType) { return node; } @@ -400,22 +443,28 @@ export default class Document extends Node implements IDocument { * @returns Active element. */ public get activeElement(): IHTMLElement { - if (this._activeElement && !this._activeElement.isConnected) { - this._activeElement = null; + if ( + this[PropertySymbol.activeElement] && + !this[PropertySymbol.activeElement][PropertySymbol.isConnected] + ) { + this[PropertySymbol.activeElement] = null; } - if (this._activeElement && this._activeElement instanceof Element) { + if ( + this[PropertySymbol.activeElement] && + this[PropertySymbol.activeElement] instanceof Element + ) { let rootNode: IShadowRoot | IDocument = ( - this._activeElement.getRootNode() + this[PropertySymbol.activeElement].getRootNode() ); - let activeElement: IHTMLElement = this._activeElement; + let activeElement: IHTMLElement = this[PropertySymbol.activeElement]; while (rootNode !== this) { activeElement = (rootNode).host; rootNode = activeElement ? activeElement.getRootNode() : this; } return activeElement; } - return this._activeElement || this.body || this.documentElement || null; + return this[PropertySymbol.activeElement] || this.body || this.documentElement || null; } /** @@ -433,7 +482,7 @@ export default class Document extends Node implements IDocument { * @returns Location. */ public get location(): Location { - return this.defaultView.location; + return this[PropertySymbol.ownerWindow].location; } /** @@ -456,7 +505,7 @@ export default class Document extends Node implements IDocument { if (element) { return element.href; } - return this.defaultView.location.href; + return this[PropertySymbol.ownerWindow].location.href; } /** @@ -465,7 +514,7 @@ export default class Document extends Node implements IDocument { * @returns the URL of the current document. * */ public get URL(): string { - return this.defaultView.location.href; + return this[PropertySymbol.ownerWindow].location.href; } /** @@ -509,7 +558,7 @@ export default class Document extends Node implements IDocument { * @returns the currently executing script element. */ public get currentScript(): IHTMLScriptElement { - return this._currentScript; + return this[PropertySymbol.currentScript]; } /** @@ -612,7 +661,7 @@ export default class Document extends Node implements IDocument { name: string ): INodeList => { const matches = new NodeList(); - for (const child of (parentNode)._children) { + for (const child of (parentNode)[PropertySymbol.children]) { if (child.getAttributeNS(null, 'name') === name) { matches.push(child); } @@ -633,14 +682,12 @@ export default class Document extends Node implements IDocument { * @returns Cloned node. */ public cloneNode(deep = false): IDocument { - (this.constructor)._defaultView = this.defaultView; - const clone = super.cloneNode(deep); if (deep) { - for (const node of clone._childNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - clone._children.push(node); + for (const node of clone[PropertySymbol.childNodes]) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { + clone[PropertySymbol.children].push(node); } } } @@ -686,23 +733,23 @@ export default class Document extends Node implements IDocument { public write(html: string): void { const root = XMLParser.parse(this, html, { evaluateScripts: true }); - if (this._isFirstWrite || this._isFirstWriteAfterOpen) { - if (this._isFirstWrite) { - if (!this._isFirstWriteAfterOpen) { + if (this[PropertySymbol.isFirstWrite] || this[PropertySymbol.isFirstWriteAfterOpen]) { + if (this[PropertySymbol.isFirstWrite]) { + if (!this[PropertySymbol.isFirstWriteAfterOpen]) { this.open(); } - this._isFirstWrite = false; + this[PropertySymbol.isFirstWrite] = false; } - this._isFirstWriteAfterOpen = false; + this[PropertySymbol.isFirstWriteAfterOpen] = false; let documentElement = null; let documentTypeNode = null; - for (const node of root._childNodes) { + for (const node of root[PropertySymbol.childNodes]) { if (node['tagName'] === 'HTML') { documentElement = node; - } else if (node.nodeType === NodeTypeEnum.documentTypeNode) { + } else if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { documentTypeNode = node; } @@ -718,11 +765,23 @@ export default class Document extends Node implements IDocument { } this.appendChild(documentElement); + + const head = ParentNodeUtility.getElementByTagName(this, 'head'); + let body = ParentNodeUtility.getElementByTagName(this, 'body'); + + if (!body) { + body = this.createElement('body'); + documentElement.appendChild(this.createElement('body')); + } + + if (!head) { + documentElement.insertBefore(this.createElement('head'), body); + } } else { const rootBody = ParentNodeUtility.getElementByTagName(root, 'body'); const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (rootBody && body) { - for (const child of rootBody._childNodes.slice()) { + for (const child of rootBody[PropertySymbol.childNodes].slice()) { body.appendChild(child); } } @@ -731,8 +790,11 @@ export default class Document extends Node implements IDocument { // Remaining nodes outside the element are added to the element. const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (body) { - for (const child of root._childNodes.slice()) { - if (child['tagName'] !== 'HTML' && child.nodeType !== NodeTypeEnum.documentTypeNode) { + for (const child of root[PropertySymbol.childNodes].slice()) { + if ( + child['tagName'] !== 'HTML' && + child[PropertySymbol.nodeType] !== NodeTypeEnum.documentTypeNode + ) { body.appendChild(child); } } @@ -742,7 +804,7 @@ export default class Document extends Node implements IDocument { const bodyElement = this.createElement('body'); const headElement = this.createElement('head'); - for (const child of root._childNodes.slice()) { + for (const child of root[PropertySymbol.childNodes].slice()) { bodyElement.appendChild(child); } @@ -754,7 +816,7 @@ export default class Document extends Node implements IDocument { } else { const bodyNode = ParentNodeUtility.getElementByTagName(root, 'body'); const body = ParentNodeUtility.getElementByTagName(this, 'body'); - for (const child of ((bodyNode || root))._childNodes.slice()) { + for (const child of ((bodyNode || root))[PropertySymbol.childNodes].slice()) { body.appendChild(child); } } @@ -766,10 +828,10 @@ export default class Document extends Node implements IDocument { * @returns Document. */ public open(): IDocument { - this._isFirstWriteAfterOpen = true; + this[PropertySymbol.isFirstWriteAfterOpen] = true; - for (const eventType of Object.keys(this._listeners)) { - const listeners = this._listeners[eventType]; + for (const eventType of Object.keys(this[PropertySymbol.listeners])) { + const listeners = this[PropertySymbol.listeners][eventType]; if (listeners) { for (const listener of listeners) { this.removeEventListener(eventType, listener); @@ -777,7 +839,7 @@ export default class Document extends Node implements IDocument { } } - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } @@ -820,23 +882,23 @@ export default class Document extends Node implements IDocument { const tagName = String(qualifiedName).toUpperCase(); let customElementClass; - if (this.defaultView && options && options.is) { - customElementClass = this.defaultView.customElements.get(String(options.is)); - } else if (this.defaultView) { - customElementClass = this.defaultView.customElements.get(tagName); + if (options && options.is) { + customElementClass = this[PropertySymbol.ownerWindow].customElements.get(String(options.is)); + } else { + customElementClass = this[PropertySymbol.ownerWindow].customElements.get(tagName); } const elementClass: typeof Element = - customElementClass || ElementTag[tagName] || HTMLUnknownElement; + customElementClass || + this[PropertySymbol.ownerWindow][ElementTag[tagName]] || + HTMLUnknownElement; - elementClass._ownerDocument = this; + const element = NodeFactory.createNode(this, elementClass); + element[PropertySymbol.tagName] = tagName; - const element = new elementClass(); - element.tagName = tagName; - (element.ownerDocument) = this; - (element.namespaceURI) = namespaceURI; + element[PropertySymbol.namespaceURI] = namespaceURI; if (element instanceof Element && options && options.is) { - element._isValue = String(options.is); + element[PropertySymbol.isValue] = String(options.is); } return element; @@ -851,8 +913,7 @@ export default class Document extends Node implements IDocument { * @returns Text node. */ public createTextNode(data?: string): IText { - Text._ownerDocument = this; - return new Text(data); + return NodeFactory.createNode(this, this[PropertySymbol.ownerWindow].Text, data); } /** @@ -862,8 +923,7 @@ export default class Document extends Node implements IDocument { * @returns Text node. */ public createComment(data?: string): IComment { - Comment._ownerDocument = this; - return new Comment(data); + return NodeFactory.createNode(this, this[PropertySymbol.ownerWindow].Comment, data); } /** @@ -872,8 +932,7 @@ export default class Document extends Node implements IDocument { * @returns Document fragment. */ public createDocumentFragment(): IDocumentFragment { - DocumentFragment._ownerDocument = this; - return new DocumentFragment(); + return new this[PropertySymbol.ownerWindow].DocumentFragment(); } /** @@ -910,8 +969,8 @@ export default class Document extends Node implements IDocument { * @returns Event. */ public createEvent(type: string): Event { - if (typeof this.defaultView[type] === 'function') { - return new this.defaultView[type]('init'); + if (typeof this[PropertySymbol.ownerWindow][type] === 'function') { + return new this[PropertySymbol.ownerWindow][type]('init'); } return new Event('init'); } @@ -934,10 +993,9 @@ export default class Document extends Node implements IDocument { * @returns Element. */ public createAttributeNS(namespaceURI: string, qualifiedName: string): IAttr { - Attr._ownerDocument = this; - const attribute = new Attr(); - attribute.namespaceURI = namespaceURI; - attribute.name = qualifiedName; + const attribute = NodeFactory.createNode(this, this[PropertySymbol.ownerWindow].Attr); + attribute[PropertySymbol.namespaceURI] = namespaceURI; + attribute[PropertySymbol.name] = qualifiedName; return attribute; } @@ -952,7 +1010,7 @@ export default class Document extends Node implements IDocument { throw new DOMException('Parameter 1 was not of type Node.'); } const clone = node.cloneNode(deep); - (clone.ownerDocument) = this; + this.#importNode(clone); return clone; } @@ -962,7 +1020,7 @@ export default class Document extends Node implements IDocument { * @returns Range. */ public createRange(): Range { - return new this.defaultView.Range(); + return new this[PropertySymbol.ownerWindow].Range(); } /** @@ -976,8 +1034,11 @@ export default class Document extends Node implements IDocument { throw new DOMException('Parameter 1 was not of type Node.'); } - const adopted = node.parentNode ? node.parentNode.removeChild(node) : node; - (adopted.ownerDocument) = this; + const adopted = node[PropertySymbol.parentNode] + ? node[PropertySymbol.parentNode].removeChild(node) + : node; + const document = this; + Object.defineProperty(adopted, 'ownerDocument', { value: document }); return adopted; } @@ -987,10 +1048,10 @@ export default class Document extends Node implements IDocument { * @returns Selection. */ public getSelection(): Selection { - if (!this._selection) { - this._selection = new Selection(this); + if (!this.#selection) { + this.#selection = new Selection(this); } - return this._selection; + return this.#selection; } /** @@ -1002,23 +1063,12 @@ export default class Document extends Node implements IDocument { return !!this.activeElement; } - /** - * Triggered by window when it is ready. - */ - public _onWindowReady(): void { - this._readyStateManager.whenComplete().then(() => { - (this.readyState) = DocumentReadyStateEnum.complete; - this.dispatchEvent(new Event('readystatechange')); - this.dispatchEvent(new Event('load', { bubbles: true })); - }); - } - /** * Creates a Processing Instruction node. * + * @param target Target. + * @param data Data. * @returns IProcessingInstruction. - * @param target - * @param data */ public createProcessingInstruction(target: string, data: string): IProcessingInstruction { if (!target || !PROCESSING_INSTRUCTION_TARGET_REGEXP.test(target)) { @@ -1031,9 +1081,25 @@ export default class Document extends Node implements IDocument { `Failed to execute 'createProcessingInstruction' on 'Document': The data provided ('?>') contains '?>'` ); } - ProcessingInstruction._ownerDocument = this; - const processingInstruction = new ProcessingInstruction(data); - processingInstruction.target = target; + const processingInstruction = NodeFactory.createNode( + this, + this[PropertySymbol.ownerWindow].ProcessingInstruction, + data + ); + processingInstruction[PropertySymbol.target] = target; return processingInstruction; } + + /** + * Imports a node. + * + * @param node Node. + */ + #importNode(node: INode): void { + node[PropertySymbol.ownerDocument] = this; + + for (const child of node[PropertySymbol.childNodes]) { + this.#importNode(child); + } + } } diff --git a/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts b/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts index ee526daa9..61121a3a1 100644 --- a/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts +++ b/packages/happy-dom/src/nodes/document/DocumentReadyStateManager.ts @@ -1,4 +1,4 @@ -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; /** * Document ready state manager. @@ -6,7 +6,7 @@ import IWindow from '../../window/IWindow.js'; export default class DocumentReadyStateManager { private totalTasks = 0; private readyStateCallbacks: (() => void)[] = []; - private window: IWindow = null; + private window: IBrowserWindow = null; private immediate: NodeJS.Immediate | null = null; private isComplete = false; @@ -15,7 +15,7 @@ export default class DocumentReadyStateManager { * * @param window */ - constructor(window: IWindow) { + constructor(window: IBrowserWindow) { this.window = window; } @@ -24,7 +24,7 @@ export default class DocumentReadyStateManager { * * @returns Promise. */ - public whenComplete(): Promise { + public waitUntilComplete(): Promise { return new Promise((resolve) => { if (this.isComplete) { resolve(); diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts index 1f4c15b36..fb6f3953e 100644 --- a/packages/happy-dom/src/nodes/document/IDocument.ts +++ b/packages/happy-dom/src/nodes/document/IDocument.ts @@ -1,6 +1,7 @@ import IElement from '../element/IElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import NodeIterator from '../../tree-walker/NodeIterator.js'; import TreeWalker from '../../tree-walker/TreeWalker.js'; import Event from '../../event/Event.js'; @@ -27,7 +28,8 @@ import VisibilityStateEnum from './VisibilityStateEnum.js'; * Document. */ export default interface IDocument extends IParentNode { - readonly defaultView: IWindow; + readonly defaultView: IBrowserWindow | null; + readonly [PropertySymbol.ownerWindow]: IBrowserWindow; readonly implementation: DOMImplementation; readonly documentElement: IHTMLElement; readonly doctype: IDocumentType; @@ -48,6 +50,7 @@ export default interface IDocument extends IParentNode { readonly links: IHTMLCollection; readonly referrer: string; readonly currentScript: IHTMLScriptElement; + adoptedStyleSheets: CSSStyleSheet[]; cookie: string; title: string; @@ -331,9 +334,9 @@ export default interface IDocument extends IParentNode { /** * Creates a Processing Instruction node. * + * @param target Target. + * @param data Data. * @returns IProcessingInstruction. - * @param target - * @param data */ createProcessingInstruction(target: string, data: string): IProcessingInstruction; } diff --git a/packages/happy-dom/src/nodes/element/Dataset.ts b/packages/happy-dom/src/nodes/element/Dataset.ts index 35e59daac..e842421e3 100644 --- a/packages/happy-dom/src/nodes/element/Dataset.ts +++ b/packages/happy-dom/src/nodes/element/Dataset.ts @@ -1,4 +1,5 @@ -import Element from '../element/Element.js'; +import Element from './Element.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; /** @@ -22,11 +23,11 @@ export default class Dataset { // Build the initial dataset record from all data attributes. const dataset: DatasetRecord = {}; - for (let i = 0, max = element.attributes.length; i < max; i++) { - const attribute = element.attributes[i]; - if (attribute.name.startsWith('data-')) { - const key = Dataset.kebabToCamelCase(attribute.name.replace('data-', '')); - dataset[key] = attribute.value; + for (let i = 0, max = element[PropertySymbol.attributes].length; i < max; i++) { + const attribute = element[PropertySymbol.attributes][i]; + if (attribute[PropertySymbol.name].startsWith('data-')) { + const key = Dataset.kebabToCamelCase(attribute[PropertySymbol.name].replace('data-', '')); + dataset[key] = attribute[PropertySymbol.value]; } } @@ -34,9 +35,11 @@ export default class Dataset { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy this.proxy = new Proxy(dataset, { get(dataset: DatasetRecord, key: string): string { - const attribute = element.attributes.getNamedItem('data-' + Dataset.camelCaseToKebab(key)); + const attribute = element[PropertySymbol.attributes].getNamedItem( + 'data-' + Dataset.camelCaseToKebab(key) + ); if (attribute) { - return (dataset[key] = attribute.value); + return (dataset[key] = attribute[PropertySymbol.value]); } delete dataset[key]; return undefined; @@ -47,9 +50,9 @@ export default class Dataset { return true; }, deleteProperty(dataset: DatasetRecord, key: string): boolean { - (element.attributes)._removeNamedItem( - 'data-' + Dataset.camelCaseToKebab(key) - ); + (element[PropertySymbol.attributes])[ + PropertySymbol.removeNamedItem + ]('data-' + Dataset.camelCaseToKebab(key)); return delete dataset[key]; }, ownKeys(dataset: DatasetRecord): string[] { @@ -58,12 +61,14 @@ export default class Dataset { // "The result List must contain the keys of all non-configurable own properties of the target object." const keys = []; const deleteKeys = []; - for (let i = 0, max = element.attributes.length; i < max; i++) { - const attribute = element.attributes[i]; - if (attribute.name.startsWith('data-')) { - const key = Dataset.kebabToCamelCase(attribute.name.replace('data-', '')); + for (let i = 0, max = element[PropertySymbol.attributes].length; i < max; i++) { + const attribute = element[PropertySymbol.attributes][i]; + if (attribute[PropertySymbol.name].startsWith('data-')) { + const key = Dataset.kebabToCamelCase( + attribute[PropertySymbol.name].replace('data-', '') + ); keys.push(key); - dataset[key] = attribute.value; + dataset[key] = attribute[PropertySymbol.value]; if (!dataset[key]) { deleteKeys.push(key); } @@ -75,7 +80,9 @@ export default class Dataset { return keys; }, has(_dataset: DatasetRecord, key: string): boolean { - return !!element.attributes.getNamedItem('data-' + Dataset.camelCaseToKebab(key)); + return !!element[PropertySymbol.attributes].getNamedItem( + 'data-' + Dataset.camelCaseToKebab(key) + ); } }); } diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 3b807d157..b77be0f80 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -1,11 +1,10 @@ import Node from '../node/Node.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import ShadowRoot from '../shadow-root/ShadowRoot.js'; -import Attr from '../attr/Attr.js'; import DOMRect from './DOMRect.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList.js'; import QuerySelector from '../../query-selector/QuerySelector.js'; -import NamespaceURI from '../../config/NamespaceURI.js'; import XMLParser from '../../xml-parser/XMLParser.js'; import XMLSerializer from '../../xml-serializer/XMLSerializer.js'; import ChildNodeUtility from '../child-node/ChildNodeUtility.js'; @@ -15,7 +14,6 @@ import IElement from './IElement.js'; import DOMException from '../../exception/DOMException.js'; import IShadowRoot from '../shadow-root/IShadowRoot.js'; import INode from '../node/INode.js'; -import IDocument from '../document/IDocument.js'; import IHTMLCollection from './IHTMLCollection.js'; import INodeList from '../node/INodeList.js'; import { TInsertAdjacentPositions } from './IElement.js'; @@ -32,25 +30,19 @@ import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; import DocumentFragment from '../document-fragment/DocumentFragment.js'; import ElementNamedNodeMap from './ElementNamedNodeMap.js'; import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import WindowBrowserSettingsReader from '../../window/WindowBrowserSettingsReader.js'; +import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; +import NodeFactory from '../NodeFactory.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * Element. */ export default class Element extends Node implements IElement { // ObservedAttributes should only be called once by CustomElementRegistry (see #117) - // CustomElementRegistry will therefore populate _observedAttributes when CustomElementRegistry.define() is called - public static _observedAttributes: string[]; + // CustomElementRegistry will therefore populate "[PropertySymbol.observedAttributes]" when CustomElementRegistry.define() is called + public static [PropertySymbol.observedAttributes]: string[]; public static observedAttributes: string[]; - public tagName: string = null; - public nodeType = Node.ELEMENT_NODE; - public shadowRoot: IShadowRoot | null = null; - public prefix: string = null; - - public scrollHeight = 0; - public scrollWidth = 0; - public scrollTop = 0; - public scrollLeft = 0; - public readonly namespaceURI: string = null; // Events public oncancel: (event: Event) => void | null = null; @@ -88,21 +80,127 @@ export default class Element extends Node implements IElement { public ontouchmove: (event: Event) => void | null = null; public ontouchstart: (event: Event) => void | null = null; - public readonly _children: IHTMLCollection = new HTMLCollection(); + // Internal properties + public [PropertySymbol.children]: IHTMLCollection = new HTMLCollection(); + public [PropertySymbol.classList]: DOMTokenList = null; + public [PropertySymbol.isValue]: string | null = null; + public [PropertySymbol.computedStyle]: CSSStyleDeclaration | null = null; + public [PropertySymbol.nodeType] = NodeTypeEnum.elementNode; + public [PropertySymbol.tagName]: string | null = null; + public [PropertySymbol.prefix]: string | null = null; + public [PropertySymbol.shadowRoot]: IShadowRoot | null = null; + public [PropertySymbol.scrollHeight] = 0; + public [PropertySymbol.scrollWidth] = 0; + public [PropertySymbol.scrollTop] = 0; + public [PropertySymbol.scrollLeft] = 0; + public [PropertySymbol.attributes]: INamedNodeMap = new ElementNamedNodeMap(this); + public [PropertySymbol.namespaceURI]: string | null = null; + + /** + * Returns tag name. + * + * @returns Tag name. + */ + public get tagName(): string { + return this[PropertySymbol.tagName]; + } + + /** + * Returns prefix. + * + * @returns Prefix. + */ + public get prefix(): string | null { + return this[PropertySymbol.prefix]; + } + + /** + * Returns shadow root. + * + * @returns Shadow root. + */ + public get shadowRoot(): IShadowRoot | null { + const shadowRoot = this[PropertySymbol.shadowRoot]; + return shadowRoot && shadowRoot[PropertySymbol.mode] === 'open' ? shadowRoot : null; + } + + /** + * Returns scroll height. + * + * @returns Scroll height. + */ + public get scrollHeight(): number { + return this[PropertySymbol.scrollHeight]; + } - // Used for being able to access closed shadow roots - public _shadowRoot: IShadowRoot = null; - public readonly attributes: INamedNodeMap = new ElementNamedNodeMap(this); + /** + * Returns scroll width. + * + * @returns Scroll width. + */ + public get scrollWidth(): number { + return this[PropertySymbol.scrollWidth]; + } - public _classList: DOMTokenList = null; - public _isValue?: string | null = null; - public _computedStyle: CSSStyleDeclaration | null = null; + /** + * Returns scroll top. + * + * @returns Scroll top. + */ + public get scrollTop(): number { + return this[PropertySymbol.scrollTop]; + } + + /** + * Sets scroll top. + * + * @param value Scroll top. + */ + public set scrollTop(value: number) { + this[PropertySymbol.scrollTop] = value; + } + + /** + * Returns scroll left. + * + * @returns Scroll left. + */ + public get scrollLeft(): number { + return this[PropertySymbol.scrollLeft]; + } + + /** + * Sets scroll left. + * + * @param value Scroll left. + */ + public set scrollLeft(value: number) { + this[PropertySymbol.scrollLeft] = value; + } + + /** + * Returns attributes. + * + * @returns Attributes. + */ + public get attributes(): INamedNodeMap { + return this[PropertySymbol.attributes]; + } + + /** + * Returns namespace URI. + * + * @returns Namespace URI. + */ + public get namespaceURI(): string | null { + return this[PropertySymbol.namespaceURI]; + } /** * Returns element children. */ public get children(): IHTMLCollection { - return this._children; + return this[PropertySymbol.children]; } /** @@ -111,10 +209,10 @@ export default class Element extends Node implements IElement { * @returns Class list. */ public get classList(): IDOMTokenList { - if (!this._classList) { - this._classList = new DOMTokenList(this, 'class'); + if (!this[PropertySymbol.classList]) { + this[PropertySymbol.classList] = new DOMTokenList(this, 'class'); } - return this._classList; + return this[PropertySymbol.classList]; } /** @@ -159,7 +257,7 @@ export default class Element extends Node implements IElement { * @returns Node name. */ public get nodeName(): string { - return this.tagName; + return this[PropertySymbol.tagName]; } /** @@ -168,7 +266,7 @@ export default class Element extends Node implements IElement { * @returns Local name. */ public get localName(): string { - return this.tagName ? this.tagName.toLowerCase() : 'unknown'; + return this[PropertySymbol.tagName] ? this[PropertySymbol.tagName].toLowerCase() : 'unknown'; } /** @@ -214,8 +312,11 @@ export default class Element extends Node implements IElement { */ public get textContent(): string { let result = ''; - for (const childNode of this._childNodes) { - if (childNode.nodeType === Node.ELEMENT_NODE || childNode.nodeType === Node.TEXT_NODE) { + for (const childNode of this[PropertySymbol.childNodes]) { + if ( + childNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode || + childNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode + ) { result += childNode.textContent; } } @@ -228,11 +329,11 @@ export default class Element extends Node implements IElement { * @param textContent Text content. */ public set textContent(textContent: string) { - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } if (textContent) { - this.appendChild(this.ownerDocument.createTextNode(textContent)); + this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(textContent)); } } @@ -251,11 +352,11 @@ export default class Element extends Node implements IElement { * @param html HTML. */ public set innerHTML(html: string) { - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } - XMLParser.parse(this.ownerDocument, html, { rootNode: this }); + XMLParser.parse(this[PropertySymbol.ownerDocument], html, { rootNode: this }); } /** @@ -282,7 +383,7 @@ export default class Element extends Node implements IElement { * @returns Element. */ public get firstElementChild(): IElement { - return this._children[0] ?? null; + return this[PropertySymbol.children][0] ?? null; } /** @@ -291,7 +392,7 @@ export default class Element extends Node implements IElement { * @returns Element. */ public get lastElementChild(): IElement { - return this._children[this._children.length - 1] ?? null; + return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; } /** @@ -300,7 +401,7 @@ export default class Element extends Node implements IElement { * @returns Element. */ public get childElementCount(): number { - return this._children.length; + return this[PropertySymbol.children].length; } /** @@ -347,7 +448,7 @@ export default class Element extends Node implements IElement { escapeEntities: false }); let xml = ''; - for (const node of this._childNodes) { + for (const node of this[PropertySymbol.childNodes]) { xml += xmlSerializer.serializeToString(node); } return xml; @@ -363,23 +464,29 @@ export default class Element extends Node implements IElement { public cloneNode(deep = false): IElement { const clone = super.cloneNode(deep); - Attr._ownerDocument = this.ownerDocument; - - for (let i = 0, max = this.attributes.length; i < max; i++) { - const attribute = this.attributes[i]; - clone.attributes.setNamedItem(Object.assign(new Attr(), attribute)); + for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { + const attribute = this[PropertySymbol.attributes][i]; + clone[PropertySymbol.attributes].setNamedItem( + Object.assign( + this[PropertySymbol.ownerDocument].createAttributeNS( + attribute[PropertySymbol.namespaceURI], + attribute[PropertySymbol.name] + ), + attribute + ) + ); } if (deep) { - for (const node of clone._childNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - clone._children.push(node); + for (const node of clone[PropertySymbol.childNodes]) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { + clone[PropertySymbol.children].push(node); } } } - (clone.tagName) = this.tagName; - (clone.namespaceURI) = this.namespaceURI; + clone[PropertySymbol.tagName] = this[PropertySymbol.tagName]; + clone[PropertySymbol.namespaceURI] = this[PropertySymbol.namespaceURI]; return clone; } @@ -512,8 +619,8 @@ export default class Element extends Node implements IElement { */ public insertAdjacentHTML(position: TInsertAdjacentPositions, text: string): void { for (const node of (( - XMLParser.parse(this.ownerDocument, text) - ))._childNodes.slice()) { + XMLParser.parse(this[PropertySymbol.ownerDocument], text) + ))[PropertySymbol.childNodes].slice()) { this.insertAdjacentElement(position, node); } } @@ -528,7 +635,7 @@ export default class Element extends Node implements IElement { if (!text) { return; } - const textNode = this.ownerDocument.createTextNode(text); + const textNode = this[PropertySymbol.ownerDocument].createTextNode(text); this.insertAdjacentElement(position, textNode); } @@ -539,8 +646,8 @@ export default class Element extends Node implements IElement { * @param value Value. */ public setAttribute(name: string, value: string): void { - const attribute = this.ownerDocument.createAttributeNS(null, name); - attribute.value = String(value); + const attribute = this[PropertySymbol.ownerDocument].createAttributeNS(null, name); + attribute[PropertySymbol.value] = String(value); this.setAttributeNode(attribute); } @@ -552,8 +659,8 @@ export default class Element extends Node implements IElement { * @param value Value. */ public setAttributeNS(namespaceURI: string, name: string, value: string): void { - const attribute = this.ownerDocument.createAttributeNS(namespaceURI, name); - attribute.value = String(value); + const attribute = this[PropertySymbol.ownerDocument].createAttributeNS(namespaceURI, name); + attribute[PropertySymbol.value] = String(value); this.setAttributeNode(attribute); } @@ -564,8 +671,8 @@ export default class Element extends Node implements IElement { */ public getAttributeNames(): string[] { const attributeNames = []; - for (let i = 0, max = this.attributes.length; i < max; i++) { - attributeNames.push(this.attributes[i].name); + for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { + attributeNames.push(this[PropertySymbol.attributes][i][PropertySymbol.name]); } return attributeNames; } @@ -578,7 +685,7 @@ export default class Element extends Node implements IElement { public getAttribute(name: string): string { const attribute = this.getAttributeNode(name); if (attribute) { - return attribute.value; + return attribute[PropertySymbol.value]; } return null; } @@ -616,7 +723,7 @@ export default class Element extends Node implements IElement { public getAttributeNS(namespace: string | null, localName: string): string { const attribute = this.getAttributeNodeNS(namespace, localName); if (attribute) { - return attribute.value; + return attribute[PropertySymbol.value]; } return null; } @@ -639,7 +746,7 @@ export default class Element extends Node implements IElement { * @returns True if attribute exists, false otherwise. */ public hasAttributeNS(namespace: string | null, localName: string): boolean { - return this.attributes.getNamedItemNS(namespace, localName) !== null; + return this[PropertySymbol.attributes].getNamedItemNS(namespace, localName) !== null; } /** @@ -648,7 +755,7 @@ export default class Element extends Node implements IElement { * @returns "true" if the element has attributes. */ public hasAttributes(): boolean { - return this.attributes.length > 0; + return this[PropertySymbol.attributes].length > 0; } /** @@ -658,7 +765,7 @@ export default class Element extends Node implements IElement { */ public removeAttribute(name: string): void { try { - this.attributes.removeNamedItem(name); + this[PropertySymbol.attributes].removeNamedItem(name); } catch (error) { // Ignore DOMException when the attribute does not exist. } @@ -671,33 +778,33 @@ export default class Element extends Node implements IElement { * @param localName Local name. */ public removeAttributeNS(namespace: string | null, localName: string): void { - this.attributes.removeNamedItemNS(namespace, localName); + this[PropertySymbol.attributes].removeNamedItemNS(namespace, localName); } /** * Attaches a shadow root. * - * @param _shadowRootInit Shadow root init. - * @param shadowRootInit - * @param shadowRootInit.mode + * @param init Shadow root init. + * @param init.mode Shadow root mode. * @returns Shadow root. */ - public attachShadow(shadowRootInit: { mode: string }): IShadowRoot { - if (this._shadowRoot) { + public attachShadow(init: { mode: string }): IShadowRoot { + if (this[PropertySymbol.shadowRoot]) { throw new DOMException('Shadow root has already been attached.'); } - (this._shadowRoot) = new ShadowRoot(); - (this._shadowRoot.ownerDocument) = this.ownerDocument; - (this._shadowRoot.host) = this; - (this._shadowRoot.mode) = shadowRootInit.mode; - (this._shadowRoot)._connectToNode(this); + const shadowRoot = NodeFactory.createNode( + this[PropertySymbol.ownerDocument], + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].ShadowRoot + ); - if (this._shadowRoot.mode === 'open') { - (this.shadowRoot) = this._shadowRoot; - } + this[PropertySymbol.shadowRoot] = shadowRoot; + + shadowRoot[PropertySymbol.host] = this; + shadowRoot[PropertySymbol.mode] = init.mode; + (shadowRoot)[PropertySymbol.connectToNode](this); - return this._shadowRoot; + return this[PropertySymbol.shadowRoot]; } /** @@ -818,7 +925,7 @@ export default class Element extends Node implements IElement { * @returns Replaced attribute. */ public setAttributeNode(attribute: IAttr): IAttr | null { - return this.attributes.setNamedItem(attribute); + return this[PropertySymbol.attributes].setNamedItem(attribute); } /** @@ -828,7 +935,7 @@ export default class Element extends Node implements IElement { * @returns Replaced attribute. */ public setAttributeNodeNS(attribute: IAttr): IAttr | null { - return this.attributes.setNamedItemNS(attribute); + return this[PropertySymbol.attributes].setNamedItemNS(attribute); } /** @@ -838,7 +945,7 @@ export default class Element extends Node implements IElement { * @returns Replaced attribute. */ public getAttributeNode(name: string): IAttr | null { - return this.attributes.getNamedItem(name); + return this[PropertySymbol.attributes].getNamedItem(name); } /** @@ -849,7 +956,7 @@ export default class Element extends Node implements IElement { * @returns Replaced attribute. */ public getAttributeNodeNS(namespace: string | null, localName: string): IAttr | null { - return this.attributes.getNamedItemNS(namespace, localName); + return this[PropertySymbol.attributes].getNamedItemNS(namespace, localName); } /** @@ -859,7 +966,7 @@ export default class Element extends Node implements IElement { * @returns Removed attribute. */ public removeAttributeNode(attribute: IAttr): IAttr | null { - return this.attributes.removeNamedItem(attribute.name); + return this[PropertySymbol.attributes].removeNamedItem(attribute[PropertySymbol.name]); } /** @@ -869,7 +976,10 @@ export default class Element extends Node implements IElement { * @returns Removed attribute. */ public removeAttributeNodeNS(attribute: IAttr): IAttr | null { - return this.attributes.removeNamedItemNS(attribute.namespaceURI, attribute.localName); + return this[PropertySymbol.attributes].removeNamedItemNS( + attribute[PropertySymbol.namespaceURI], + attribute.localName + ); } /** @@ -881,7 +991,7 @@ export default class Element extends Node implements IElement { public scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void { if (typeof x === 'object') { if (x.behavior === 'smooth') { - this.ownerDocument.defaultView.setTimeout(() => { + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].setTimeout(() => { if (x.top !== undefined) { (this.scrollTop) = x.top; } @@ -921,20 +1031,33 @@ export default class Element extends Node implements IElement { */ public override dispatchEvent(event: Event): boolean { const returnValue = super.dispatchEvent(event); + const browserSettings = WindowBrowserSettingsReader.getSettings( + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + ); if ( + browserSettings && + !browserSettings.disableJavaScriptEvaluation && (event.eventPhase === EventPhaseEnum.atTarget || event.eventPhase === EventPhaseEnum.bubbling) && - !event._immediatePropagationStopped + !event[PropertySymbol.immediatePropagationStopped] ) { const attribute = this.getAttribute('on' + event.type); - if (attribute && !event._immediatePropagationStopped) { - if (this.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - this.ownerDocument.defaultView.eval(attribute); + if (attribute && !event[PropertySymbol.immediatePropagationStopped]) { + const code = `//# sourceURL=${ + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href + }\n${attribute}`; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code); } else { - WindowErrorUtility.captureError(this.ownerDocument.defaultView, () => - this.ownerDocument.defaultView.eval(attribute) + WindowErrorUtility.captureError( + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], + () => this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code) ); } } @@ -942,17 +1065,4 @@ export default class Element extends Node implements IElement { return returnValue; } - - /** - * Returns attribute name. - * - * @param name Name. - * @returns Attribute name based on namespace. - */ - protected _getAttributeName(name): string { - if (this.namespaceURI === NamespaceURI.svg) { - return name; - } - return name.toLowerCase(); - } } diff --git a/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts index 18c3c86b9..1b3a4de9c 100644 --- a/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts @@ -1,12 +1,13 @@ import NamespaceURI from '../../config/NamespaceURI.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import MutationRecord from '../../mutation-observer/MutationRecord.js'; import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; import IAttr from '../attr/IAttr.js'; -import IDocument from '../document/IDocument.js'; import Element from './Element.js'; import HTMLCollection from './HTMLCollection.js'; import IElement from './IElement.js'; +import MutationListener from '../../mutation-observer/MutationListener.js'; /** * Named Node Map. @@ -14,96 +15,119 @@ import IElement from './IElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class ElementNamedNodeMap extends NamedNodeMap { - protected _ownerElement: Element; + protected [PropertySymbol.ownerElement]: IElement; /** * Constructor. * * @param ownerElement Owner element. */ - constructor(ownerElement: Element) { + constructor(ownerElement: IElement) { super(); - this._ownerElement = ownerElement; + this[PropertySymbol.ownerElement] = ownerElement; } /** * @override */ public override getNamedItem(name: string): IAttr | null { - return this._namedItems[this._getAttributeName(name)] || null; + return this[PropertySymbol.namedItems][this[PropertySymbol.getAttributeName](name)] || null; } /** * @override */ public override getNamedItemNS(namespace: string, localName: string): IAttr | null { - return super.getNamedItemNS(namespace, this._getAttributeName(localName)); + return super.getNamedItemNS(namespace, this[PropertySymbol.getAttributeName](localName)); } /** * @override */ public override setNamedItem(item: IAttr): IAttr | null { - if (!item.name) { + if (!item[PropertySymbol.name]) { return null; } - item.name = this._getAttributeName(item.name); - (item.ownerElement) = this._ownerElement; - (item.ownerDocument) = this._ownerElement.ownerDocument; + item[PropertySymbol.name] = this[PropertySymbol.getAttributeName](item[PropertySymbol.name]); + (item[PropertySymbol.ownerElement]) = this[PropertySymbol.ownerElement]; const replacedItem = super.setNamedItem(item); - const oldValue = replacedItem ? replacedItem.value : null; + const oldValue = replacedItem ? replacedItem[PropertySymbol.value] : null; - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; + if (this[PropertySymbol.ownerElement][PropertySymbol.isConnected]) { + this[PropertySymbol.ownerElement].ownerDocument[PropertySymbol.cacheID]++; } - if (item.name === 'class' && this._ownerElement._classList) { - this._ownerElement._classList._updateIndices(); + if ( + item[PropertySymbol.name] === 'class' && + this[PropertySymbol.ownerElement][PropertySymbol.classList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.classList][PropertySymbol.updateIndices](); } - if (item.name === 'id' || item.name === 'name') { + if (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') { if ( - this._ownerElement.parentNode && - (this._ownerElement.parentNode)._children && - item.value !== oldValue + this[PropertySymbol.ownerElement][PropertySymbol.parentNode] && + (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ + PropertySymbol.children + ] && + item[PropertySymbol.value] !== oldValue ) { if (oldValue) { (>( - (this._ownerElement.parentNode)._children - ))._removeNamedItem(this._ownerElement, oldValue); + (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ + PropertySymbol.children + ] + ))[PropertySymbol.removeNamedItem](this[PropertySymbol.ownerElement], oldValue); } - if (item.value) { + if (item[PropertySymbol.value]) { (>( - (this._ownerElement.parentNode)._children - ))._appendNamedItem(this._ownerElement, item.value); + (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ + PropertySymbol.children + ] + ))[PropertySymbol.appendNamedItem]( + this[PropertySymbol.ownerElement], + item[PropertySymbol.value] + ); } } } if ( - this._ownerElement.attributeChangedCallback && - (this._ownerElement.constructor)._observedAttributes && - (this._ownerElement.constructor)._observedAttributes.includes(item.name) + this[PropertySymbol.ownerElement].attributeChangedCallback && + (this[PropertySymbol.ownerElement].constructor)[ + PropertySymbol.observedAttributes + ] && + (this[PropertySymbol.ownerElement].constructor)[ + PropertySymbol.observedAttributes + ].includes(item[PropertySymbol.name]) ) { - this._ownerElement.attributeChangedCallback(item.name, oldValue, item.value); + this[PropertySymbol.ownerElement].attributeChangedCallback( + item[PropertySymbol.name], + oldValue, + item[PropertySymbol.value] + ); } // MutationObserver - if (this._ownerElement._observers.length > 0) { - for (const observer of this._ownerElement._observers) { + if (this[PropertySymbol.ownerElement][PropertySymbol.observers].length > 0) { + for (const observer of ( + this[PropertySymbol.ownerElement][PropertySymbol.observers] + )) { if ( observer.options.attributes && (!observer.options.attributeFilter || - observer.options.attributeFilter.includes(item.name)) + observer.options.attributeFilter.includes(item[PropertySymbol.name])) ) { - const record = new MutationRecord(); - record.target = this._ownerElement; - record.type = MutationTypeEnum.attributes; - record.attributeName = item.name; - record.oldValue = observer.options.attributeOldValue ? oldValue : null; - observer.callback([record], observer.observer); + observer.report( + new MutationRecord({ + target: this[PropertySymbol.ownerElement], + type: MutationTypeEnum.attributes, + attributeName: item[PropertySymbol.name], + oldValue: observer.options.attributeOldValue ? oldValue : null + }) + ); } } } @@ -114,57 +138,81 @@ export default class ElementNamedNodeMap extends NamedNodeMap { /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(this._getAttributeName(name)); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem]( + this[PropertySymbol.getAttributeName](name) + ); if (!removedItem) { return null; } - if (this._ownerElement.isConnected) { - this._ownerElement.ownerDocument['_cacheID']++; + if (this[PropertySymbol.ownerElement][PropertySymbol.isConnected]) { + this[PropertySymbol.ownerElement].ownerDocument[PropertySymbol.cacheID]++; } - if (removedItem.name === 'class' && this._ownerElement._classList) { - this._ownerElement._classList._updateIndices(); + if ( + removedItem[PropertySymbol.name] === 'class' && + this[PropertySymbol.ownerElement][PropertySymbol.classList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.classList][PropertySymbol.updateIndices](); } - if (removedItem.name === 'id' || removedItem.name === 'name') { + if (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') { if ( - this._ownerElement.parentNode && - (this._ownerElement.parentNode)._children && - removedItem.value + this[PropertySymbol.ownerElement][PropertySymbol.parentNode] && + (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ + PropertySymbol.children + ] && + removedItem[PropertySymbol.value] ) { (>( - (this._ownerElement.parentNode)._children - ))._removeNamedItem(this._ownerElement, removedItem.value); + (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ + PropertySymbol.children + ] + ))[PropertySymbol.removeNamedItem]( + this[PropertySymbol.ownerElement], + removedItem[PropertySymbol.value] + ); } } if ( - this._ownerElement.attributeChangedCallback && - (this._ownerElement.constructor)._observedAttributes && - (this._ownerElement.constructor)._observedAttributes.includes( - removedItem.name - ) + this[PropertySymbol.ownerElement].attributeChangedCallback && + (this[PropertySymbol.ownerElement].constructor)[ + PropertySymbol.observedAttributes + ] && + (this[PropertySymbol.ownerElement].constructor)[ + PropertySymbol.observedAttributes + ].includes(removedItem[PropertySymbol.name]) ) { - this._ownerElement.attributeChangedCallback(removedItem.name, removedItem.value, null); + this[PropertySymbol.ownerElement].attributeChangedCallback( + removedItem[PropertySymbol.name], + removedItem[PropertySymbol.value], + null + ); } // MutationObserver - if (this._ownerElement._observers.length > 0) { - for (const observer of this._ownerElement._observers) { + if (this[PropertySymbol.ownerElement][PropertySymbol.observers].length > 0) { + for (const observer of ( + this[PropertySymbol.ownerElement][PropertySymbol.observers] + )) { if ( observer.options.attributes && (!observer.options.attributeFilter || - observer.options.attributeFilter.includes(removedItem.name)) + observer.options.attributeFilter.includes(removedItem[PropertySymbol.name])) ) { - const record = new MutationRecord(); - record.target = this._ownerElement; - record.type = MutationTypeEnum.attributes; - record.attributeName = removedItem.name; - record.oldValue = observer.options.attributeOldValue ? removedItem.value : null; - observer.callback([record], observer.observer); + observer.report( + new MutationRecord({ + target: this[PropertySymbol.ownerElement], + type: MutationTypeEnum.attributes, + attributeName: removedItem[PropertySymbol.name], + oldValue: observer.options.attributeOldValue + ? removedItem[PropertySymbol.value] + : null + }) + ); } } } @@ -176,7 +224,7 @@ export default class ElementNamedNodeMap extends NamedNodeMap { * @override */ public override removeNamedItemNS(namespace: string, localName: string): IAttr | null { - return super.removeNamedItemNS(namespace, this._getAttributeName(localName)); + return super.removeNamedItemNS(namespace, this[PropertySymbol.getAttributeName](localName)); } /** @@ -185,8 +233,8 @@ export default class ElementNamedNodeMap extends NamedNodeMap { * @param name Name. * @returns Attribute name based on namespace. */ - protected _getAttributeName(name): string { - if (this._ownerElement.namespaceURI === NamespaceURI.svg) { + protected [PropertySymbol.getAttributeName](name): string { + if (this[PropertySymbol.ownerElement][PropertySymbol.namespaceURI] === NamespaceURI.svg) { return name; } return name.toLowerCase(); diff --git a/packages/happy-dom/src/nodes/element/ElementUtility.ts b/packages/happy-dom/src/nodes/element/ElementUtility.ts index 5968a7e8f..08c3e21e1 100644 --- a/packages/happy-dom/src/nodes/element/ElementUtility.ts +++ b/packages/happy-dom/src/nodes/element/ElementUtility.ts @@ -1,4 +1,5 @@ import NodeTypeEnum from '../node/NodeTypeEnum.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IElement from './IElement.js'; import INode from '../node/INode.js'; import HTMLCollection from './HTMLCollection.js'; @@ -30,7 +31,7 @@ export default class ElementUtility { node: INode, options?: { disableAncestorValidation?: boolean } ): INode { - if (node.nodeType === NodeTypeEnum.elementNode && node !== ancestorNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && node !== ancestorNode) { if ( !options?.disableAncestorValidation && NodeUtility.isInclusiveAncestor(node, ancestorNode) @@ -40,18 +41,23 @@ export default class ElementUtility { DOMExceptionNameEnum.domException ); } - if (node.parentNode) { + if (node[PropertySymbol.parentNode]) { const parentNodeChildren = >( - (node.parentNode)._children + (node[PropertySymbol.parentNode])[PropertySymbol.children] ); if (parentNodeChildren) { const index = parentNodeChildren.indexOf(node); if (index !== -1) { for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node).attributes.getNamedItem(attributeName); + const attribute = (node)[PropertySymbol.attributes].getNamedItem( + attributeName + ); if (attribute) { - parentNodeChildren._removeNamedItem(node, attribute.value); + parentNodeChildren[PropertySymbol.removeNamedItem]( + node, + attribute[PropertySymbol.value] + ); } } @@ -59,12 +65,17 @@ export default class ElementUtility { } } } - const ancestorNodeChildren = >(ancestorNode)._children; + const ancestorNodeChildren = >( + (ancestorNode)[PropertySymbol.children] + ); for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node).attributes.getNamedItem(attributeName); + const attribute = (node)[PropertySymbol.attributes].getNamedItem(attributeName); if (attribute) { - ancestorNodeChildren._appendNamedItem(node, attribute.value); + ancestorNodeChildren[PropertySymbol.appendNamedItem]( + node, + attribute[PropertySymbol.value] + ); } } @@ -89,14 +100,19 @@ export default class ElementUtility { ancestorNode: IElement | IDocument | IDocumentFragment, node: INode ): INode { - if (node.nodeType === NodeTypeEnum.elementNode) { - const ancestorNodeChildren = >(ancestorNode)._children; + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { + const ancestorNodeChildren = >( + (ancestorNode)[PropertySymbol.children] + ); const index = ancestorNodeChildren.indexOf(node); if (index !== -1) { for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node).attributes.getNamedItem(attributeName); + const attribute = (node)[PropertySymbol.attributes].getNamedItem(attributeName); if (attribute) { - ancestorNodeChildren._removeNamedItem(node, attribute.value); + ancestorNodeChildren[PropertySymbol.removeNamedItem]( + node, + attribute[PropertySymbol.value] + ); } } ancestorNodeChildren.splice(index, 1); @@ -126,7 +142,7 @@ export default class ElementUtility { options?: { disableAncestorValidation?: boolean } ): INode { // NodeUtility.insertBefore() will call appendChild() for the scenario where "referenceNode" is "null" or "undefined" - if (newNode.nodeType === NodeTypeEnum.elementNode && referenceNode) { + if (newNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && referenceNode) { if ( !options?.disableAncestorValidation && NodeUtility.isInclusiveAncestor(newNode, ancestorNode) @@ -136,18 +152,23 @@ export default class ElementUtility { DOMExceptionNameEnum.domException ); } - if (newNode.parentNode) { + if (newNode[PropertySymbol.parentNode]) { const parentNodeChildren = >( - (newNode.parentNode)._children + (newNode[PropertySymbol.parentNode])[PropertySymbol.children] ); if (parentNodeChildren) { const index = parentNodeChildren.indexOf(newNode); if (index !== -1) { for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (newNode).attributes.getNamedItem(attributeName); + const attribute = (newNode)[PropertySymbol.attributes].getNamedItem( + attributeName + ); if (attribute) { - parentNodeChildren._removeNamedItem(newNode, attribute.value); + parentNodeChildren[PropertySymbol.removeNamedItem]( + newNode, + attribute[PropertySymbol.value] + ); } } @@ -156,9 +177,11 @@ export default class ElementUtility { } } - const ancestorNodeChildren = >(ancestorNode)._children; + const ancestorNodeChildren = >( + (ancestorNode)[PropertySymbol.children] + ); - if (referenceNode.nodeType === NodeTypeEnum.elementNode) { + if (referenceNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { const index = ancestorNodeChildren.indexOf(referenceNode); if (index !== -1) { ancestorNodeChildren.splice(index, 0, newNode); @@ -166,20 +189,23 @@ export default class ElementUtility { } else { ancestorNodeChildren.length = 0; - for (const node of (ancestorNode)._childNodes) { + for (const node of (ancestorNode)[PropertySymbol.childNodes]) { if (node === referenceNode) { ancestorNodeChildren.push(newNode); } - if (node.nodeType === NodeTypeEnum.elementNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { ancestorNodeChildren.push(node); } } } for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (newNode).attributes.getNamedItem(attributeName); + const attribute = (newNode)[PropertySymbol.attributes].getNamedItem(attributeName); if (attribute) { - ancestorNodeChildren._appendNamedItem(newNode, attribute.value); + ancestorNodeChildren[PropertySymbol.appendNamedItem]( + newNode, + attribute[PropertySymbol.value] + ); } } diff --git a/packages/happy-dom/src/nodes/element/HTMLCollection.ts b/packages/happy-dom/src/nodes/element/HTMLCollection.ts index cb008c27b..99a7e6fc2 100644 --- a/packages/happy-dom/src/nodes/element/HTMLCollection.ts +++ b/packages/happy-dom/src/nodes/element/HTMLCollection.ts @@ -1,10 +1,11 @@ import IHTMLCollection from './IHTMLCollection.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * HTML collection. */ export default class HTMLCollection extends Array implements IHTMLCollection { - protected _namedItems: { [k: string]: T[] } = {}; + protected [PropertySymbol.namedItems]: { [k: string]: T[] } = {}; /** * Returns item by index. @@ -22,8 +23,8 @@ export default class HTMLCollection extends Array implements IHTMLCollection< * @returns Node. */ public namedItem(name: string): T | null { - return this._namedItems[name] && this._namedItems[name].length - ? this._namedItems[name][0] + return this[PropertySymbol.namedItems][name] && this[PropertySymbol.namedItems][name].length + ? this[PropertySymbol.namedItems][name][0] : null; } @@ -33,16 +34,16 @@ export default class HTMLCollection extends Array implements IHTMLCollection< * @param node Node. * @param name Name. */ - public _appendNamedItem(node: T, name: string): void { + public [PropertySymbol.appendNamedItem](node: T, name: string): void { if (name) { - this._namedItems[name] = this._namedItems[name] || []; + this[PropertySymbol.namedItems][name] = this[PropertySymbol.namedItems][name] || []; - if (!this._namedItems[name].includes(node)) { - this._namedItems[name].push(node); + if (!this[PropertySymbol.namedItems][name].includes(node)) { + this[PropertySymbol.namedItems][name].push(node); } - if (!this.hasOwnProperty(name) && this._isValidPropertyName(name)) { - this[name] = this._namedItems[name][0]; + if (!this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { + this[name] = this[PropertySymbol.namedItems][name][0]; } } } @@ -53,20 +54,20 @@ export default class HTMLCollection extends Array implements IHTMLCollection< * @param node Node. * @param name Name. */ - public _removeNamedItem(node: T, name: string): void { - if (name && this._namedItems[name]) { - const index = this._namedItems[name].indexOf(node); + public [PropertySymbol.removeNamedItem](node: T, name: string): void { + if (name && this[PropertySymbol.namedItems][name]) { + const index = this[PropertySymbol.namedItems][name].indexOf(node); if (index > -1) { - this._namedItems[name].splice(index, 1); + this[PropertySymbol.namedItems][name].splice(index, 1); - if (this._namedItems[name].length === 0) { - delete this._namedItems[name]; - if (this.hasOwnProperty(name) && this._isValidPropertyName(name)) { + if (this[PropertySymbol.namedItems][name].length === 0) { + delete this[PropertySymbol.namedItems][name]; + if (this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { delete this[name]; } - } else if (this._isValidPropertyName(name)) { - this[name] = this._namedItems[name][0]; + } else if (this[PropertySymbol.isValidPropertyName](name)) { + this[name] = this[PropertySymbol.namedItems][name][0]; } } } @@ -78,7 +79,7 @@ export default class HTMLCollection extends Array implements IHTMLCollection< * @param name Name. * @returns True if the property name is valid. */ - protected _isValidPropertyName(name: string): boolean { + protected [PropertySymbol.isValidPropertyName](name: string): boolean { return ( !this.constructor.prototype.hasOwnProperty(name) && !Array.prototype.hasOwnProperty(name) && diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index be9313f74..a3fb5143e 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -20,11 +20,11 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, readonly shadowRoot: IShadowRoot; readonly classList: IDOMTokenList; readonly namespaceURI: string; - prefix: string | null; + readonly prefix: string | null; scrollTop: number; scrollLeft: number; - scrollWidth: number; - scrollHeight: number; + readonly scrollWidth: number; + readonly scrollHeight: number; id: string; className: string; role: string; @@ -182,10 +182,10 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, /** * Attaches a shadow root. * - * @param _shadowRootInit Shadow root init. + * @param init Shadow root init. * @returns Shadow root. */ - attachShadow(_shadowRootInit: { mode: string }): IShadowRoot; + attachShadow(init: { mode: string }): IShadowRoot; /** * Scrolls to a particular set of coordinates. diff --git a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts index e68d81f98..18fbc8ba2 100644 --- a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts +++ b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList.js'; import IHTMLAnchorElement from './IHTMLAnchorElement.js'; @@ -6,6 +7,8 @@ import URL from '../../url/URL.js'; import HTMLAnchorElementUtility from './HTMLAnchorElementUtility.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLAnchorElementNamedNodeMap from './HTMLAnchorElementNamedNodeMap.js'; +import Event from '../../event/Event.js'; +import EventPhaseEnum from '../../event/EventPhaseEnum.js'; /** * HTML Anchor Element. @@ -14,9 +17,11 @@ import HTMLAnchorElementNamedNodeMap from './HTMLAnchorElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement. */ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAnchorElement { - public override readonly attributes: INamedNodeMap = new HTMLAnchorElementNamedNodeMap(this); - public _relList: DOMTokenList = null; - public _url: URL | null = null; + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLAnchorElementNamedNodeMap( + this + ); + public [PropertySymbol.relList]: DOMTokenList = null; + public [PropertySymbol.url]: URL | null = null; /** * Returns download. @@ -42,7 +47,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Hash. */ public get hash(): string { - return this._url?.hash ?? ''; + return this[PropertySymbol.url]?.hash ?? ''; } /** @@ -51,9 +56,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param hash Hash. */ public set hash(hash: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.hash = hash; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].hash = hash; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -63,8 +68,8 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Href. */ public get href(): string | null { - if (this._url) { - return this._url.toString(); + if (this[PropertySymbol.url]) { + return this[PropertySymbol.url].toString(); } return this.getAttribute('href') || ''; @@ -103,7 +108,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Origin. */ public get origin(): string { - return this._url?.origin ?? ''; + return this[PropertySymbol.url]?.origin ?? ''; } /** @@ -130,7 +135,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Protocol. */ public get protocol(): string { - return this._url?.protocol ?? ''; + return this[PropertySymbol.url]?.protocol ?? ''; } /** @@ -139,9 +144,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param protocol Protocol. */ public set protocol(protocol: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.protocol = protocol; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].protocol = protocol; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -151,7 +156,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Username. */ public get username(): string { - return this._url?.username ?? ''; + return this[PropertySymbol.url]?.username ?? ''; } /** @@ -161,13 +166,13 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho */ public set username(username: string) { if ( - this._url && - !HTMLAnchorElementUtility.isBlobURL(this._url) && - this._url.host && - this._url.protocol != 'file' + this[PropertySymbol.url] && + !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url]) && + this[PropertySymbol.url].host && + this[PropertySymbol.url].protocol != 'file' ) { - this._url.username = username; - this.setAttribute('href', this._url.toString()); + this[PropertySymbol.url].username = username; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -177,7 +182,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Password. */ public get password(): string { - return this._url?.password ?? ''; + return this[PropertySymbol.url]?.password ?? ''; } /** @@ -187,13 +192,13 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho */ public set password(password: string) { if ( - this._url && - !HTMLAnchorElementUtility.isBlobURL(this._url) && - this._url.host && - this._url.protocol != 'file' + this[PropertySymbol.url] && + !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url]) && + this[PropertySymbol.url].host && + this[PropertySymbol.url].protocol != 'file' ) { - this._url.password = password; - this.setAttribute('href', this._url.toString()); + this[PropertySymbol.url].password = password; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -203,7 +208,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Pathname. */ public get pathname(): string { - return this._url?.pathname ?? ''; + return this[PropertySymbol.url]?.pathname ?? ''; } /** @@ -212,9 +217,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param pathname Pathname. */ public set pathname(pathname: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.pathname = pathname; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].pathname = pathname; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -224,7 +229,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Port. */ public get port(): string { - return this._url?.port ?? ''; + return this[PropertySymbol.url]?.port ?? ''; } /** @@ -234,13 +239,13 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho */ public set port(port: string) { if ( - this._url && - !HTMLAnchorElementUtility.isBlobURL(this._url) && - this._url.host && - this._url.protocol != 'file' + this[PropertySymbol.url] && + !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url]) && + this[PropertySymbol.url].host && + this[PropertySymbol.url].protocol != 'file' ) { - this._url.port = port; - this.setAttribute('href', this._url.toString()); + this[PropertySymbol.url].port = port; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -250,7 +255,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Host. */ public get host(): string { - return this._url?.host ?? ''; + return this[PropertySymbol.url]?.host ?? ''; } /** @@ -259,9 +264,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param host Host. */ public set host(host: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.host = host; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].host = host; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -271,7 +276,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Hostname. */ public get hostname(): string { - return this._url?.hostname ?? ''; + return this[PropertySymbol.url]?.hostname ?? ''; } /** @@ -280,9 +285,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param hostname Hostname. */ public set hostname(hostname: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.hostname = hostname; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].hostname = hostname; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -328,10 +333,10 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Rel list. */ public get relList(): IDOMTokenList { - if (!this._relList) { - this._relList = new DOMTokenList(this, 'rel'); + if (!this[PropertySymbol.relList]) { + this[PropertySymbol.relList] = new DOMTokenList(this, 'rel'); } - return this._relList; + return this[PropertySymbol.relList]; } /** @@ -340,7 +345,7 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @returns Search. */ public get search(): string { - return this._url?.search ?? ''; + return this[PropertySymbol.url]?.search ?? ''; } /** @@ -349,9 +354,9 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho * @param search Search. */ public set search(search: string) { - if (this._url && !HTMLAnchorElementUtility.isBlobURL(this._url)) { - this._url.search = search; - this.setAttribute('href', this._url.toString()); + if (this[PropertySymbol.url] && !HTMLAnchorElementUtility.isBlobURL(this[PropertySymbol.url])) { + this[PropertySymbol.url].search = search; + this.setAttribute('href', this[PropertySymbol.url].toString()); } } @@ -415,4 +420,29 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLAncho public override toString(): string { return this.href; } + + /** + * @override + */ + public override dispatchEvent(event: Event): boolean { + const returnValue = super.dispatchEvent(event); + + if ( + event.type === 'click' && + (event.eventPhase === EventPhaseEnum.atTarget || + event.eventPhase === EventPhaseEnum.bubbling) && + !event.defaultPrevented && + this[PropertySymbol.url] + ) { + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].open( + this[PropertySymbol.url].toString(), + this.target || '_self' + ); + if (this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].closed) { + event.stopImmediatePropagation(); + } + } + + return returnValue; + } } diff --git a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElementNamedNodeMap.ts index 33843cc0f..8832b3626 100644 --- a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLAnchorElement from './HTMLAnchorElement.js'; import HTMLAnchorElementUtility from './HTMLAnchorElementUtility.js'; @@ -9,7 +10,7 @@ import HTMLAnchorElementUtility from './HTMLAnchorElementUtility.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLAnchorElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLAnchorElement; + protected [PropertySymbol.ownerElement]: HTMLAnchorElement; /** * @override @@ -17,12 +18,15 @@ export default class HTMLAnchorElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if (item.name === 'rel' && this._ownerElement._relList) { - this._ownerElement._relList._updateIndices(); - } else if (item.name === 'href') { - this._ownerElement._url = HTMLAnchorElementUtility.getUrl( - this._ownerElement.ownerDocument, - item.value + if ( + item[PropertySymbol.name] === 'rel' && + this[PropertySymbol.ownerElement][PropertySymbol.relList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); + } else if (item[PropertySymbol.name] === 'href') { + this[PropertySymbol.ownerElement][PropertySymbol.url] = HTMLAnchorElementUtility.getUrl( + this[PropertySymbol.ownerElement].ownerDocument, + item[PropertySymbol.value] ); } @@ -32,14 +36,17 @@ export default class HTMLAnchorElementNamedNodeMap extends HTMLElementNamedNodeM /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); if (removedItem) { - if (removedItem.name === 'rel' && this._ownerElement._relList) { - this._ownerElement._relList._updateIndices(); - } else if (removedItem.name === 'href') { - this._ownerElement._url = null; + if ( + removedItem[PropertySymbol.name] === 'rel' && + this[PropertySymbol.ownerElement][PropertySymbol.relList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); + } else if (removedItem[PropertySymbol.name] === 'href') { + this[PropertySymbol.ownerElement][PropertySymbol.url] = null; } } diff --git a/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts b/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts index 3d58678dc..f4d217a7b 100644 --- a/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts +++ b/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts @@ -1,5 +1,6 @@ import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLBaseElement from './IHTMLBaseElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * HTML Base Element. @@ -18,7 +19,7 @@ export default class HTMLBaseElement extends HTMLElement implements IHTMLBaseEle if (href !== null) { return href; } - return this.ownerDocument.location.href; + return this[PropertySymbol.ownerDocument].location.href; } /** diff --git a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts index 14252f73a..a0426fcc7 100644 --- a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts +++ b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts @@ -1,4 +1,5 @@ import Event from '../../event/Event.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import ValidityState from '../../validity-state/ValidityState.js'; @@ -21,9 +22,29 @@ const BUTTON_TYPES = ['submit', 'reset', 'button', 'menu']; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement. */ export default class HTMLButtonElement extends HTMLElement implements IHTMLButtonElement { - public override readonly attributes: INamedNodeMap = new HTMLButtonElementNamedNodeMap(this); - public readonly validationMessage = ''; - public readonly validity = new ValidityState(this); + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLButtonElementNamedNodeMap( + this + ); + public [PropertySymbol.validationMessage] = ''; + public [PropertySymbol.validity] = new ValidityState(this); + + /** + * Returns validation message. + * + * @returns Validation message. + */ + public get validationMessage(): string { + return this[PropertySymbol.validationMessage]; + } + + /** + * Returns validity. + * + * @returns Validity. + */ + public get validity(): ValidityState { + return this[PropertySymbol.validity]; + } /** * Returns name. @@ -89,7 +110,7 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto * @returns Type */ public get type(): string { - return this._sanitizeType(this.getAttribute('type')); + return this.#sanitizeType(this.getAttribute('type')); } /** @@ -98,7 +119,7 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto * @param v Type */ public set type(v: string) { - this.setAttribute('type', this._sanitizeType(v)); + this.setAttribute('type', this.#sanitizeType(v)); } /** @@ -129,7 +150,7 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** @@ -148,7 +169,10 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto */ public checkValidity(): boolean { const valid = - this.disabled || this.type === 'reset' || this.type === 'button' || this.validity.valid; + this.disabled || + this.type === 'reset' || + this.type === 'button' || + this[PropertySymbol.validity].valid; if (!valid) { this.dispatchEvent(new Event('invalid', { bubbles: true, cancelable: true })); } @@ -170,25 +194,7 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto * @param message Message. */ public setCustomValidity(message: string): void { - (this.validationMessage) = String(message); - } - - /** - * Sanitizes type. - * - * TODO: We can improve performance a bit if we make the types as a constant. - * - * @param type Type. - * @returns Type sanitized. - */ - protected _sanitizeType(type: string): string { - type = (type && type.toLowerCase()) || 'submit'; - - if (!BUTTON_TYPES.includes(type)) { - type = 'submit'; - } - - return type; + this[PropertySymbol.validationMessage] = String(message); } /** @@ -205,10 +211,10 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto event.type === 'click' && (event.eventPhase === EventPhaseEnum.atTarget || event.eventPhase === EventPhaseEnum.bubbling) && - this._formNode && - this.isConnected + this[PropertySymbol.formNode] && + this[PropertySymbol.isConnected] ) { - const form = this._formNode; + const form = this[PropertySymbol.formNode]; switch (this.type) { case 'submit': form.requestSubmit(this); @@ -225,20 +231,44 @@ export default class HTMLButtonElement extends HTMLElement implements IHTMLButto /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldFormNode = this._formNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldFormNode = this[PropertySymbol.formNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldFormNode !== this._formNode) { + if (oldFormNode !== this[PropertySymbol.formNode]) { if (oldFormNode) { - oldFormNode._removeFormControlItem(this, this.name); - oldFormNode._removeFormControlItem(this, this.id); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); } - if (this._formNode) { - (this._formNode)._appendFormControlItem(this, this.name); - (this._formNode)._appendFormControlItem(this, this.id); + if (this[PropertySymbol.formNode]) { + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.name + ); + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.id + ); } } } + + /** + * Sanitizes type. + * + * TODO: We can improve performance a bit if we make the types as a constant. + * + * @param type Type. + * @returns Type sanitized. + */ + #sanitizeType(type: string): string { + type = (type && type.toLowerCase()) || 'submit'; + + if (!BUTTON_TYPES.includes(type)) { + type = 'submit'; + } + + return type; + } } diff --git a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts index f145d2017..f58ec815c 100644 --- a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLButtonElement from './HTMLButtonElement.js'; @@ -9,7 +10,7 @@ import HTMLButtonElement from './HTMLButtonElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLButtonElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLButtonElement; + protected [PropertySymbol.ownerElement]: HTMLButtonElement; /** * @override @@ -17,18 +18,19 @@ export default class HTMLButtonElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if ((item.name === 'id' || item.name === 'name') && this._ownerElement._formNode) { - if (replacedItem?.value) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - replacedItem.value - ); + if ( + (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] + ) { + if (replacedItem?.[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); } - if (item.value) { - (this._ownerElement._formNode)._appendFormControlItem( - this._ownerElement, - item.value - ); + if (item[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.appendFormControlItem + ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); } } @@ -38,18 +40,17 @@ export default class HTMLButtonElementNamedNodeMap extends HTMLElementNamedNodeM /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); if ( removedItem && - (removedItem.name === 'id' || removedItem.name === 'name') && - this._ownerElement._formNode + (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] ) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - removedItem.value - ); + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-dialog-element/HTMLDialogElement.ts b/packages/happy-dom/src/nodes/html-dialog-element/HTMLDialogElement.ts index 2b77cc33e..dcbe08af3 100644 --- a/packages/happy-dom/src/nodes/html-dialog-element/HTMLDialogElement.ts +++ b/packages/happy-dom/src/nodes/html-dialog-element/HTMLDialogElement.ts @@ -1,6 +1,7 @@ import Event from '../../event/Event.js'; import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLDialogElement from './IHTMLDialogElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * HTML Dialog Element. @@ -9,12 +10,31 @@ import IHTMLDialogElement from './IHTMLDialogElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement. */ export default class HTMLDialogElement extends HTMLElement implements IHTMLDialogElement { - public returnValue = ''; + // Internal properties + public [PropertySymbol.returnValue] = ''; // Events public oncancel: (event: Event) => void | null = null; public onclose: (event: Event) => void | null = null; + /** + * Returns return value. + * + * @returns Return value. + */ + public get returnValue(): string { + return this[PropertySymbol.returnValue]; + } + + /** + * Sets return value. + * + * @param value Return value. + */ + public set returnValue(value: string) { + this[PropertySymbol.returnValue] = value; + } + /** * Sets the "open" attribute. * diff --git a/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts b/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts index df203ccc9..620e0f85b 100644 --- a/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts +++ b/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts @@ -1,6 +1,32 @@ +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import Document from '../document/Document.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * Document. */ -export default class HTMLDocument extends Document {} +export default class HTMLDocument extends Document { + /** + * Constructor. + * + * @param injected Injected properties. + * @param injected.browserFrame Browser frame. + * @param injected.window Window. + */ + constructor(injected: { browserFrame: IBrowserFrame; window: IBrowserWindow }) { + super(injected); + + // Default document elements + const doctype = this[PropertySymbol.implementation].createDocumentType('html', '', ''); + const documentElement = this.createElement('html'); + const bodyElement = this.createElement('body'); + const headElement = this.createElement('head'); + + this.appendChild(doctype); + this.appendChild(documentElement); + + documentElement.appendChild(headElement); + documentElement.appendChild(bodyElement); + } +} diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index 60e1f7b05..e8802cf8d 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -1,4 +1,5 @@ import Element from '../element/Element.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLElement from './IHTMLElement.js'; import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; import PointerEvent from '../../event/events/PointerEvent.js'; @@ -17,23 +18,6 @@ import HTMLElementNamedNodeMap from './HTMLElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement. */ export default class HTMLElement extends Element implements IHTMLElement { - public override readonly attributes: INamedNodeMap = new HTMLElementNamedNodeMap(this); - public readonly accessKey = ''; - public readonly accessKeyLabel = ''; - public readonly contentEditable = 'inherit'; - public readonly isContentEditable = false; - public readonly offsetHeight = 0; - public readonly offsetWidth = 0; - public readonly offsetLeft = 0; - public readonly offsetTop = 0; - public readonly clientHeight = 0; - public readonly clientWidth = 0; - public readonly clientLeft = 0; - public readonly clientTop = 0; - - public _style: CSSStyleDeclaration = null; - private _dataset: Dataset = null; - // Events public oncopy: (event: Event) => void | null = null; public oncut: (event: Event) => void | null = null; @@ -61,6 +45,141 @@ export default class HTMLElement extends Element implements IHTMLElement { public ontransitionrun: (event: Event) => void | null = null; public ontransitionstart: (event: Event) => void | null = null; + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLElementNamedNodeMap(this); + public [PropertySymbol.accessKey] = ''; + public [PropertySymbol.contentEditable] = 'inherit'; + public [PropertySymbol.isContentEditable] = false; + public [PropertySymbol.offsetHeight] = 0; + public [PropertySymbol.offsetWidth] = 0; + public [PropertySymbol.offsetLeft] = 0; + public [PropertySymbol.offsetTop] = 0; + public [PropertySymbol.clientHeight] = 0; + public [PropertySymbol.clientWidth] = 0; + public [PropertySymbol.clientLeft] = 0; + public [PropertySymbol.clientTop] = 0; + public [PropertySymbol.style]: CSSStyleDeclaration = null; + + // Private properties + #dataset: Dataset = null; + + /** + * Returns access key. + * + * @returns Access key. + */ + public get accessKey(): string { + return this[PropertySymbol.accessKey]; + } + + /** + * Sets access key. + * + * @param accessKey Access key. + */ + public set accessKey(accessKey: string) { + this[PropertySymbol.accessKey] = accessKey; + } + + /** + * Returns content editable. + * + * @returns Content editable. + */ + public get contentEditable(): string { + return this[PropertySymbol.contentEditable]; + } + + /** + * Sets content editable. + * + * @param contentEditable Content editable. + */ + public set contentEditable(contentEditable: string) { + this[PropertySymbol.contentEditable] = contentEditable; + } + + /** + * Returns is content editable. + * + * @returns Is content editable. + */ + public get isContentEditable(): boolean { + return this[PropertySymbol.isContentEditable]; + } + + /** + * Returns offset height. + * + * @returns Offset height. + */ + public get offsetHeight(): number { + return this[PropertySymbol.offsetHeight]; + } + + /** + * Returns offset width. + * + * @returns Offset width. + */ + public get offsetWidth(): number { + return this[PropertySymbol.offsetWidth]; + } + + /** + * Returns offset left. + * + * @returns Offset left. + */ + public get offsetLeft(): number { + return this[PropertySymbol.offsetLeft]; + } + + /** + * Returns offset top. + * + * @returns Offset top. + */ + public get offsetTop(): number { + return this[PropertySymbol.offsetTop]; + } + + /** + * Returns client height. + * + * @returns Client height. + */ + public get clientHeight(): number { + return this[PropertySymbol.clientHeight]; + } + + /** + * Returns client width. + * + * @returns Client width. + */ + public get clientWidth(): number { + return this[PropertySymbol.clientWidth]; + } + + /** + * Returns client left. + * + * @returns Client left. + */ + public get clientLeft(): number { + return this[PropertySymbol.clientLeft]; + } + + /** + * Returns client top. + * + * @returns Client top. + */ + public get clientTop(): number { + return this[PropertySymbol.clientTop]; + } + /** * Returns tab index. * @@ -91,18 +210,24 @@ export default class HTMLElement extends Element implements IHTMLElement { * @returns Inner text. */ public get innerText(): string { - if (!this.isConnected) { + if (!this[PropertySymbol.isConnected]) { return this.textContent; } let result = ''; - for (const childNode of this._childNodes) { - if (childNode.nodeType === NodeTypeEnum.elementNode) { + for (const childNode of this[PropertySymbol.childNodes]) { + if (childNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { const childElement = childNode; - const computedStyle = this.ownerDocument.defaultView.getComputedStyle(childElement); - - if (childElement.tagName !== 'SCRIPT' && childElement.tagName !== 'STYLE') { + const computedStyle = + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].getComputedStyle( + childElement + ); + + if ( + childElement[PropertySymbol.tagName] !== 'SCRIPT' && + childElement[PropertySymbol.tagName] !== 'STYLE' + ) { const display = computedStyle.display; if (display !== 'none') { const textTransform = computedStyle.textTransform; @@ -128,7 +253,7 @@ export default class HTMLElement extends Element implements IHTMLElement { result += text; } } - } else if (childNode.nodeType === NodeTypeEnum.textNode) { + } else if (childNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode) { result += childNode.textContent.replace(/[\n\r]/, ''); } } @@ -143,7 +268,7 @@ export default class HTMLElement extends Element implements IHTMLElement { * @param innerText Inner text. */ public set innerText(text: string) { - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } @@ -151,9 +276,9 @@ export default class HTMLElement extends Element implements IHTMLElement { for (let i = 0, max = texts.length; i < max; i++) { if (i !== 0) { - this.appendChild(this.ownerDocument.createElement('br')); + this.appendChild(this[PropertySymbol.ownerDocument].createElement('br')); } - this.appendChild(this.ownerDocument.createTextNode(texts[i])); + this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(texts[i])); } } @@ -174,7 +299,7 @@ export default class HTMLElement extends Element implements IHTMLElement { * @param text Text. */ public set outerText(text: string) { - if (!this.parentNode) { + if (!this[PropertySymbol.parentNode]) { throw new DOMException( "Failed to set the 'outerHTML' property on 'Element': This element has no parent node." ); @@ -184,12 +309,18 @@ export default class HTMLElement extends Element implements IHTMLElement { for (let i = 0, max = texts.length; i < max; i++) { if (i !== 0) { - this.parentNode.insertBefore(this.ownerDocument.createElement('br'), this); + this[PropertySymbol.parentNode].insertBefore( + this[PropertySymbol.ownerDocument].createElement('br'), + this + ); } - this.parentNode.insertBefore(this.ownerDocument.createTextNode(texts[i]), this); + this[PropertySymbol.parentNode].insertBefore( + this[PropertySymbol.ownerDocument].createTextNode(texts[i]), + this + ); } - this.parentNode.removeChild(this); + this[PropertySymbol.parentNode].removeChild(this); } /** @@ -198,10 +329,10 @@ export default class HTMLElement extends Element implements IHTMLElement { * @returns Style. */ public get style(): CSSStyleDeclaration { - if (!this._style) { - this._style = new CSSStyleDeclaration(this); + if (!this[PropertySymbol.style]) { + this[PropertySymbol.style] = new CSSStyleDeclaration(this); } - return this._style; + return this[PropertySymbol.style]; } /** @@ -220,7 +351,7 @@ export default class HTMLElement extends Element implements IHTMLElement { * @returns Data set. */ public get dataset(): { [key: string]: string } { - return (this._dataset ??= new Dataset(this)).proxy; + return (this.#dataset ??= new Dataset(this)).proxy; } /** @@ -307,8 +438,8 @@ export default class HTMLElement extends Element implements IHTMLElement { bubbles: true, composed: true }); - event._target = this; - event._currentTarget = this; + event[PropertySymbol.target] = this; + event[PropertySymbol.currentTarget] = this; this.dispatchEvent(event); } @@ -332,13 +463,12 @@ export default class HTMLElement extends Element implements IHTMLElement { public cloneNode(deep = false): IHTMLElement { const clone = super.cloneNode(deep); - (clone.accessKey) = this.accessKey; - (clone.accessKeyLabel) = this.accessKeyLabel; - (clone.contentEditable) = this.contentEditable; - (clone.isContentEditable) = this.isContentEditable; + clone[PropertySymbol.accessKey] = this[PropertySymbol.accessKey]; + clone[PropertySymbol.contentEditable] = this[PropertySymbol.contentEditable]; + clone[PropertySymbol.isContentEditable] = this[PropertySymbol.isContentEditable]; - if (this._style) { - clone.style.cssText = this._style.cssText; + if (this[PropertySymbol.style]) { + clone.style.cssText = this[PropertySymbol.style].cssText; } return clone; diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts index faa2cd511..b19070914 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import ElementNamedNodeMap from '../element/ElementNamedNodeMap.js'; import HTMLElement from './HTMLElement.js'; @@ -8,7 +9,7 @@ import HTMLElement from './HTMLElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLElementNamedNodeMap extends ElementNamedNodeMap { - protected _ownerElement: HTMLElement; + protected [PropertySymbol.ownerElement]: HTMLElement; /** * @override @@ -16,8 +17,11 @@ export default class HTMLElementNamedNodeMap extends ElementNamedNodeMap { public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if (item.name === 'style' && this._ownerElement._style) { - this._ownerElement._style.cssText = item.value; + if ( + item[PropertySymbol.name] === 'style' && + this[PropertySymbol.ownerElement][PropertySymbol.style] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = item[PropertySymbol.value]; } return replacedItem || null; @@ -26,11 +30,15 @@ export default class HTMLElementNamedNodeMap extends ElementNamedNodeMap { /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); - if (removedItem && removedItem.name === 'style' && this._ownerElement._style) { - this._ownerElement._style.cssText = ''; + if ( + removedItem && + removedItem[PropertySymbol.name] === 'style' && + this[PropertySymbol.ownerElement][PropertySymbol.style] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = ''; } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts index 435f8ebf2..ceda0a8d6 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts @@ -1,4 +1,5 @@ import FocusEvent from '../../event/events/FocusEvent.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; import ISVGElement from '../svg-element/ISVGElement.js'; @@ -12,13 +13,17 @@ export default class HTMLElementUtility { * @param element Element. */ public static blur(element: IHTMLElement | ISVGElement): void { - if (element.ownerDocument['_activeElement'] !== element || !element.isConnected) { + if ( + element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] !== element || + !element[PropertySymbol.isConnected] + ) { return; } - const relatedTarget = element.ownerDocument['_nextActiveElement'] ?? null; + const relatedTarget = + element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] ?? null; - element.ownerDocument['_activeElement'] = null; + element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; element.dispatchEvent( new FocusEvent('blur', { @@ -42,23 +47,26 @@ export default class HTMLElementUtility { * @param element Element. */ public static focus(element: IHTMLElement | ISVGElement): void { - if (element.ownerDocument['_activeElement'] === element || !element.isConnected) { + if ( + element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === element || + !element[PropertySymbol.isConnected] + ) { return; } // Set the next active element so `blur` can use it for `relatedTarget`. - element.ownerDocument['_nextActiveElement'] = element; + element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] = element; - const relatedTarget = element.ownerDocument['_activeElement']; + const relatedTarget = element[PropertySymbol.ownerDocument][PropertySymbol.activeElement]; - if (element.ownerDocument['_activeElement'] !== null) { - element.ownerDocument['_activeElement'].blur(); + if (element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] !== null) { + element[PropertySymbol.ownerDocument][PropertySymbol.activeElement].blur(); } // Clean up after blur, so it does not affect next blur call. - element.ownerDocument['_nextActiveElement'] = null; + element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] = null; - element.ownerDocument['_activeElement'] = element; + element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = element; element.dispatchEvent( new FocusEvent('focus', { diff --git a/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts b/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts index f341e351a..93259bc04 100644 --- a/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/IHTMLElement.ts @@ -9,6 +9,9 @@ import IElement from '../element/IElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement. */ export default interface IHTMLElement extends IElement { + accessKey: string; + contentEditable: string; + isContentEditable: boolean; dataset: { [key: string]: string }; tabIndex: number; offsetHeight: number; diff --git a/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts b/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts index e8bada726..18aeef4ed 100644 --- a/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts +++ b/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts @@ -1,4 +1,5 @@ import IHTMLFormControlsCollection from './IHTMLFormControlsCollection.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLInputElement from '../html-input-element/IHTMLInputElement.js'; import IHTMLTextAreaElement from '../html-text-area-element/IHTMLTextAreaElement.js'; import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement.js'; @@ -14,7 +15,7 @@ export default class HTMLFormControlsCollection extends Array implements IHTMLFormControlsCollection { - public _namedItems: { [k: string]: RadioNodeList } = {}; + public [PropertySymbol.namedItems]: { [k: string]: RadioNodeList } = {}; /** * Returns item by index. @@ -42,11 +43,11 @@ export default class HTMLFormControlsCollection | IHTMLButtonElement | RadioNodeList | null { - if (this._namedItems[name] && this._namedItems[name].length) { - if (this._namedItems[name].length === 1) { - return this._namedItems[name][0]; + if (this[PropertySymbol.namedItems][name] && this[PropertySymbol.namedItems][name].length) { + if (this[PropertySymbol.namedItems][name].length === 1) { + return this[PropertySymbol.namedItems][name][0]; } - return this._namedItems[name]; + return this[PropertySymbol.namedItems][name]; } return null; } @@ -57,20 +58,23 @@ export default class HTMLFormControlsCollection * @param node Node. * @param name Name. */ - public _appendNamedItem( + public [PropertySymbol.appendNamedItem]( node: IHTMLInputElement | IHTMLTextAreaElement | IHTMLSelectElement | IHTMLButtonElement, name: string ): void { if (name) { - this._namedItems[name] = this._namedItems[name] || new RadioNodeList(); + this[PropertySymbol.namedItems][name] = + this[PropertySymbol.namedItems][name] || new RadioNodeList(); - if (!this._namedItems[name].includes(node)) { - this._namedItems[name].push(node); + if (!this[PropertySymbol.namedItems][name].includes(node)) { + this[PropertySymbol.namedItems][name].push(node); } - if (this._isValidPropertyName(name)) { + if (this[PropertySymbol.isValidPropertyName](name)) { this[name] = - this._namedItems[name].length > 1 ? this._namedItems[name] : this._namedItems[name][0]; + this[PropertySymbol.namedItems][name].length > 1 + ? this[PropertySymbol.namedItems][name] + : this[PropertySymbol.namedItems][name][0]; } } } @@ -81,24 +85,26 @@ export default class HTMLFormControlsCollection * @param node Node. * @param name Name. */ - public _removeNamedItem( + public [PropertySymbol.removeNamedItem]( node: IHTMLInputElement | IHTMLTextAreaElement | IHTMLSelectElement | IHTMLButtonElement, name: string ): void { - if (name && this._namedItems[name]) { - const index = this._namedItems[name].indexOf(node); + if (name && this[PropertySymbol.namedItems][name]) { + const index = this[PropertySymbol.namedItems][name].indexOf(node); if (index > -1) { - this._namedItems[name].splice(index, 1); + this[PropertySymbol.namedItems][name].splice(index, 1); - if (this._namedItems[name].length === 0) { - delete this._namedItems[name]; - if (this.hasOwnProperty(name) && this._isValidPropertyName(name)) { + if (this[PropertySymbol.namedItems][name].length === 0) { + delete this[PropertySymbol.namedItems][name]; + if (this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { delete this[name]; } - } else if (this._isValidPropertyName(name)) { + } else if (this[PropertySymbol.isValidPropertyName](name)) { this[name] = - this._namedItems[name].length > 1 ? this._namedItems[name] : this._namedItems[name][0]; + this[PropertySymbol.namedItems][name].length > 1 + ? this[PropertySymbol.namedItems][name] + : this[PropertySymbol.namedItems][name][0]; } } } @@ -110,7 +116,7 @@ export default class HTMLFormControlsCollection * @param name Name. * @returns True if the property name is valid. */ - protected _isValidPropertyName(name: string): boolean { + protected [PropertySymbol.isValidPropertyName](name: string): boolean { return ( !this.constructor.prototype.hasOwnProperty(name) && !Array.prototype.hasOwnProperty(name) && diff --git a/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts b/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts index 068265503..db067657b 100644 --- a/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts +++ b/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLFormElement from './IHTMLFormElement.js'; import Event from '../../event/Event.js'; import SubmitEvent from '../../event/events/SubmitEvent.js'; @@ -17,17 +18,33 @@ import IHTMLButtonElement from '../html-button-element/IHTMLButtonElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement. */ export default class HTMLFormElement extends HTMLElement implements IHTMLFormElement { - // Public properties. - public readonly elements: IHTMLFormControlsCollection = new HTMLFormControlsCollection(); - public readonly length = 0; + // Internal properties. + public [PropertySymbol.elements]: IHTMLFormControlsCollection = new HTMLFormControlsCollection(); + public [PropertySymbol.length] = 0; + public [PropertySymbol.formNode]: INode = this; // Events public onformdata: (event: Event) => void | null = null; public onreset: (event: Event) => void | null = null; public onsubmit: (event: Event) => void | null = null; - // Private properties - public _formNode: INode = this; + /** + * Returns elements. + * + * @returns Elements. + */ + public get elements(): IHTMLFormControlsCollection { + return this[PropertySymbol.elements]; + } + + /** + * Returns length. + * + * @returns Length. + */ + public get length(): number { + return this[PropertySymbol.length]; + } /** * Returns name. @@ -220,13 +237,16 @@ export default class HTMLFormElement extends HTMLElement implements IHTMLFormEle * Resets form. */ public reset(): void { - for (const element of this.elements) { - if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { - element['_value'] = null; - element['_checked'] = null; - } else if (element.tagName === 'TEXTAREA') { - element['_value'] = null; - } else if (element.tagName === 'SELECT') { + for (const element of this[PropertySymbol.elements]) { + if ( + element[PropertySymbol.tagName] === 'INPUT' || + element[PropertySymbol.tagName] === 'TEXTAREA' + ) { + element[PropertySymbol.value] = null; + element[PropertySymbol.checked] = null; + } else if (element[PropertySymbol.tagName] === 'TEXTAREA') { + element[PropertySymbol.value] = null; + } else if (element[PropertySymbol.tagName] === 'SELECT') { let hasSelectedAttribute = false; for (const option of (element).options) { if (option.hasAttribute('selected')) { @@ -253,8 +273,8 @@ export default class HTMLFormElement extends HTMLElement implements IHTMLFormEle const radioValidationState: { [k: string]: boolean } = {}; let isFormValid = true; - for (const element of this.elements) { - if (element.tagName === 'INPUT' && element.type === 'radio' && element.name) { + for (const element of this[PropertySymbol.elements]) { + if (element[PropertySymbol.tagName] === 'INPUT' && element.type === 'radio' && element.name) { if (!radioValidationState[element.name]) { radioValidationState[element.name] = true; if (!element.checkValidity()) { @@ -295,18 +315,23 @@ export default class HTMLFormElement extends HTMLElement implements IHTMLFormEle * @param node Node. * @param name Name */ - public _appendFormControlItem( + public [PropertySymbol.appendFormControlItem]( node: IHTMLInputElement | IHTMLTextAreaElement | IHTMLSelectElement | IHTMLButtonElement, name: string ): void { - if (!this.elements.includes(node)) { - this[this.elements.length] = node; - this.elements.push(node); - (this.length) = this.elements.length; + const elements = this[PropertySymbol.elements]; + + if (!elements.includes(node)) { + this[elements.length] = node; + elements.push(node); + this[PropertySymbol.length] = elements.length; } - (this.elements)._appendNamedItem(node, name); - this[name] = this.elements[name]; + (elements)[PropertySymbol.appendNamedItem](node, name); + + if (this[PropertySymbol.isValidPropertyName](name)) { + this[name] = elements[name]; + } } /** @@ -315,27 +340,43 @@ export default class HTMLFormElement extends HTMLElement implements IHTMLFormEle * @param node Node. * @param name Name. */ - public _removeFormControlItem( + public [PropertySymbol.removeFormControlItem]( node: IHTMLInputElement | IHTMLTextAreaElement | IHTMLSelectElement | IHTMLButtonElement, name: string ): void { - const index = this.elements.indexOf(node); + const elements = this[PropertySymbol.elements]; + const index = elements.indexOf(node); if (index !== -1) { - this.elements.splice(index, 1); - for (let i = index; i < this.length; i++) { + elements.splice(index, 1); + for (let i = index; i < this[PropertySymbol.length]; i++) { this[i] = this[i + 1]; } - delete this[this.length - 1]; - (this.length)--; + delete this[this[PropertySymbol.length] - 1]; + this[PropertySymbol.length]--; } - (this.elements)._removeNamedItem(node, name); + (elements)[PropertySymbol.removeNamedItem](node, name); - if (this.elements[name]) { - this[name] = this.elements[name]; - } else { - delete this[name]; + if (this[PropertySymbol.isValidPropertyName](name)) { + if (elements[name]) { + this[name] = elements[name]; + } else { + delete this[name]; + } } } + + /** + * Returns "true" if the property name is valid. + * + * @param name Name. + * @returns True if the property name is valid. + */ + protected [PropertySymbol.isValidPropertyName](name: string): boolean { + return ( + !this.constructor.prototype.hasOwnProperty(name) && + (isNaN(Number(name)) || name.includes('.')) + ); + } } diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts index 93f4dbba0..f7a337586 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts @@ -1,13 +1,15 @@ import Event from '../../event/Event.js'; -import IWindow from '../../window/IWindow.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import IDocument from '../document/IDocument.js'; import HTMLElement from '../html-element/HTMLElement.js'; import INode from '../node/INode.js'; -import IFrameCrossOriginWindow from './IFrameCrossOriginWindow.js'; import IHTMLIFrameElement from './IHTMLIFrameElement.js'; -import HTMLIFrameUtility from './HTMLIFrameUtility.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLIFrameElementNamedNodeMap from './HTMLIFrameElementNamedNodeMap.js'; +import ICrossOriginBrowserWindow from '../../window/ICrossOriginBrowserWindow.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import HTMLIFrameElementPageLoader from './HTMLIFrameElementPageLoader.js'; /** * HTML Iframe Element. @@ -16,14 +18,33 @@ import HTMLIFrameElementNamedNodeMap from './HTMLIFrameElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement. */ export default class HTMLIFrameElement extends HTMLElement implements IHTMLIFrameElement { - public override readonly attributes: INamedNodeMap = new HTMLIFrameElementNamedNodeMap(this); - // Events public onload: (event: Event) => void | null = null; public onerror: (event: Event) => void | null = null; // Internal properties - public _contentWindow: IWindow | IFrameCrossOriginWindow | null = null; + public override [PropertySymbol.attributes]: INamedNodeMap; + + // Private properties + #contentWindowContainer: { window: IBrowserWindow | ICrossOriginBrowserWindow | null } = { + window: null + }; + #pageLoader: HTMLIFrameElementPageLoader; + + /** + * Constructor. + * + * @param browserFrame Browser frame. + */ + constructor(browserFrame: IBrowserFrame) { + super(); + this.#pageLoader = new HTMLIFrameElementPageLoader({ + element: this, + contentWindowContainer: this.#contentWindowContainer, + browserParentFrame: browserFrame + }); + this[PropertySymbol.attributes] = new HTMLIFrameElementNamedNodeMap(this, this.#pageLoader); + } /** * Returns source. @@ -157,7 +178,7 @@ export default class HTMLIFrameElement extends HTMLElement implements IHTMLIFram * @returns Content document. */ public get contentDocument(): IDocument | null { - return (this._contentWindow)?.document || null; + return (this.#contentWindowContainer.window)?.document ?? null; } /** @@ -165,21 +186,25 @@ export default class HTMLIFrameElement extends HTMLElement implements IHTMLIFram * * @returns Content window. */ - public get contentWindow(): IWindow | IFrameCrossOriginWindow | null { - return this._contentWindow; + public get contentWindow(): IBrowserWindow | ICrossOriginBrowserWindow | null { + return this.#contentWindowContainer.window; } /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const isConnected = this.isConnected; - const isParentConnected = parentNode ? parentNode.isConnected : false; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const isConnected = this[PropertySymbol.isConnected]; + const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (isParentConnected && isConnected !== isParentConnected) { - HTMLIFrameUtility.loadPage(this); + if (isConnected !== isParentConnected) { + if (isParentConnected) { + this.#pageLoader.loadPage(); + } else { + this.#pageLoader.unloadPage(); + } } } diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts index 5dff9daf5..798740a25 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts @@ -1,7 +1,8 @@ import IAttr from '../attr/IAttr.js'; +import Element from '../element/Element.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLIFrameElement from './HTMLIFrameElement.js'; -import HTMLIFrameUtility from './HTMLIFrameUtility.js'; +import HTMLIFrameElementPageLoader from './HTMLIFrameElementPageLoader.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * Named Node Map. @@ -9,7 +10,18 @@ import HTMLIFrameUtility from './HTMLIFrameUtility.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLIFrameElement; + #pageLoader: HTMLIFrameElementPageLoader; + + /** + * Constructor. + * + * @param ownerElement Owner element. + * @param pageLoader + */ + constructor(ownerElement: Element, pageLoader: HTMLIFrameElementPageLoader) { + super(ownerElement); + this.#pageLoader = pageLoader; + } /** * @override @@ -17,8 +29,12 @@ export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: IAttr): IAttr | null { const replacedAttribute = super.setNamedItem(item); - if (item.name === 'src' && item.value && item.value !== replacedAttribute?.value) { - HTMLIFrameUtility.loadPage(this._ownerElement); + if ( + item[PropertySymbol.name] === 'src' && + item[PropertySymbol.value] && + item[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value] + ) { + this.#pageLoader.loadPage(); } return replacedAttribute || null; diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts new file mode 100644 index 000000000..45f49ac88 --- /dev/null +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts @@ -0,0 +1,106 @@ +import Event from '../../event/Event.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import CrossOriginBrowserWindow from '../../window/CrossOriginBrowserWindow.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import ICrossOriginBrowserWindow from '../../window/ICrossOriginBrowserWindow.js'; +import IHTMLIFrameElement from './IHTMLIFrameElement.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import BrowserFrameURL from '../../browser/utilities/BrowserFrameURL.js'; +import BrowserFrameFactory from '../../browser/utilities/BrowserFrameFactory.js'; + +/** + * HTML Iframe page loader. + */ +export default class HTMLIFrameElementPageLoader { + #element: IHTMLIFrameElement; + #contentWindowContainer: { window: IBrowserWindow | ICrossOriginBrowserWindow | null }; + #browserParentFrame: IBrowserFrame; + #browserIFrame: IBrowserFrame; + + /** + * Constructor. + * + * @param options Options. + * @param options.element Iframe element. + * @param options.browserParentFrame Main browser frame. + * @param options.contentWindowContainer Content window container. + * @param options.contentWindowContainer.window Content window. + */ + constructor(options: { + element: IHTMLIFrameElement; + browserParentFrame: IBrowserFrame; + contentWindowContainer: { window: IBrowserWindow | ICrossOriginBrowserWindow | null }; + }) { + this.#element = options.element; + this.#contentWindowContainer = options.contentWindowContainer; + this.#browserParentFrame = options.browserParentFrame; + } + + /** + * Loads an iframe page. + */ + public loadPage(): void { + if (!this.#element[PropertySymbol.isConnected]) { + if (this.#browserIFrame) { + BrowserFrameFactory.destroyFrame(this.#browserIFrame); + this.#browserIFrame = null; + } + this.#contentWindowContainer.window = null; + return; + } + + const window = this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; + const originURL = this.#browserParentFrame.window.location; + const targetURL = BrowserFrameURL.getRelativeURL(this.#browserParentFrame, this.#element.src); + + if (this.#browserIFrame && this.#browserIFrame.window.location.href === targetURL.href) { + return; + } + + if (this.#browserParentFrame.page.context.browser.settings.disableIframePageLoading) { + WindowErrorUtility.dispatchError( + this.#element, + new DOMException( + `Failed to load iframe page "${targetURL.href}". Iframe page loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + return; + } + + // Iframes has a special rule for CORS and doesn't allow access between frames when the origin is different. + const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null'; + const parentWindow = isSameOrigin ? window : new CrossOriginBrowserWindow(window); + + this.#browserIFrame = + this.#browserIFrame ?? BrowserFrameFactory.newChildFrame(this.#browserParentFrame); + + ((this.#browserIFrame.window.top)) = + parentWindow; + ((this.#browserIFrame.window.parent)) = + parentWindow; + + this.#browserIFrame + .goto(targetURL.href) + .then(() => this.#element.dispatchEvent(new Event('load'))) + .catch((error) => WindowErrorUtility.dispatchError(this.#element, error)); + + this.#contentWindowContainer.window = isSameOrigin + ? this.#browserIFrame.window + : new CrossOriginBrowserWindow(this.#browserIFrame.window, window); + } + + /** + * Unloads an iframe page. + */ + public unloadPage(): void { + if (this.#browserIFrame) { + BrowserFrameFactory.destroyFrame(this.#browserIFrame); + this.#browserIFrame = null; + } + this.#contentWindowContainer.window = null; + } +} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameUtility.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameUtility.ts deleted file mode 100644 index 25accebf4..000000000 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameUtility.ts +++ /dev/null @@ -1,86 +0,0 @@ -import URL from '../../url/URL.js'; -import Event from '../../event/Event.js'; -import ErrorEvent from '../../event/events/ErrorEvent.js'; -import IWindow from '../../window/IWindow.js'; -import IFrameCrossOriginWindow from './IFrameCrossOriginWindow.js'; -import HTMLIFrameElement from './HTMLIFrameElement.js'; - -/** - * HTML Iframe Utility. - */ -export default class HTMLIFrameUtility { - /** - * Loads an iframe page. - * - * @param element - */ - public static async loadPage(element: HTMLIFrameElement): Promise { - if ( - element.isConnected && - !element.ownerDocument.defaultView.happyDOM.settings.disableIframePageLoading - ) { - const src = element.src; - - if (src) { - // To avoid circular dependency, we use a reference to the window class instead of importing it. - const contentWindow = new element.ownerDocument['_windowClass']({ - url: new URL(src, element.ownerDocument.defaultView.location.href).href, - settings: { - ...element.ownerDocument.defaultView.happyDOM.settings - } - }); - - (contentWindow.parent) = element.ownerDocument.defaultView; - (contentWindow.top) = element.ownerDocument.defaultView; - - if (src === 'about:blank') { - element._contentWindow = contentWindow; - return; - } - - if (src.startsWith('javascript:')) { - element._contentWindow = contentWindow; - element._contentWindow.eval(src.replace('javascript:', '')); - return; - } - - const originURL = element.ownerDocument.defaultView.location; - const targetURL = new URL(src, originURL); - const isCORS = - (originURL.hostname !== targetURL.hostname && - !originURL.hostname.endsWith(targetURL.hostname)) || - originURL.protocol !== targetURL.protocol; - - let responseText: string; - - element._contentWindow = null; - - try { - const response = await element.ownerDocument.defaultView.fetch(src); - responseText = await response.text(); - } catch (error) { - element.dispatchEvent( - new ErrorEvent('error', { - message: error.message, - error - }) - ); - element.ownerDocument.defaultView.dispatchEvent( - new ErrorEvent('error', { - message: error.message, - error - }) - ); - element.ownerDocument.defaultView.console.error(error); - return; - } - - element._contentWindow = isCORS - ? new IFrameCrossOriginWindow(element.ownerDocument.defaultView, contentWindow) - : contentWindow; - contentWindow.document.write(responseText); - element.dispatchEvent(new Event('load')); - } - } - } -} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/IFrameCrossOriginWindow.ts b/packages/happy-dom/src/nodes/html-iframe-element/IFrameCrossOriginWindow.ts deleted file mode 100644 index 450703260..000000000 --- a/packages/happy-dom/src/nodes/html-iframe-element/IFrameCrossOriginWindow.ts +++ /dev/null @@ -1,60 +0,0 @@ -import EventTarget from '../../event/EventTarget.js'; -import IWindow from '../../window/IWindow.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import Location from '../../location/Location.js'; - -/** - * Browser window with limited access due to CORS restrictions in iframes. - */ -export default class IFrameCrossOriginWindow extends EventTarget { - public readonly self = this; - public readonly window = this; - public readonly parent: IWindow; - public readonly top: IWindow; - public readonly location: Location; - - private _targetWindow: IWindow; - - /** - * Constructor. - * - * @param parent Parent window. - * @param target Target window. - */ - constructor(parent: IWindow, target: IWindow) { - super(); - - this.parent = parent; - this.top = parent; - this.location = new Proxy( - {}, - { - get: () => { - throw new DOMException( - `Blocked a frame with origin "${this.parent.location.origin}" from accessing a cross-origin frame.`, - DOMExceptionNameEnum.securityError - ); - }, - set: () => { - throw new DOMException( - `Blocked a frame with origin "${this.parent.location.origin}" from accessing a cross-origin frame.`, - DOMExceptionNameEnum.securityError - ); - } - } - ); - this._targetWindow = target; - } - - /** - * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. - * - * @param message Message. - * @param [targetOrigin=*] Target origin. - * @param transfer Transfer. Not implemented. - */ - public postMessage(message: unknown, targetOrigin = '*', transfer?: unknown[]): void { - this._targetWindow.postMessage(message, targetOrigin, transfer); - } -} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIFrameElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIFrameElement.ts index f1c50eedb..a430b1ff8 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIFrameElement.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/IHTMLIFrameElement.ts @@ -1,8 +1,8 @@ import Event from '../../event/Event.js'; -import IWindow from '../../window/IWindow.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; import IDocument from '../document/IDocument.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; -import IFrameCrossOriginWindow from './IFrameCrossOriginWindow.js'; +import ICrossOriginBrowserWindow from '../../window/ICrossOriginBrowserWindow.js'; /** * HTML Iframe Element. @@ -19,7 +19,7 @@ export default interface IHTMLIFrameElement extends IHTMLElement { sandbox: string | null; srcdoc: string | null; readonly contentDocument: IDocument | null; - readonly contentWindow: IWindow | IFrameCrossOriginWindow | null; + readonly contentWindow: IBrowserWindow | ICrossOriginBrowserWindow | null; // Events onload: (event: Event) => void | null; diff --git a/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts b/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts index cf72213f2..daffead4b 100644 --- a/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +++ b/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts @@ -1,5 +1,6 @@ import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLImageElement from './IHTMLImageElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * HTML Image Element. @@ -8,15 +9,101 @@ import IHTMLImageElement from './IHTMLImageElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement. */ export default class HTMLImageElement extends HTMLElement implements IHTMLImageElement { - public readonly tagName: string = 'IMG'; - public readonly complete = false; - public readonly naturalHeight = 0; - public readonly naturalWidth = 0; - public crossOrigin = null; - public decoding = 'auto'; - public loading = 'auto'; - public readonly x = 0; - public readonly y = 0; + public [PropertySymbol.tagName] = 'IMG'; + public [PropertySymbol.complete] = false; + public [PropertySymbol.naturalHeight] = 0; + public [PropertySymbol.naturalWidth] = 0; + public [PropertySymbol.loading] = 'auto'; + public [PropertySymbol.x] = 0; + public [PropertySymbol.y] = 0; + + /** + * Returns complete. + * + * @returns Complete. + */ + public get complete(): boolean { + return this[PropertySymbol.complete]; + } + + /** + * Returns natural height. + * + * @returns Natural height. + */ + public get naturalHeight(): number { + return this[PropertySymbol.naturalHeight]; + } + + /** + * Returns natural width. + * + * @returns Natural width. + */ + public get naturalWidth(): number { + return this[PropertySymbol.naturalWidth]; + } + + /** + * Returns loading. + * + * @returns Loading. + */ + public get loading(): string { + return this[PropertySymbol.loading]; + } + + /** + * Returns x. + */ + public get x(): number { + return this[PropertySymbol.x]; + } + + /** + * Returns y. + */ + public get y(): number { + return this[PropertySymbol.y]; + } + + /** + * Returns decoding. + * + * @returns Decoding. + */ + public get decoding(): string { + return this.getAttribute('decoding') || 'auto'; + } + + /** + * Sets decoding. + * + * @param decoding Decoding. + */ + public set decoding(decoding: string) { + this.setAttribute('decoding', decoding); + } + + /** + * Returns cross origin. + * + * @returns Cross origin. + */ + public get crossOrigin(): string | null { + return this.getAttribute('crossOrigin'); + } + + /** + * Sets cross origin. + * + * @param crossOrigin Cross origin. + */ + public set crossOrigin(crossOrigin: string | null) { + if (crossOrigin === 'anonymous' || crossOrigin === 'use-credentials') { + this.setAttribute('crossOrigin', crossOrigin); + } + } /** * Returns alt. @@ -45,6 +132,25 @@ export default class HTMLImageElement extends HTMLElement implements IHTMLImageE return this.src; } + /** + * Returns width. + * + * @returns Width. + */ + public get width(): number { + const width = this.getAttribute('width'); + return width !== null ? Number(width) : 0; + } + + /** + * Sets width. + * + * @param width Width. + */ + public set width(width: number) { + this.setAttribute('width', String(width)); + } + /** * Returns height. * @@ -176,25 +282,6 @@ export default class HTMLImageElement extends HTMLElement implements IHTMLImageE this.setAttribute('usemap', useMap); } - /** - * Returns width. - * - * @returns Width. - */ - public get width(): number { - const width = this.getAttribute('width'); - return width !== null ? Number(width) : 0; - } - - /** - * Sets width. - * - * @param width Width. - */ - public set width(width: number) { - this.setAttribute('width', String(width)); - } - /** * The decode() method of the HTMLImageElement interface returns a Promise that resolves when the image is decoded and it is safe to append the image to the DOM. * diff --git a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts index 893bcf236..9fb7cdb52 100644 --- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts +++ b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import ValidityState from '../../validity-state/ValidityState.js'; import DOMException from '../../exception/DOMException.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; @@ -22,6 +23,7 @@ import HTMLInputElementDateUtility from './HTMLInputElementDateUtility.js'; import HTMLLabelElementUtility from '../html-label-element/HTMLLabelElementUtility.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLInputElementNamedNodeMap from './HTMLInputElementNamedNodeMap.js'; +import { URL } from 'url'; /** * HTML Input Element. @@ -33,38 +35,130 @@ import HTMLInputElementNamedNodeMap from './HTMLInputElementNamedNodeMap.js'; * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/nodes/HTMLInputElement-impl.js (MIT licensed). */ export default class HTMLInputElement extends HTMLElement implements IHTMLInputElement { - public override readonly attributes: INamedNodeMap = new HTMLInputElementNamedNodeMap(this); + // Events + public oninput: (event: Event) => void | null = null; + public oninvalid: (event: Event) => void | null = null; + public onselectionchange: (event: Event) => void | null = null; - // Related to parent form. - public formAction = ''; - public formMethod = ''; + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLInputElementNamedNodeMap( + this + ); + public [PropertySymbol.value] = null; + public [PropertySymbol.height] = 0; + public [PropertySymbol.width] = 0; + public [PropertySymbol.defaultChecked] = false; + public [PropertySymbol.checked]: boolean | null = null; + public [PropertySymbol.validationMessage] = ''; + public [PropertySymbol.validity] = new ValidityState(this); + public [PropertySymbol.files]: IFileList = new FileList(); + + // Private properties + #selectionStart: number = null; + #selectionEnd: number = null; + #selectionDirection: HTMLInputElementSelectionDirectionEnum = + HTMLInputElementSelectionDirectionEnum.none; - // Any type of input - public _value = null; - public _height = 0; - public _width = 0; + /** + * Returns default checked. + * + * @returns Default checked. + */ + public get defaultChecked(): boolean { + return this[PropertySymbol.defaultChecked]; + } - // Type specific: checkbox/radio - public defaultChecked = false; - public _checked: boolean | null = null; + /** + * Sets default checked. + * + * @param defaultChecked Default checked. + */ + public set defaultChecked(defaultChecked: boolean) { + this[PropertySymbol.defaultChecked] = defaultChecked; + } - // Type specific: file - public files: IFileList = new FileList(); + /** + * Returns files. + * + * @returns Files. + */ + public get files(): IFileList { + return this[PropertySymbol.files]; + } - // All fields - public readonly validationMessage = ''; - public readonly validity = new ValidityState(this); + /** + * Sets files. + * + * @param files Files. + */ + public set files(files: IFileList) { + this[PropertySymbol.files] = files; + } - // Events - public oninput: (event: Event) => void | null = null; - public oninvalid: (event: Event) => void | null = null; - public onselectionchange: (event: Event) => void | null = null; + /** + * Returns form action. + * + * @returns URL. + */ + public get formAction(): string { + return ( + this.getAttribute('formaction') || + (this[PropertySymbol.formNode])?.action || + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href + ); + } - // Type specific: text/password/search/tel/url/week/month - private _selectionStart: number = null; - private _selectionEnd: number = null; - private _selectionDirection: HTMLInputElementSelectionDirectionEnum = - HTMLInputElementSelectionDirectionEnum.none; + /** + * Sets form action. + * + * @param url URL. + */ + public set formAction(url: string) { + try { + new URL(url); + } catch (error) { + return; + } + this.setAttribute('formaction', url); + } + + /** + * Returns form method. + */ + public get formMethod(): string { + return ( + this.getAttribute('formmethod') || + (this[PropertySymbol.formNode])?.method || + '' + ); + } + + /** + * Sets form method. + * + * @param method Method. + */ + public set formMethod(method: string) { + this.setAttribute('formmethod', method); + } + + /** + * Returns validation message. + * + * @returns Validation message. + */ + public get validationMessage(): string { + return this[PropertySymbol.validationMessage]; + } + + /** + * Returns validity. + * + * @returns Validity. + */ + public get validity(): ValidityState { + return this[PropertySymbol.validity]; + } /** * Returns height. @@ -72,7 +166,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Height. */ public get height(): number { - return this._height; + return this[PropertySymbol.height]; } /** @@ -81,7 +175,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param height Height. */ public set height(height: number) { - this._height = height; + this[PropertySymbol.height] = height; this.setAttribute('height', String(height)); } @@ -91,7 +185,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Width. */ public get width(): number { - return this._width; + return this[PropertySymbol.width]; } /** @@ -100,7 +194,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param width Width. */ public set width(width: number) { - this._width = width; + this[PropertySymbol.width] = width; this.setAttribute('width', String(width)); } @@ -560,8 +654,8 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Checked. */ public get checked(): boolean { - if (this._checked !== null) { - return this._checked; + if (this[PropertySymbol.checked] !== null) { + return this[PropertySymbol.checked]; } return this.getAttribute('checked') !== null; } @@ -572,7 +666,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param checked Checked. */ public set checked(checked: boolean) { - this._setChecked(checked); + this.#setChecked(checked); } /** @@ -593,14 +687,16 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE const attritube = this.getAttribute('value'); return attritube !== null ? attritube : 'on'; case 'file': - return this.files.length > 0 ? '/fake/path/' + this.files[0].name : ''; + return this[PropertySymbol.files].length > 0 + ? '/fake/path/' + this[PropertySymbol.files][0].name + : ''; } - if (this._value === null) { + if (this[PropertySymbol.value] === null) { return this.getAttribute('value') || ''; } - return this._value; + return this[PropertySymbol.value]; } /** @@ -630,13 +726,13 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE } break; default: - const oldValue = this._value; - this._value = HTMLInputElementValueSanitizer.sanitize(this, value); + const oldValue = this.value; + this[PropertySymbol.value] = HTMLInputElementValueSanitizer.sanitize(this, value); - if (oldValue !== this._value) { - this._selectionStart = this._value.length; - this._selectionEnd = this._value.length; - this._selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + if (oldValue !== this[PropertySymbol.value]) { + this.#selectionStart = this[PropertySymbol.value].length; + this.#selectionEnd = this[PropertySymbol.value].length; + this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; } break; @@ -649,15 +745,15 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Selection start. */ public get selectionStart(): number { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { return null; } - if (this._selectionStart === null) { + if (this.#selectionStart === null) { return this.value.length; } - return this._selectionStart; + return this.#selectionStart; } /** @@ -666,14 +762,14 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param start Start. */ public set selectionStart(start: number) { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { throw new DOMException( `The input element's type (${this.type}) does not support selection.`, DOMExceptionNameEnum.invalidStateError ); } - this.setSelectionRange(start, Math.max(start, this.selectionEnd), this._selectionDirection); + this.setSelectionRange(start, Math.max(start, this.selectionEnd), this.#selectionDirection); } /** @@ -682,15 +778,15 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Selection end. */ public get selectionEnd(): number { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { return null; } - if (this._selectionEnd === null) { + if (this.#selectionEnd === null) { return this.value.length; } - return this._selectionEnd; + return this.#selectionEnd; } /** @@ -699,14 +795,14 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param end End. */ public set selectionEnd(end: number) { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { throw new DOMException( `The input element's type (${this.type}) does not support selection.`, DOMExceptionNameEnum.invalidStateError ); } - this.setSelectionRange(this.selectionStart, end, this._selectionDirection); + this.setSelectionRange(this.selectionStart, end, this.#selectionDirection); } /** @@ -715,11 +811,11 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Selection direction. */ public get selectionDirection(): string { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { return null; } - return this._selectionDirection; + return this.#selectionDirection; } /** @@ -728,14 +824,14 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param direction Direction. */ public set selectionDirection(direction: string) { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { throw new DOMException( `The input element's type (${this.type}) does not support selection.`, DOMExceptionNameEnum.invalidStateError ); } - this.setSelectionRange(this._selectionStart, this._selectionEnd, direction); + this.setSelectionRange(this.#selectionStart, this.#selectionEnd, direction); } /** @@ -766,7 +862,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** @@ -969,20 +1065,20 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param message Message. */ public setCustomValidity(message: string): void { - (this.validationMessage) = String(message); + this[PropertySymbol.validationMessage] = String(message); } /** * Selects the text. */ public select(): void { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { return null; } - this._selectionStart = 0; - this._selectionEnd = this.value.length; - this._selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + this.#selectionStart = 0; + this.#selectionEnd = this.value.length; + this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; this.dispatchEvent(new Event('select', { bubbles: true, cancelable: true })); } @@ -995,16 +1091,16 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * @param [direction="none"] Direction. */ public setSelectionRange(start: number, end: number, direction = 'none'): void { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { throw new DOMException( `The input element's type (${this.type}) does not support selection.`, DOMExceptionNameEnum.invalidStateError ); } - this._selectionEnd = Math.min(end, this.value.length); - this._selectionStart = Math.min(start, this._selectionEnd); - this._selectionDirection = + this.#selectionEnd = Math.min(end, this.value.length); + this.#selectionStart = Math.min(start, this.#selectionEnd); + this.#selectionDirection = direction === HTMLInputElementSelectionDirectionEnum.forward || direction === HTMLInputElementSelectionDirectionEnum.backward ? direction @@ -1027,7 +1123,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE end: number = null, selectionMode = HTMLInputElementSelectionModeEnum.preserve ): void { - if (!this._isSelectionSupported()) { + if (!this.#isSelectionSupported()) { throw new DOMException( `The input element's type (${this.type}) does not support selection.`, DOMExceptionNameEnum.invalidStateError @@ -1035,10 +1131,10 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE } if (start === null) { - start = this._selectionStart; + start = this.#selectionStart; } if (end === null) { - end = this._selectionEnd; + end = this.#selectionEnd; } if (start > end) { @@ -1052,8 +1148,8 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE end = Math.min(end, this.value.length); const val = this.value; - let selectionStart = this._selectionStart; - let selectionEnd = this._selectionEnd; + let selectionStart = this.#selectionStart; + let selectionEnd = this.#selectionEnd; this.value = val.slice(0, start) + replacement + val.slice(end); @@ -1101,7 +1197,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE this.type === 'hidden' || this.type === 'reset' || this.type === 'button' || - this.validity.valid; + this[PropertySymbol.validity].valid; if (!valid) { this.dispatchEvent(new Event('invalid', { bubbles: true, cancelable: true })); } @@ -1152,14 +1248,14 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE const clone = super.cloneNode(deep); clone.formAction = this.formAction; clone.formMethod = this.formMethod; - clone._value = this._value; - clone._height = this._height; - clone._width = this._width; - clone.defaultChecked = this.defaultChecked; - clone.files = this.files.slice(); - clone._selectionStart = this._selectionStart; - clone._selectionEnd = this._selectionEnd; - clone._selectionDirection = this._selectionDirection; + clone[PropertySymbol.value] = this[PropertySymbol.value]; + clone[PropertySymbol.height] = this[PropertySymbol.height]; + clone[PropertySymbol.width] = this[PropertySymbol.width]; + clone[PropertySymbol.defaultChecked] = this[PropertySymbol.defaultChecked]; + clone[PropertySymbol.files] = this[PropertySymbol.files].slice(); + clone.#selectionStart = this.#selectionStart; + clone.#selectionEnd = this.#selectionEnd; + clone.#selectionDirection = this.#selectionDirection; return clone; } @@ -1184,7 +1280,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE const inputType = this.type; if (inputType === 'checkbox' || inputType === 'radio') { previousCheckedValue = this.checked; - this._setChecked(inputType === 'checkbox' ? !previousCheckedValue : true); + this.#setChecked(inputType === 'checkbox' ? !previousCheckedValue : true); } } @@ -1195,7 +1291,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE (event.eventPhase === EventPhaseEnum.atTarget || event.eventPhase === EventPhaseEnum.bubbling) && event.type === 'click' && - this.isConnected + this[PropertySymbol.isConnected] ) { const inputType = this.type; if (!this.readOnly || inputType === 'checkbox' || inputType === 'radio') { @@ -1203,12 +1299,12 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE this.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); this.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); } else if (inputType === 'submit') { - const form = this._formNode; + const form = this[PropertySymbol.formNode]; if (form) { form.requestSubmit(this); } - } else if (inputType === 'reset' && this.isConnected) { - const form = this._formNode; + } else if (inputType === 'reset' && this[PropertySymbol.isConnected]) { + const form = this[PropertySymbol.formNode]; if (form) { form.reset(); } @@ -1226,7 +1322,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE ) { const inputType = this.type; if (inputType === 'checkbox' || inputType === 'radio') { - this._setChecked(previousCheckedValue); + this.#setChecked(previousCheckedValue); } } @@ -1236,19 +1332,25 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldFormNode = this._formNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldFormNode = this[PropertySymbol.formNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldFormNode !== this._formNode) { + if (oldFormNode !== this[PropertySymbol.formNode]) { if (oldFormNode) { - oldFormNode._removeFormControlItem(this, this.name); - oldFormNode._removeFormControlItem(this, this.id); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); } - if (this._formNode) { - (this._formNode)._appendFormControlItem(this, this.name); - (this._formNode)._appendFormControlItem(this, this.id); + if (this[PropertySymbol.formNode]) { + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.name + ); + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.id + ); } } } @@ -1258,7 +1360,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * * @returns "true" if selection is supported. */ - private _isSelectionSupported(): boolean { + #isSelectionSupported(): boolean { const inputType = this.type; return ( inputType === 'text' || @@ -1274,16 +1376,18 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE * * @param checked Checked. */ - private _setChecked(checked: boolean): void { - this._checked = checked; + #setChecked(checked: boolean): void { + this[PropertySymbol.checked] = checked; if (checked && this.type === 'radio' && this.name) { - const root = (this._formNode || this.getRootNode()); + const root = ( + (this[PropertySymbol.formNode] || this.getRootNode()) + ); const radioButtons = root.querySelectorAll(`input[type="radio"][name="${this.name}"]`); for (const radioButton of radioButtons) { if (radioButton !== this) { - radioButton['_checked'] = false; + radioButton[PropertySymbol.checked] = false; } } } diff --git a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts index ef72fd0f2..22507fe0d 100644 --- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLInputElement from './HTMLInputElement.js'; @@ -9,7 +10,7 @@ import HTMLInputElement from './HTMLInputElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLInputElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLInputElement; + protected [PropertySymbol.ownerElement]: HTMLInputElement; /** * @override @@ -17,18 +18,19 @@ export default class HTMLInputElementNamedNodeMap extends HTMLElementNamedNodeMa public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if ((item.name === 'id' || item.name === 'name') && this._ownerElement._formNode) { - if (replacedItem && replacedItem.value) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - replacedItem.value - ); + if ( + (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] + ) { + if (replacedItem && replacedItem[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); } - if (item.value) { - (this._ownerElement._formNode)._appendFormControlItem( - this._ownerElement, - item.value - ); + if (item[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.appendFormControlItem + ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); } } @@ -38,18 +40,17 @@ export default class HTMLInputElementNamedNodeMap extends HTMLElementNamedNodeMa /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); if ( removedItem && - (removedItem.name === 'id' || removedItem.name === 'name') && - this._ownerElement._formNode + (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] ) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - removedItem.value - ); + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts index 7843407c5..1fe4e02d8 100644 --- a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts +++ b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement.js'; import IHTMLLabelElement from './IHTMLLabelElement.js'; @@ -43,7 +44,7 @@ export default class HTMLLabelElement extends HTMLElement implements IHTMLLabelE public get control(): IHTMLElement { const htmlFor = this.htmlFor; if (htmlFor) { - const control = this.ownerDocument.getElementById(htmlFor); + const control = this[PropertySymbol.ownerDocument].getElementById(htmlFor); return control !== this ? control : null; } return ( @@ -57,7 +58,7 @@ export default class HTMLLabelElement extends HTMLElement implements IHTMLLabelE * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** diff --git a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts index df9a27de3..336b0442a 100644 --- a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts +++ b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts @@ -4,6 +4,7 @@ import IHTMLLabelElement from './IHTMLLabelElement.js'; import INodeList from '../node/INodeList.js'; import NodeList from '../node/NodeList.js'; import IShadowRoot from '../shadow-root/IShadowRoot.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * Utility for finding labels associated with a form element. @@ -25,13 +26,13 @@ export default class HTMLLabelElementUtility { labels = new NodeList(); } - let parent = element.parentNode; + let parent = element[PropertySymbol.parentNode]; while (parent) { if (parent['tagName'] === 'LABEL') { labels.push(parent); break; } - parent = parent.parentNode; + parent = parent[PropertySymbol.parentNode]; } return labels; diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts index 04d732761..069aec408 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -1,4 +1,5 @@ import CSSStyleSheet from '../../css/CSSStyleSheet.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLLinkElement from './IHTMLLinkElement.js'; import Event from '../../event/Event.js'; @@ -6,9 +7,10 @@ import ErrorEvent from '../../event/events/ErrorEvent.js'; import INode from '../../nodes/node/INode.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; import IDOMTokenList from '../../dom-token-list/IDOMTokenList.js'; -import HTMLLinkElementUtility from './HTMLLinkElementUtility.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLLinkElementNamedNodeMap from './HTMLLinkElementNamedNodeMap.js'; +import HTMLLinkElementStyleSheetLoader from './HTMLLinkElementStyleSheetLoader.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; /** * HTML Link Element. @@ -17,12 +19,39 @@ import HTMLLinkElementNamedNodeMap from './HTMLLinkElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement. */ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkElement { - public override readonly attributes: INamedNodeMap = new HTMLLinkElementNamedNodeMap(this); + // Events public onerror: (event: ErrorEvent) => void = null; public onload: (event: Event) => void = null; - public readonly sheet: CSSStyleSheet = null; - public _evaluateCSS = true; - public _relList: DOMTokenList = null; + + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap; + public readonly [PropertySymbol.sheet]: CSSStyleSheet = null; + public [PropertySymbol.evaluateCSS] = true; + public [PropertySymbol.relList]: DOMTokenList = null; + #styleSheetLoader: HTMLLinkElementStyleSheetLoader; + + /** + * Constructor. + * + * @param browserFrame Browser frame. + */ + constructor(browserFrame: IBrowserFrame) { + super(); + + this.#styleSheetLoader = new HTMLLinkElementStyleSheetLoader({ + element: this, + browserFrame + }); + + this[PropertySymbol.attributes] = new HTMLLinkElementNamedNodeMap(this, this.#styleSheetLoader); + } + + /** + * Returns sheet. + */ + public get sheet(): CSSStyleSheet { + return this[PropertySymbol.sheet]; + } /** * Returns rel list. @@ -30,10 +59,10 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle * @returns Rel list. */ public get relList(): IDOMTokenList { - if (!this._relList) { - this._relList = new DOMTokenList(this, 'rel'); + if (!this[PropertySymbol.relList]) { + this[PropertySymbol.relList] = new DOMTokenList(this, 'rel'); } - return this._relList; + return this[PropertySymbol.relList]; } /** @@ -183,14 +212,18 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const isConnected = this.isConnected; - const isParentConnected = parentNode ? parentNode.isConnected : false; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const isConnected = this[PropertySymbol.isConnected]; + const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (isParentConnected && isConnected !== isParentConnected && this._evaluateCSS) { - HTMLLinkElementUtility.loadExternalStylesheet(this); + if ( + isParentConnected && + isConnected !== isParentConnected && + this[PropertySymbol.evaluateCSS] + ) { + this.#styleSheetLoader.loadStyleSheet(this.getAttribute('href'), this.getAttribute('rel')); } } } diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts index e14435c0b..5a368fb1c 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts @@ -1,7 +1,8 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLLinkElement from './HTMLLinkElement.js'; -import HTMLLinkElementUtility from './HTMLLinkElementUtility.js'; +import HTMLLinkElementStyleSheetLoader from './HTMLLinkElementStyleSheetLoader.js'; /** * Named Node Map. @@ -9,7 +10,20 @@ import HTMLLinkElementUtility from './HTMLLinkElementUtility.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLLinkElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLLinkElement; + protected [PropertySymbol.ownerElement]: HTMLLinkElement; + #styleSheetLoader: HTMLLinkElementStyleSheetLoader; + + /** + * Constructor. + * + * @param ownerElement Owner element. + * @param stylesheetLoader Stylesheet loader. + * @param styleSheetLoader + */ + constructor(ownerElement: HTMLLinkElement, styleSheetLoader: HTMLLinkElementStyleSheetLoader) { + super(ownerElement); + this.#styleSheetLoader = styleSheetLoader; + } /** * @override @@ -17,12 +31,23 @@ export default class HTMLLinkElementNamedNodeMap extends HTMLElementNamedNodeMap public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if (item.name === 'rel' && this._ownerElement._relList) { - this._ownerElement._relList._updateIndices(); + if ( + item[PropertySymbol.name] === 'rel' && + this[PropertySymbol.ownerElement][PropertySymbol.relList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); } - if (item.name === 'rel' || item.name === 'href') { - HTMLLinkElementUtility.loadExternalStylesheet(this._ownerElement); + if (item[PropertySymbol.name] === 'rel') { + this.#styleSheetLoader.loadStyleSheet( + this[PropertySymbol.ownerElement].getAttribute('href'), + item[PropertySymbol.value] + ); + } else if (item[PropertySymbol.name] === 'href') { + this.#styleSheetLoader.loadStyleSheet( + item[PropertySymbol.value], + this[PropertySymbol.ownerElement].getAttribute('rel') + ); } return replacedItem || null; @@ -31,11 +56,15 @@ export default class HTMLLinkElementNamedNodeMap extends HTMLElementNamedNodeMap /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); - if (removedItem && removedItem.name === 'rel' && this._ownerElement._relList) { - this._ownerElement._relList._updateIndices(); + if ( + removedItem && + removedItem[PropertySymbol.name] === 'rel' && + this[PropertySymbol.ownerElement][PropertySymbol.relList] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts new file mode 100644 index 000000000..427c17ba2 --- /dev/null +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts @@ -0,0 +1,110 @@ +import Event from '../../event/Event.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import ResourceFetch from '../../fetch/ResourceFetch.js'; +import CSSStyleSheet from '../../css/CSSStyleSheet.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; +import IHTMLLinkElement from './IHTMLLinkElement.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; + +/** + * Helper class for getting the URL relative to a Location object. + */ +export default class HTMLLinkElementStyleSheetLoader { + #element: IHTMLLinkElement; + #browserFrame: IBrowserFrame; + #loadedStyleSheetURL: string | null = null; + + /** + * Constructor. + * + * @param options Options. + * @param options.element Element. + * @param options.browserFrame Browser frame. + */ + constructor(options: { element: IHTMLLinkElement; browserFrame: IBrowserFrame }) { + this.#element = options.element; + this.#browserFrame = options.browserFrame; + } + + /** + * Returns a URL relative to the given Location object. + * + * @param url URL. + * @param rel Rel. + */ + public async loadStyleSheet(url: string | null, rel: string | null): Promise { + const element = this.#element; + const browserSettings = this.#browserFrame.page.context.browser.settings; + + if ( + !url || + !rel || + rel.toLowerCase() !== 'stylesheet' || + !element[PropertySymbol.isConnected] + ) { + return; + } + + let absoluteURL: string; + try { + absoluteURL = new URL( + url, + element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location + ).href; + } catch (error) { + this.#loadedStyleSheetURL = null; + element.dispatchEvent(new Event('error')); + return; + } + + if (this.#loadedStyleSheetURL === absoluteURL) { + return; + } + + if (browserSettings.disableCSSFileLoading) { + WindowErrorUtility.dispatchError( + element, + new DOMException( + `Failed to load external stylesheet "${absoluteURL}". CSS file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + return; + } + + const resourceFetch = new ResourceFetch({ + browserFrame: this.#browserFrame, + window: element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + }); + const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( + (element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]) + ))[PropertySymbol.readyStateManager]; + + this.#loadedStyleSheetURL = absoluteURL; + + readyStateManager.startTask(); + + let code: string | null = null; + let error: Error | null = null; + + try { + code = await resourceFetch.fetch(absoluteURL); + } catch (e) { + error = e; + } + + readyStateManager.endTask(); + + if (error) { + WindowErrorUtility.dispatchError(element, error); + } else { + const styleSheet = new CSSStyleSheet(); + styleSheet.replaceSync(code); + element[PropertySymbol.sheet] = styleSheet; + element.dispatchEvent(new Event('load')); + } + } +} diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementUtility.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementUtility.ts deleted file mode 100644 index 687635ab7..000000000 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementUtility.ts +++ /dev/null @@ -1,64 +0,0 @@ -import Document from '../document/Document.js'; -import Event from '../../event/Event.js'; -import ResourceFetch from '../../fetch/ResourceFetch.js'; -import HTMLLinkElement from './HTMLLinkElement.js'; -import CSSStyleSheet from '../../css/CSSStyleSheet.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; - -/** - * Helper class for getting the URL relative to a Location object. - */ -export default class HTMLLinkElementUtility { - /** - * Returns a URL relative to the given Location object. - * - * @param options Options. - * @param options.element Element. - * @param element - */ - public static async loadExternalStylesheet(element: HTMLLinkElement): Promise { - const href = element.getAttribute('href'); - const rel = element.getAttribute('rel'); - - if (href !== null && rel && rel.toLowerCase() === 'stylesheet' && element.isConnected) { - if (element.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading) { - const error = new DOMException( - `Failed to load external stylesheet "${href}". CSS file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ); - WindowErrorUtility.dispatchError(element, error); - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - throw error; - } - return; - } - - (element.ownerDocument)._readyStateManager.startTask(); - - let code: string | null = null; - let error: Error | null = null; - - try { - code = await ResourceFetch.fetch(element.ownerDocument, href); - } catch (e) { - error = e; - } - - (element.ownerDocument)._readyStateManager.endTask(); - - if (error) { - WindowErrorUtility.dispatchError(element, error); - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - throw error; - } - } else { - const styleSheet = new CSSStyleSheet(); - styleSheet.replaceSync(code); - (element.sheet) = styleSheet; - element.dispatchEvent(new Event('load')); - } - } - } -} diff --git a/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts b/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts index 7d248b8b0..2775abe06 100644 --- a/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts +++ b/packages/happy-dom/src/nodes/html-media-element/HTMLMediaElement.ts @@ -4,6 +4,7 @@ import DOMException from '../../exception/DOMException.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLMediaElement, { IMediaError } from './IHTMLMediaElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * @@ -30,19 +31,6 @@ function getTimeRangeDummy(): object { * */ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaElement { - // Public Properties - public readonly buffered = getTimeRangeDummy(); - public readonly duration = NaN; - public readonly error: IMediaError = null; - public readonly ended = false; - public readonly networkState = 0; - public readonly readyState = 0; - public readonly textTracks = []; - public readonly videoTracks = []; - public readonly seeking = false; - public readonly seekable = getTimeRangeDummy(); - public readonly played = getTimeRangeDummy(); - // Events public onabort: (event: Event) => void | null = null; public oncanplay: (event: Event) => void | null = null; @@ -68,14 +56,126 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE public onvolumechange: (event: Event) => void | null = null; public onwaiting: (event: Event) => void | null = null; - #volume = 1; - #paused = true; - #currentTime = 0; - #playbackRate = 1; - #defaultPlaybackRate = 1; - #muted = false; - #defaultMuted = false; - #preservesPitch = true; + // Internal Properties + public [PropertySymbol.volume] = 1; + public [PropertySymbol.paused] = true; + public [PropertySymbol.currentTime] = 0; + public [PropertySymbol.playbackRate] = 1; + public [PropertySymbol.defaultPlaybackRate] = 1; + public [PropertySymbol.muted] = false; + public [PropertySymbol.defaultMuted] = false; + public [PropertySymbol.preservesPitch] = true; + public [PropertySymbol.buffered]: object = getTimeRangeDummy(); + public [PropertySymbol.duration] = NaN; + public [PropertySymbol.error]: IMediaError = null; + public [PropertySymbol.ended] = false; + public [PropertySymbol.networkState] = 0; + public [PropertySymbol.readyState] = 0; + public [PropertySymbol.textTracks]: object[] = []; + public [PropertySymbol.videoTracks]: object[] = []; + public [PropertySymbol.seeking] = false; + public [PropertySymbol.seekable] = getTimeRangeDummy(); + public [PropertySymbol.played] = getTimeRangeDummy(); + + /** + * Returns buffered. + * + * @returns Buffered. + */ + public get buffered(): object { + return this[PropertySymbol.buffered]; + } + + /** + * Returns duration. + * + * @returns Duration. + */ + public get duration(): number { + return this[PropertySymbol.duration]; + } + + /** + * Returns error. + * + * @returns Error. + */ + public get error(): IMediaError { + return this[PropertySymbol.error]; + } + + /** + * Returns ended. + * + * @returns Ended. + */ + public get ended(): boolean { + return this[PropertySymbol.ended]; + } + + /** + * Returns networkState. + * + * @returns NetworkState. + */ + public get networkState(): number { + return this[PropertySymbol.networkState]; + } + + /** + * Returns readyState. + * + * @returns ReadyState. + */ + public get readyState(): number { + return this[PropertySymbol.readyState]; + } + + /** + * Returns textTracks. + * + * @returns TextTracks. + */ + public get textTracks(): object[] { + return this[PropertySymbol.textTracks]; + } + + /** + * Returns videoTracks. + * + * @returns VideoTracks. + */ + public get videoTracks(): object[] { + return this[PropertySymbol.videoTracks]; + } + + /** + * Returns seeking. + * + * @returns Seeking. + */ + public get seeking(): boolean { + return this[PropertySymbol.seeking]; + } + + /** + * Returns seekable. + * + * @returns Seekable. + */ + public get seekable(): object { + return this[PropertySymbol.seekable]; + } + + /** + * Returns played. + * + * @returns Played. + */ + public get played(): object { + return this[PropertySymbol.played]; + } + /** * Returns autoplay. * @@ -147,11 +247,11 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns Muted. */ public get muted(): boolean { - if (this.#muted) { - return this.#muted; + if (this[PropertySymbol.muted]) { + return this[PropertySymbol.muted]; } - if (!this.#defaultMuted) { + if (!this[PropertySymbol.defaultMuted]) { return this.getAttribute('muted') !== null; } @@ -164,8 +264,8 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @param muted Muted. */ public set muted(muted: boolean) { - this.#muted = !!muted; - if (!muted && !this.#defaultMuted) { + this[PropertySymbol.muted] = !!muted; + if (!muted && !this[PropertySymbol.defaultMuted]) { this.removeAttribute('muted'); } else { this.setAttribute('muted', ''); @@ -178,7 +278,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns DefaultMuted. */ public get defaultMuted(): boolean { - return this.#defaultMuted; + return this[PropertySymbol.defaultMuted]; } /** @@ -187,8 +287,8 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @param defaultMuted DefaultMuted. */ public set defaultMuted(defaultMuted: boolean) { - this.#defaultMuted = !!defaultMuted; - if (!this.#defaultMuted && !this.#muted) { + this[PropertySymbol.defaultMuted] = !!defaultMuted; + if (!this[PropertySymbol.defaultMuted] && !this[PropertySymbol.muted]) { this.removeAttribute('muted'); } else { this.setAttribute('muted', ''); @@ -232,7 +332,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns Volume. */ public get volume(): number { - return this.#volume; + return this[PropertySymbol.volume]; } /** @@ -255,7 +355,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE ); } // TODO: volumechange event https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volumechange_event - this.#volume = parsedVolume; + this[PropertySymbol.volume] = parsedVolume; } /** @@ -290,7 +390,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns CurrentTime. */ public get currentTime(): number { - return this.#currentTime; + return this[PropertySymbol.currentTime]; } /** @@ -305,7 +405,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE `Failed to set the 'currentTime' property on 'HTMLMediaElement': The provided double value is non-finite.` ); } - this.#currentTime = parsedCurrentTime; + this[PropertySymbol.currentTime] = parsedCurrentTime; } /** @@ -314,7 +414,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns PlaybackRate. */ public get playbackRate(): number { - return this.#playbackRate; + return this[PropertySymbol.playbackRate]; } /** @@ -329,7 +429,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE `Failed to set the 'playbackRate' property on 'HTMLMediaElement': The provided double value is non-finite.` ); } - this.#playbackRate = parsedPlaybackRate; + this[PropertySymbol.playbackRate] = parsedPlaybackRate; } /** @@ -338,7 +438,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns DefaultPlaybackRate. */ public get defaultPlaybackRate(): number { - return this.#defaultPlaybackRate; + return this[PropertySymbol.defaultPlaybackRate]; } /** @@ -353,7 +453,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE `Failed to set the 'defaultPlaybackRate' property on 'HTMLMediaElement': The provided double value is non-finite.` ); } - this.#defaultPlaybackRate = parsedDefaultPlaybackRate; + this[PropertySymbol.defaultPlaybackRate] = parsedDefaultPlaybackRate; } /** @@ -362,7 +462,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns PlaybackRate. */ public get preservesPitch(): boolean { - return this.#preservesPitch; + return this[PropertySymbol.preservesPitch]; } /** @@ -371,7 +471,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @param preservesPitch PreservesPitch. */ public set preservesPitch(preservesPitch: boolean) { - this.#preservesPitch = Boolean(preservesPitch); + this[PropertySymbol.preservesPitch] = Boolean(preservesPitch); } /** @@ -398,14 +498,14 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * @returns Paused. */ public get paused(): boolean { - return this.#paused; + return this[PropertySymbol.paused]; } /** * Pause played media. */ public pause(): void { - this.#paused = true; + this[PropertySymbol.paused] = true; this.dispatchEvent(new Event('pause', { bubbles: false, cancelable: false })); } @@ -413,7 +513,7 @@ export default class HTMLMediaElement extends HTMLElement implements IHTMLMediaE * Start playing media. */ public async play(): Promise { - this.#paused = false; + this[PropertySymbol.paused] = false; return Promise.resolve(); } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts index f676de1aa..7c11b02f3 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts @@ -1,7 +1,9 @@ import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement.js'; import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; +import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement.js'; import INode from '../node/INode.js'; import HTMLOptionElementNamedNodeMap from './HTMLOptionElementNamedNodeMap.js'; import IHTMLOptionElement from './IHTMLOptionElement.js'; @@ -13,10 +15,11 @@ import IHTMLOptionElement from './IHTMLOptionElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement. */ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptionElement { - public override readonly attributes: INamedNodeMap = new HTMLOptionElementNamedNodeMap(this); - public _index: number; - public _selectedness = false; - public _dirtyness = false; + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLOptionElementNamedNodeMap( + this + ); + public [PropertySymbol.selectedness] = false; + public [PropertySymbol.dirtyness] = false; /** * Returns inner text, which is the rendered appearance of text. @@ -42,7 +45,9 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Index. */ public get index(): number { - return this._index; + return this[PropertySymbol.selectNode] + ? (this[PropertySymbol.selectNode]).options.indexOf(this) + : 0; } /** @@ -51,7 +56,7 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** @@ -60,7 +65,7 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Selected. */ public get selected(): boolean { - return this._selectedness; + return this[PropertySymbol.selectedness]; } /** @@ -69,13 +74,13 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @param selected Selected. */ public set selected(selected: boolean) { - const selectNode = this._selectNode; + const selectNode = this[PropertySymbol.selectNode]; - this._dirtyness = true; - this._selectedness = Boolean(selected); + this[PropertySymbol.dirtyness] = true; + this[PropertySymbol.selectedness] = Boolean(selected); if (selectNode) { - selectNode._updateOptionItems(this._selectedness ? this : null); + selectNode[PropertySymbol.updateOptionItems](this[PropertySymbol.selectedness] ? this : null); } } @@ -122,17 +127,17 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldSelectNode = this._selectNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldSelectNode = this[PropertySymbol.selectNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldSelectNode !== this._selectNode) { + if (oldSelectNode !== this[PropertySymbol.selectNode]) { if (oldSelectNode) { - oldSelectNode._updateOptionItems(); + oldSelectNode[PropertySymbol.updateOptionItems](); } - if (this._selectNode) { - (this._selectNode)._updateOptionItems(); + if (this[PropertySymbol.selectNode]) { + (this[PropertySymbol.selectNode])[PropertySymbol.updateOptionItems](); } } } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts index e9cdbc6ba..fb99cff3e 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; import HTMLOptionElement from './HTMLOptionElement.js'; @@ -9,7 +10,7 @@ import HTMLOptionElement from './HTMLOptionElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLOptionElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLOptionElement; + protected [PropertySymbol.ownerElement]: HTMLOptionElement; /** * @override @@ -18,16 +19,18 @@ export default class HTMLOptionElementNamedNodeMap extends HTMLElementNamedNodeM const replacedItem = super.setNamedItem(item); if ( - !this._ownerElement._dirtyness && - item.name === 'selected' && - replacedItem?.value !== item.value + !this[PropertySymbol.ownerElement][PropertySymbol.dirtyness] && + item[PropertySymbol.name] === 'selected' && + replacedItem?.[PropertySymbol.value] !== item[PropertySymbol.value] ) { - const selectNode = this._ownerElement._selectNode; + const selectNode = ( + this[PropertySymbol.ownerElement][PropertySymbol.selectNode] + ); - this._ownerElement._selectedness = true; + this[PropertySymbol.ownerElement][PropertySymbol.selectedness] = true; if (selectNode) { - selectNode._updateOptionItems(this._ownerElement); + selectNode[PropertySymbol.updateOptionItems](this[PropertySymbol.ownerElement]); } } @@ -37,16 +40,22 @@ export default class HTMLOptionElementNamedNodeMap extends HTMLElementNamedNodeM /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); - if (removedItem && !this._ownerElement._dirtyness && removedItem.name === 'selected') { - const selectNode = this._ownerElement._selectNode; + if ( + removedItem && + !this[PropertySymbol.ownerElement][PropertySymbol.dirtyness] && + removedItem[PropertySymbol.name] === 'selected' + ) { + const selectNode = ( + this[PropertySymbol.ownerElement][PropertySymbol.selectNode] + ); - this._ownerElement._selectedness = false; + this[PropertySymbol.ownerElement][PropertySymbol.selectedness] = false; if (selectNode) { - selectNode._updateOptionItems(); + selectNode[PropertySymbol.updateOptionItems](); } } diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts index 624fdc772..20496e98a 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -1,12 +1,16 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLScriptElement from './IHTMLScriptElement.js'; -import HTMLScriptElementUtility from './HTMLScriptElementUtility.js'; import Event from '../../event/Event.js'; import ErrorEvent from '../../event/events/ErrorEvent.js'; import INode from '../../nodes/node/INode.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLScriptElementNamedNodeMap from './HTMLScriptElementNamedNodeMap.js'; import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import WindowBrowserSettingsReader from '../../window/WindowBrowserSettingsReader.js'; +import HTMLScriptElementScriptLoader from './HTMLScriptElementScriptLoader.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; /** * HTML Script Element. @@ -15,10 +19,32 @@ import WindowErrorUtility from '../../window/WindowErrorUtility.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement. */ export default class HTMLScriptElement extends HTMLElement implements IHTMLScriptElement { - public override readonly attributes: INamedNodeMap = new HTMLScriptElementNamedNodeMap(this); + // Events public onerror: (event: ErrorEvent) => void = null; public onload: (event: Event) => void = null; - public _evaluateScript = true; + + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap; + public [PropertySymbol.evaluateScript] = true; + + // Private properties + #scriptLoader: HTMLScriptElementScriptLoader; + + /** + * Constructor. + * + * @param browserFrame Browser frame. + */ + constructor(browserFrame: IBrowserFrame) { + super(); + + this.#scriptLoader = new HTMLScriptElementScriptLoader({ + element: this, + browserFrame + }); + + this[PropertySymbol.attributes] = new HTMLScriptElementNamedNodeMap(this, this.#scriptLoader); + } /** * Returns type. @@ -168,18 +194,25 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const isConnected = this.isConnected; - const isParentConnected = parentNode ? parentNode.isConnected : false; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const isConnected = this[PropertySymbol.isConnected]; + const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; + const browserSettings = WindowBrowserSettingsReader.getSettings( + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + ); - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (isParentConnected && isConnected !== isParentConnected && this._evaluateScript) { + if ( + isParentConnected && + isConnected !== isParentConnected && + this[PropertySymbol.evaluateScript] + ) { const src = this.getAttribute('src'); if (src !== null) { - HTMLScriptElementUtility.loadExternalScript(this); - } else if (!this.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation) { + this.#scriptLoader.loadScript(src); + } else if (!browserSettings.disableJavaScriptEvaluation) { const textContent = this.textContent; const type = this.getAttribute('type'); if ( @@ -189,17 +222,26 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip type === 'application/x-javascript' || type.startsWith('text/javascript')) ) { - this.ownerDocument['_currentScript'] = this; - - if (this.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - this.ownerDocument.defaultView.eval(textContent); + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; + + const code = + `//# sourceURL=${ + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href + }\n` + textContent; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code); } else { - WindowErrorUtility.captureError(this.ownerDocument.defaultView, () => - this.ownerDocument.defaultView.eval(textContent) + WindowErrorUtility.captureError( + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], + () => this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code) ); } - this.ownerDocument['_currentScript'] = null; + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; } } } diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts index 69be7f9f7..391131687 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts @@ -1,7 +1,8 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLScriptElement from './HTMLScriptElement.js'; -import HTMLScriptElementUtility from './HTMLScriptElementUtility.js'; +import HTMLScriptElementScriptLoader from './HTMLScriptElementScriptLoader.js'; /** * Named Node Map. @@ -9,7 +10,19 @@ import HTMLScriptElementUtility from './HTMLScriptElementUtility.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLScriptElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLScriptElement; + protected [PropertySymbol.ownerElement]: HTMLScriptElement; + #scriptLoader: HTMLScriptElementScriptLoader; + + /** + * Constructor. + * + * @param ownerElement Owner element. + * @param scriptLoader Script loader. + */ + constructor(ownerElement: HTMLScriptElement, scriptLoader: HTMLScriptElementScriptLoader) { + super(ownerElement); + this.#scriptLoader = scriptLoader; + } /** * @override @@ -17,8 +30,12 @@ export default class HTMLScriptElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if (item.name === 'src' && item.value !== null && this._ownerElement.isConnected) { - HTMLScriptElementUtility.loadExternalScript(this._ownerElement); + if ( + item[PropertySymbol.name] === 'src' && + item[PropertySymbol.value] !== null && + this[PropertySymbol.ownerElement][PropertySymbol.isConnected] + ) { + this.#scriptLoader.loadScript(item[PropertySymbol.value]); } return replacedItem || null; diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts new file mode 100644 index 000000000..86d8b3137 --- /dev/null +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts @@ -0,0 +1,130 @@ +import Event from '../../event/Event.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import ResourceFetch from '../../fetch/ResourceFetch.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; +import IHTMLScriptElement from './IHTMLScriptElement.js'; +import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; + +/** + * Helper class for getting the URL relative to a Location object. + */ +export default class HTMLScriptElementScriptLoader { + #element: IHTMLScriptElement; + #browserFrame: IBrowserFrame; + #loadedScriptURL: string | null = null; + + /** + * Constructor. + * + * @param options Options. + * @param options.element Element. + * @param options.browserFrame Browser frame. + */ + constructor(options: { element: IHTMLScriptElement; browserFrame: IBrowserFrame }) { + this.#element = options.element; + this.#browserFrame = options.browserFrame; + } + + /** + * Returns a URL relative to the given Location object. + * + * @param url URL. + */ + public async loadScript(url: string): Promise { + const browserSettings = this.#browserFrame.page.context.browser.settings; + const element = this.#element; + const async = element.getAttribute('async') !== null; + + if (!url || !element[PropertySymbol.isConnected]) { + return; + } + + let absoluteURL: string; + try { + absoluteURL = new URL( + url, + element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location + ).href; + } catch (error) { + this.#loadedScriptURL = null; + element.dispatchEvent(new Event('error')); + return; + } + + if (this.#loadedScriptURL === absoluteURL) { + return; + } + + if ( + browserSettings.disableJavaScriptFileLoading || + browserSettings.disableJavaScriptEvaluation + ) { + WindowErrorUtility.dispatchError( + element, + new DOMException( + `Failed to load external script "${absoluteURL}". JavaScript file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + return; + } + + const resourceFetch = new ResourceFetch({ + browserFrame: this.#browserFrame, + window: element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + }); + let code: string | null = null; + let error: Error | null = null; + + this.#loadedScriptURL = absoluteURL; + + if (async) { + const readyStateManager = (< + { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } + >(element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]))[ + PropertySymbol.readyStateManager + ]; + + readyStateManager.startTask(); + + try { + code = await resourceFetch.fetch(absoluteURL); + } catch (e) { + error = e; + } + + readyStateManager.endTask(); + } else { + try { + code = resourceFetch.fetchSync(absoluteURL); + } catch (e) { + error = e; + } + } + + if (error) { + WindowErrorUtility.dispatchError(element, error); + } else { + element[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = element; + code = '//# sourceURL=' + absoluteURL + '\n' + code; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code); + } else { + WindowErrorUtility.captureError( + element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], + () => element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code) + ); + } + element[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; + element.dispatchEvent(new Event('load')); + } + } +} diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementUtility.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementUtility.ts deleted file mode 100644 index 93ea3d3d9..000000000 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementUtility.ts +++ /dev/null @@ -1,99 +0,0 @@ -import Document from '../document/Document.js'; -import Event from '../../event/Event.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import ResourceFetch from '../../fetch/ResourceFetch.js'; -import HTMLScriptElement from './HTMLScriptElement.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; - -/** - * Helper class for getting the URL relative to a Location object. - */ -export default class HTMLScriptElementUtility { - /** - * Returns a URL relative to the given Location object. - * - * @param options Options. - * @param options.element Element. - * @param element - */ - public static async loadExternalScript(element: HTMLScriptElement): Promise { - const src = element.getAttribute('src'); - const async = element.getAttribute('async') !== null; - - if ( - element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptFileLoading || - element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation - ) { - const error = new DOMException( - `Failed to load external script "${src}". JavaScript file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ); - WindowErrorUtility.dispatchError(element, error); - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - throw error; - } - return; - } - - if (async) { - (element.ownerDocument)._readyStateManager.startTask(); - - let code: string | null = null; - let error: Error | null = null; - - try { - code = await ResourceFetch.fetch(element.ownerDocument, src); - } catch (e) { - error = e; - } - - (element.ownerDocument)._readyStateManager.endTask(); - - if (error) { - WindowErrorUtility.dispatchError(element, error); - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - throw error; - } - } else { - element.ownerDocument['_currentScript'] = element; - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - element.ownerDocument.defaultView.eval(code); - } else { - WindowErrorUtility.captureError(element.ownerDocument.defaultView, () => - element.ownerDocument.defaultView.eval(code) - ); - } - element.ownerDocument['_currentScript'] = null; - element.dispatchEvent(new Event('load')); - } - } else { - let code: string | null = null; - let error: Error | null = null; - - try { - code = ResourceFetch.fetchSync(element.ownerDocument, src); - } catch (e) { - error = e; - } - - if (error) { - WindowErrorUtility.dispatchError(element, error); - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - throw error; - } - } else { - element.ownerDocument['_currentScript'] = element; - if (element.ownerDocument.defaultView.happyDOM.settings.disableErrorCapturing) { - element.ownerDocument.defaultView.eval(code); - } else { - WindowErrorUtility.captureError(element.ownerDocument.defaultView, () => - element.ownerDocument.defaultView.eval(code) - ); - } - element.ownerDocument['_currentScript'] = null; - element.dispatchEvent(new Event('load')); - } - } - } -} diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts index 79c07daa0..de78bf375 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts @@ -14,7 +14,7 @@ export default class HTMLOptionsCollection extends HTMLCollection implements IHTMLOptionsCollection { - private _selectElement: IHTMLSelectElement; + #selectElement: IHTMLSelectElement; /** * @@ -23,7 +23,7 @@ export default class HTMLOptionsCollection constructor(selectElement: IHTMLSelectElement) { super(); - this._selectElement = selectElement; + this.#selectElement = selectElement; } /** @@ -32,7 +32,7 @@ export default class HTMLOptionsCollection * @returns SelectedIndex. */ public get selectedIndex(): number { - return this._selectElement.selectedIndex; + return this.#selectElement.selectedIndex; } /** @@ -41,7 +41,7 @@ export default class HTMLOptionsCollection * @param selectedIndex SelectedIndex. */ public set selectedIndex(selectedIndex: number) { - this._selectElement.selectedIndex = selectedIndex; + this.#selectElement.selectedIndex = selectedIndex; } /** @@ -60,7 +60,7 @@ export default class HTMLOptionsCollection */ public add(element: IHTMLOptionElement, before?: number | IHTMLOptionElement): void { if (!before && before !== 0) { - this._selectElement.appendChild(element); + this.#selectElement.appendChild(element); return; } @@ -69,7 +69,7 @@ export default class HTMLOptionsCollection return; } - this._selectElement.insertBefore(element, this[before]); + this.#selectElement.insertBefore(element, this[before]); return; } @@ -81,7 +81,7 @@ export default class HTMLOptionsCollection ); } - this._selectElement.insertBefore(element, this[index]); + this.#selectElement.insertBefore(element, this[index]); } /** @@ -91,7 +91,7 @@ export default class HTMLOptionsCollection */ public remove(index: number): void { if (this[index]) { - this._selectElement.removeChild(this[index]); + this.#selectElement.removeChild(this[index]); } } } diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts index f7edfae77..89ba3d452 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement.js'; import ValidityState from '../../validity-state/ValidityState.js'; @@ -25,21 +26,56 @@ import HTMLSelectElementNamedNodeMap from './HTMLSelectElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement. */ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelectElement { - public override readonly attributes: INamedNodeMap = new HTMLSelectElementNamedNodeMap(this); - - // Public properties. - public readonly length = 0; - public readonly options: IHTMLOptionsCollection = new HTMLOptionsCollection(this); - public readonly validationMessage = ''; - public readonly validity = new ValidityState(this); - - // Private properties - public _selectNode: INode = this; + // Internal properties. + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLSelectElementNamedNodeMap( + this + ); + public [PropertySymbol.validationMessage] = ''; + public [PropertySymbol.validity] = new ValidityState(this); + public [PropertySymbol.selectNode]: INode = this; + public [PropertySymbol.length] = 0; + public [PropertySymbol.options]: IHTMLOptionsCollection = new HTMLOptionsCollection(this); // Events public onchange: (event: Event) => void | null = null; public oninput: (event: Event) => void | null = null; + /** + * Returns length. + * + * @returns Length. + */ + public get length(): number { + return this[PropertySymbol.length]; + } + + /** + * Returns options. + * + * @returns Options. + */ + public get options(): IHTMLOptionsCollection { + return this[PropertySymbol.options]; + } + + /** + * Returns validation message. + * + * @returns Validation message. + */ + public get validationMessage(): string { + return this[PropertySymbol.validationMessage]; + } + + /** + * Returns validity. + * + * @returns Validity. + */ + public get validity(): ValidityState { + return this[PropertySymbol.validity]; + } + /** * Returns name. * @@ -161,9 +197,9 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get value(): string { - for (let i = 0, max = this.options.length; i < max; i++) { - const option = this.options[i]; - if (option._selectedness) { + for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { + const option = this[PropertySymbol.options][i]; + if (option[PropertySymbol.selectedness]) { return option.value; } } @@ -177,13 +213,13 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param value Value. */ public set value(value: string) { - for (let i = 0, max = this.options.length; i < max; i++) { - const option = this.options[i]; + for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { + const option = this[PropertySymbol.options][i]; if (option.value === value) { - option._selectedness = true; - option._dirtyness = true; + option[PropertySymbol.selectedness] = true; + option[PropertySymbol.dirtyness] = true; } else { - option._selectedness = false; + option[PropertySymbol.selectedness] = false; } } } @@ -194,8 +230,8 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get selectedIndex(): number { - for (let i = 0, max = this.options.length; i < max; i++) { - if ((this.options[i])._selectedness) { + for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { + if ((this[PropertySymbol.options][i])[PropertySymbol.selectedness]) { return i; } } @@ -209,14 +245,14 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec */ public set selectedIndex(selectedIndex: number) { if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { - for (let i = 0, max = this.options.length; i < max; i++) { - (this.options[i])._selectedness = false; + for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { + (this[PropertySymbol.options][i])[PropertySymbol.selectedness] = false; } - const selectedOption = this.options[selectedIndex]; + const selectedOption = this[PropertySymbol.options][selectedIndex]; if (selectedOption) { - selectedOption._selectedness = true; - selectedOption._dirtyness = true; + selectedOption[PropertySymbol.selectedness] = true; + selectedOption[PropertySymbol.dirtyness] = true; } } } @@ -236,7 +272,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** @@ -260,7 +296,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param index Index. */ public item(index: number): IHTMLOptionElement { - return this.options.item(index); + return this[PropertySymbol.options].item(index); } /** @@ -270,7 +306,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param before HTMLOptionElement or index number. */ public add(element: IHTMLOptionElement, before?: number | IHTMLOptionElement): void { - this.options.add(element, before); + this[PropertySymbol.options].add(element, before); } /** @@ -280,7 +316,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec */ public override remove(index?: number): void { if (typeof index === 'number') { - this.options.remove(index); + this[PropertySymbol.options].remove(index); } else { super.remove(); } @@ -292,7 +328,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param message Message. */ public setCustomValidity(message: string): void { - (this.validationMessage) = String(message); + this[PropertySymbol.validationMessage] = String(message); } /** @@ -301,7 +337,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns "true" if the field is valid. */ public checkValidity(): boolean { - const valid = this.disabled || this.validity.valid; + const valid = this.disabled || this[PropertySymbol.validity].valid; if (!valid) { this.dispatchEvent(new Event('invalid', { bubbles: true, cancelable: true })); } @@ -326,13 +362,16 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @see https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm * @param [selectedOption] Selected option. */ - public _updateOptionItems(selectedOption?: IHTMLOptionElement): void { + public [PropertySymbol.updateOptionItems](selectedOption?: IHTMLOptionElement): void { const optionElements = >this.getElementsByTagName('option'); - if (optionElements.length < this.options.length) { - this.options.splice(this.options.length - 1, this.options.length - optionElements.length); + if (optionElements.length < this[PropertySymbol.options].length) { + this[PropertySymbol.options].splice( + this[PropertySymbol.options].length - 1, + this[PropertySymbol.options].length - optionElements.length + ); - for (let i = optionElements.length - 1, max = this.length; i < max; i++) { + for (let i = optionElements.length - 1, max = this[PropertySymbol.length]; i < max; i++) { delete this[i]; } } @@ -341,48 +380,49 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec const selected: HTMLOptionElement[] = []; for (let i = 0; i < optionElements.length; i++) { - this.options[i] = optionElements[i]; + this[PropertySymbol.options][i] = optionElements[i]; this[i] = optionElements[i]; if (!isMultiple) { if (selectedOption) { - (optionElements[i])._selectedness = + (optionElements[i])[PropertySymbol.selectedness] = optionElements[i] === selectedOption; } - if ((optionElements[i])._selectedness) { + if ((optionElements[i])[PropertySymbol.selectedness]) { selected.push(optionElements[i]); } } } - (this.length) = optionElements.length; + (this[PropertySymbol.length]) = optionElements.length; - const size = this._getDisplaySize(); + const size = this.#getDisplaySize(); if (size === 1 && !selected.length) { for (let i = 0, max = optionElements.length; i < max; i++) { const option = optionElements[i]; let disabled = option.hasAttributeNS(null, 'disabled'); - const parentNode = option.parentNode; + const parentNode = option[PropertySymbol.parentNode]; if ( parentNode && - parentNode.nodeType === NodeTypeEnum.elementNode && - parentNode.tagName === 'OPTGROUP' && + parentNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && + parentNode[PropertySymbol.tagName] === 'OPTGROUP' && parentNode.hasAttributeNS(null, 'disabled') ) { disabled = true; } if (!disabled) { - option._selectedness = true; + option[PropertySymbol.selectedness] = true; break; } } } else if (selected.length >= 2) { for (let i = 0, max = optionElements.length; i < max; i++) { - (optionElements[i])._selectedness = i === selected.length - 1; + (optionElements[i])[PropertySymbol.selectedness] = + i === selected.length - 1; } } } @@ -390,19 +430,25 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldFormNode = this._formNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldFormNode = this[PropertySymbol.formNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldFormNode !== this._formNode) { + if (oldFormNode !== this[PropertySymbol.formNode]) { if (oldFormNode) { - oldFormNode._removeFormControlItem(this, this.name); - oldFormNode._removeFormControlItem(this, this.id); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); } - if (this._formNode) { - (this._formNode)._appendFormControlItem(this, this.name); - (this._formNode)._appendFormControlItem(this, this.id); + if (this[PropertySymbol.formNode]) { + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.name + ); + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.id + ); } } } @@ -412,7 +458,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * * @returns Display size. */ - protected _getDisplaySize(): number { + #getDisplaySize(): number { if (this.hasAttributeNS(null, 'size')) { const size = parseInt(this.getAttribute('size')); if (!isNaN(size) && size >= 0) { diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts index 90793d6d4..667144978 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLSelectElement from './HTMLSelectElement.js'; @@ -9,7 +10,7 @@ import HTMLSelectElement from './HTMLSelectElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLSelectElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLSelectElement; + protected [PropertySymbol.ownerElement]: HTMLSelectElement; /** * @override @@ -17,18 +18,19 @@ export default class HTMLSelectElementNamedNodeMap extends HTMLElementNamedNodeM public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if ((item.name === 'id' || item.name === 'name') && this._ownerElement._formNode) { - if (replacedItem && replacedItem.value) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - replacedItem.value - ); + if ( + (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] + ) { + if (replacedItem && replacedItem[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); } - if (item.value) { - (this._ownerElement._formNode)._appendFormControlItem( - this._ownerElement, - item.value - ); + if (item[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.appendFormControlItem + ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); } } @@ -38,18 +40,17 @@ export default class HTMLSelectElementNamedNodeMap extends HTMLElementNamedNodeM /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); if ( removedItem && - (removedItem.name === 'id' || removedItem.name === 'name') && - this._ownerElement._formNode + (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] ) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - removedItem.value - ); + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts b/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts index b7c754b29..2baac06dc 100644 --- a/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts +++ b/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IShadowRoot from '../shadow-root/IShadowRoot.js'; import IHTMLSlotElement from './IHTMLSlotElement.js'; import IText from '../text/IText.js'; @@ -62,7 +63,7 @@ export default class HTMLSlotElement extends HTMLElement implements IHTMLSlotEle return this.assignedElements(options); } - return (host)._childNodes.slice(); + return (host)[PropertySymbol.childNodes].slice(); } return []; @@ -86,7 +87,7 @@ export default class HTMLSlotElement extends HTMLElement implements IHTMLSlotEle if (name) { const assignedElements = []; - for (const child of (host)._children) { + for (const child of (host)[PropertySymbol.children]) { if (child.slot === name) { assignedElements.push(child); } @@ -95,7 +96,7 @@ export default class HTMLSlotElement extends HTMLElement implements IHTMLSlotEle return assignedElements; } - return (host)._children.slice(); + return (host)[PropertySymbol.children].slice(); } return []; diff --git a/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts index 982620789..b16da2663 100644 --- a/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts +++ b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts @@ -1,4 +1,5 @@ import CSSStyleSheet from '../../css/CSSStyleSheet.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElement from '../html-element/HTMLElement.js'; import IHTMLStyleElement from './IHTMLStyleElement.js'; @@ -9,7 +10,7 @@ import IHTMLStyleElement from './IHTMLStyleElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLStyleElement. */ export default class HTMLStyleElement extends HTMLElement implements IHTMLStyleElement { - private _styleSheet: CSSStyleSheet | null = null; + private [PropertySymbol.sheet]: CSSStyleSheet | null = null; /** * Returns CSS style sheet. @@ -17,14 +18,14 @@ export default class HTMLStyleElement extends HTMLElement implements IHTMLStyleE * @returns CSS style sheet. */ public get sheet(): CSSStyleSheet { - if (!this.isConnected) { + if (!this[PropertySymbol.isConnected]) { return null; } - if (!this._styleSheet) { - this._styleSheet = new CSSStyleSheet(); + if (!this[PropertySymbol.sheet]) { + this[PropertySymbol.sheet] = new CSSStyleSheet(); } - this._styleSheet.replaceSync(this.textContent); - return this._styleSheet; + this[PropertySymbol.sheet].replaceSync(this.textContent); + return this[PropertySymbol.sheet]; } /** diff --git a/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts b/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts index 367be4cb3..8ff4fa083 100644 --- a/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts +++ b/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IDocumentFragment from '../document-fragment/IDocumentFragment.js'; import INode from '../node/INode.js'; import IHTMLTemplateElement from './IHTMLTemplateElement.js'; @@ -13,7 +14,18 @@ import DocumentFragment from '../document-fragment/DocumentFragment.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement. */ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTemplateElement { - public readonly content: IDocumentFragment = this.ownerDocument.createDocumentFragment(); + // Internal properties + public [PropertySymbol.content]: IDocumentFragment = + this[PropertySymbol.ownerDocument].createDocumentFragment(); + + /** + * Returns content. + * + * @returns Content. + */ + public get content(): IDocumentFragment { + return this[PropertySymbol.content]; + } /** * @override @@ -26,25 +38,29 @@ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTem * @override */ public set innerHTML(html: string) { - for (const child of (this.content)._childNodes.slice()) { - this.content.removeChild(child); + const content = this[PropertySymbol.content]; + + for (const child of content[PropertySymbol.childNodes].slice()) { + this[PropertySymbol.content].removeChild(child); } - XMLParser.parse(this.ownerDocument, html, { rootNode: this.content }); + XMLParser.parse(this[PropertySymbol.ownerDocument], html, { + rootNode: this[PropertySymbol.content] + }); } /** * @override */ public get firstChild(): INode { - return this.content.firstChild; + return this[PropertySymbol.content].firstChild; } /** * @override */ public get lastChild(): INode { - return this.content.lastChild; + return this[PropertySymbol.content].lastChild; } /** @@ -55,8 +71,9 @@ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTem includeShadowRoots: options && options.includeShadowRoots, escapeEntities: false }); + const content = this[PropertySymbol.content]; let xml = ''; - for (const node of (this.content)._childNodes) { + for (const node of content[PropertySymbol.childNodes]) { xml += xmlSerializer.serializeToString(node); } return xml; @@ -66,28 +83,28 @@ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTem * @override */ public appendChild(node: INode): INode { - return this.content.appendChild(node); + return this[PropertySymbol.content].appendChild(node); } /** * @override */ public removeChild(node: INode): INode { - return this.content.removeChild(node); + return this[PropertySymbol.content].removeChild(node); } /** * @override */ public insertBefore(newNode: INode, referenceNode: INode): INode { - return this.content.insertBefore(newNode, referenceNode); + return this[PropertySymbol.content].insertBefore(newNode, referenceNode); } /** * @override */ public replaceChild(newChild: INode, oldChild: INode): INode { - return this.content.replaceChild(newChild, oldChild); + return this[PropertySymbol.content].replaceChild(newChild, oldChild); } /** @@ -95,7 +112,7 @@ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTem */ public cloneNode(deep = false): IHTMLTemplateElement { const clone = super.cloneNode(deep); - (clone.content) = this.content.cloneNode(deep); + clone[PropertySymbol.content] = this[PropertySymbol.content].cloneNode(deep); return clone; } } diff --git a/packages/happy-dom/src/nodes/html-template-element/IHTMLTemplateElement.ts b/packages/happy-dom/src/nodes/html-template-element/IHTMLTemplateElement.ts index 19ad62717..2cbb37631 100644 --- a/packages/happy-dom/src/nodes/html-template-element/IHTMLTemplateElement.ts +++ b/packages/happy-dom/src/nodes/html-template-element/IHTMLTemplateElement.ts @@ -8,7 +8,7 @@ import IHTMLElement from '../html-element/IHTMLElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement. */ export default interface IHTMLTemplateElement extends IHTMLElement { - content: IDocumentFragment; + readonly content: IDocumentFragment; /** * Clones a node. diff --git a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts index 2139289de..f81a80a84 100644 --- a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts +++ b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts @@ -1,4 +1,5 @@ import Event from '../../event/Event.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import DOMException from '../../exception/DOMException.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import HTMLElement from '../html-element/HTMLElement.js'; @@ -22,20 +23,43 @@ import HTMLTextAreaElementNamedNodeMap from './HTMLTextAreaElementNamedNodeMap.j * https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement. */ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTextAreaElement { - public override readonly attributes: INamedNodeMap = new HTMLTextAreaElementNamedNodeMap(this); public readonly type = 'textarea'; - public readonly validationMessage = ''; - public readonly validity = new ValidityState(this); // Events public oninput: (event: Event) => void | null = null; public onselectionchange: (event: Event) => void | null = null; - public _value = null; - public _selectionStart = null; - public _selectionEnd = null; - public _selectionDirection = HTMLInputElementSelectionDirectionEnum.none; - public _textAreaNode: HTMLTextAreaElement = this; + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap = new HTMLTextAreaElementNamedNodeMap( + this + ); + public [PropertySymbol.validationMessage] = ''; + public [PropertySymbol.validity] = new ValidityState(this); + public [PropertySymbol.value] = null; + public [PropertySymbol.textAreaNode]: HTMLTextAreaElement = this; + + // Private properties + #selectionStart = null; + #selectionEnd = null; + #selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + + /** + * Returns validation message. + * + * @returns Validation message. + */ + public get validationMessage(): string { + return this[PropertySymbol.validationMessage]; + } + + /** + * Returns validity. + * + * @returns Validity. + */ + public get validity(): ValidityState { + return this[PropertySymbol.validity]; + } /** * Returns the default value. @@ -301,11 +325,11 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns Value. */ public get value(): string { - if (this._value === null) { + if (this[PropertySymbol.value] === null) { return this.textContent; } - return this._value; + return this[PropertySymbol.value]; } /** @@ -314,13 +338,13 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @param value Value. */ public set value(value: string) { - const oldValue = this._value; - this._value = value; + const oldValue = this[PropertySymbol.value]; + this[PropertySymbol.value] = value; - if (oldValue !== this._value) { - this._selectionStart = this._value.length; - this._selectionEnd = this._value.length; - this._selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + if (oldValue !== this[PropertySymbol.value]) { + this.#selectionStart = this[PropertySymbol.value].length; + this.#selectionEnd = this[PropertySymbol.value].length; + this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; } } @@ -330,11 +354,11 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns Selection start. */ public get selectionStart(): number { - if (this._selectionStart === null) { + if (this.#selectionStart === null) { return this.value.length; } - return this._selectionStart; + return this.#selectionStart; } /** @@ -343,7 +367,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @param start Start. */ public set selectionStart(start: number) { - this.setSelectionRange(start, Math.max(start, this.selectionEnd), this._selectionDirection); + this.setSelectionRange(start, Math.max(start, this.selectionEnd), this.#selectionDirection); } /** @@ -352,11 +376,11 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns Selection end. */ public get selectionEnd(): number { - if (this._selectionEnd === null) { + if (this.#selectionEnd === null) { return this.value.length; } - return this._selectionEnd; + return this.#selectionEnd; } /** @@ -365,7 +389,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @param end End. */ public set selectionEnd(end: number) { - this.setSelectionRange(this.selectionStart, end, this._selectionDirection); + this.setSelectionRange(this.selectionStart, end, this.#selectionDirection); } /** @@ -374,7 +398,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns Selection direction. */ public get selectionDirection(): string { - return this._selectionDirection; + return this.#selectionDirection; } /** @@ -392,7 +416,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns Form. */ public get form(): IHTMLFormElement { - return this._formNode; + return this[PropertySymbol.formNode]; } /** @@ -417,9 +441,9 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * Selects the text. */ public select(): void { - this._selectionStart = 0; - this._selectionEnd = this.value.length; - this._selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + this.#selectionStart = 0; + this.#selectionEnd = this.value.length; + this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; this.dispatchEvent(new Event('select', { bubbles: true, cancelable: true })); } @@ -432,9 +456,9 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @param [direction="none"] Direction. */ public setSelectionRange(start: number, end: number, direction = 'none'): void { - this._selectionEnd = Math.min(end, this.value.length); - this._selectionStart = Math.min(start, this.selectionEnd); - this._selectionDirection = + this.#selectionEnd = Math.min(end, this.value.length); + this.#selectionStart = Math.min(start, this.selectionEnd); + this.#selectionDirection = direction === HTMLInputElementSelectionDirectionEnum.forward || direction === HTMLInputElementSelectionDirectionEnum.backward ? direction @@ -458,10 +482,10 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex selectionMode = HTMLInputElementSelectionModeEnum.preserve ): void { if (start === null) { - start = this._selectionStart; + start = this.#selectionStart; } if (end === null) { - end = this._selectionEnd; + end = this.#selectionEnd; } if (start > end) { @@ -475,8 +499,8 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex end = Math.min(end, this.value.length); const val = this.value; - let selectionStart = this._selectionStart; - let selectionEnd = this._selectionEnd; + let selectionStart = this.#selectionStart; + let selectionEnd = this.#selectionEnd; this.value = val.slice(0, start) + replacement + val.slice(end); @@ -518,7 +542,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @param message Message. */ public setCustomValidity(message: string): void { - (this.validationMessage) = String(message); + this[PropertySymbol.validationMessage] = String(message); } /** @@ -527,7 +551,7 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex * @returns "true" if the field is valid. */ public checkValidity(): boolean { - const valid = this.disabled || this.readOnly || this.validity.valid; + const valid = this.disabled || this.readOnly || this[PropertySymbol.validity].valid; if (!valid) { this.dispatchEvent(new Event('invalid', { bubbles: true, cancelable: true })); } @@ -553,10 +577,10 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex public cloneNode(deep = false): IHTMLTextAreaElement { const clone = super.cloneNode(deep); - clone._value = this._value; - clone._selectionStart = this._selectionStart; - clone._selectionEnd = this._selectionEnd; - clone._selectionDirection = this._selectionDirection; + clone[PropertySymbol.value] = this[PropertySymbol.value]; + clone.#selectionStart = this.#selectionStart; + clone.#selectionEnd = this.#selectionEnd; + clone.#selectionDirection = this.#selectionDirection; return clone; } @@ -564,30 +588,36 @@ export default class HTMLTextAreaElement extends HTMLElement implements IHTMLTex /** * Resets selection. */ - public _resetSelection(): void { - if (this._value === null) { - this._selectionStart = null; - this._selectionEnd = null; - this._selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + public [PropertySymbol.resetSelection](): void { + if (this[PropertySymbol.value] === null) { + this.#selectionStart = null; + this.#selectionEnd = null; + this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; } } /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldFormNode = this._formNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldFormNode = this[PropertySymbol.formNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldFormNode !== this._formNode) { + if (oldFormNode !== this[PropertySymbol.formNode]) { if (oldFormNode) { - oldFormNode._removeFormControlItem(this, this.name); - oldFormNode._removeFormControlItem(this, this.id); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); + oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); } - if (this._formNode) { - (this._formNode)._appendFormControlItem(this, this.name); - (this._formNode)._appendFormControlItem(this, this.id); + if (this[PropertySymbol.formNode]) { + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.name + ); + (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( + this, + this.id + ); } } } diff --git a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts index a829469c3..b182f16d1 100644 --- a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLTextAreaElement from './HTMLTextAreaElement.js'; @@ -9,7 +10,7 @@ import HTMLTextAreaElement from './HTMLTextAreaElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class HTMLTextAreaElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected _ownerElement: HTMLTextAreaElement; + protected [PropertySymbol.ownerElement]: HTMLTextAreaElement; /** * @override @@ -17,18 +18,19 @@ export default class HTMLTextAreaElementNamedNodeMap extends HTMLElementNamedNod public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if ((item.name === 'id' || item.name === 'name') && this._ownerElement._formNode) { - if (replacedItem && replacedItem.value) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - replacedItem.value - ); + if ( + (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] + ) { + if (replacedItem && replacedItem[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); } - if (item.value) { - (this._ownerElement._formNode)._appendFormControlItem( - this._ownerElement, - item.value - ); + if (item[PropertySymbol.value]) { + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.appendFormControlItem + ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); } } @@ -38,18 +40,17 @@ export default class HTMLTextAreaElementNamedNodeMap extends HTMLElementNamedNod /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); if ( removedItem && - (removedItem.name === 'id' || removedItem.name === 'name') && - this._ownerElement._formNode + (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && + this[PropertySymbol.ownerElement][PropertySymbol.formNode] ) { - (this._ownerElement._formNode)._removeFormControlItem( - this._ownerElement, - removedItem.value - ); + (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ + PropertySymbol.removeFormControlItem + ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); } return removedItem; diff --git a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts index 02cb7a737..4eedf2bdf 100644 --- a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts +++ b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts @@ -1,4 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import INode from '../node/INode.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; import INodeList from '../node/INodeList.js'; @@ -15,88 +16,123 @@ import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js' * https://developer.mozilla.org/en-US/docs/Web/API/HTMLUnknownElement. */ export default class HTMLUnknownElement extends HTMLElement implements IHTMLElement { - private _customElementDefineCallback: () => void = null; + #customElementDefineCallback: () => void = null; /** * Connects this element to another element. * * @param parentNode Parent node. */ - public _connectToNode(parentNode: INode = null): void { - const tagName = this.tagName; + public [PropertySymbol.connectToNode](parentNode: INode = null): void { + const tagName = this[PropertySymbol.tagName]; // This element can potentially be a custom element that has not been defined yet // Therefore we need to register a callback for when it is defined in CustomElementRegistry and replace it with the registered element (see #404) - if (tagName.includes('-') && this.ownerDocument.defaultView.customElements._callbacks) { - const callbacks = this.ownerDocument.defaultView.customElements._callbacks; + if ( + tagName.includes('-') && + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ] + ) { + const callbacks = + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ]; - if (parentNode && !this._customElementDefineCallback) { + if (parentNode && !this.#customElementDefineCallback) { const callback = (): void => { - if (this.parentNode) { - const newElement = this.ownerDocument.createElement(tagName); - (>newElement._childNodes) = this._childNodes; - (>newElement._children) = this._children; - (newElement.isConnected) = this.isConnected; + if (this[PropertySymbol.parentNode]) { + const newElement = ( + this[PropertySymbol.ownerDocument].createElement(tagName) + ); + (>newElement[PropertySymbol.childNodes]) = + this[PropertySymbol.childNodes]; + (>newElement[PropertySymbol.children]) = + this[PropertySymbol.children]; + (newElement[PropertySymbol.isConnected]) = this[PropertySymbol.isConnected]; - newElement._rootNode = this._rootNode; - newElement._formNode = this._formNode; - newElement._selectNode = this._selectNode; - newElement._textAreaNode = this._textAreaNode; - newElement._observers = this._observers; - newElement._isValue = this._isValue; + newElement[PropertySymbol.rootNode] = this[PropertySymbol.rootNode]; + newElement[PropertySymbol.formNode] = this[PropertySymbol.formNode]; + newElement[PropertySymbol.selectNode] = this[PropertySymbol.selectNode]; + newElement[PropertySymbol.textAreaNode] = this[PropertySymbol.textAreaNode]; + newElement[PropertySymbol.observers] = this[PropertySymbol.observers]; + newElement[PropertySymbol.isValue] = this[PropertySymbol.isValue]; - for (let i = 0, max = this.attributes.length; i < max; i++) { - newElement.attributes.setNamedItem(this.attributes[i]); + for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { + newElement[PropertySymbol.attributes].setNamedItem( + this[PropertySymbol.attributes][i] + ); } - (>this._childNodes) = new NodeList(); - (>this._children) = new HTMLCollection(); - this._rootNode = null; - this._formNode = null; - this._selectNode = null; - this._textAreaNode = null; - this._observers = []; - this._isValue = null; - (this.attributes) = new HTMLElementNamedNodeMap(this); + (>this[PropertySymbol.childNodes]) = new NodeList(); + (>this[PropertySymbol.children]) = new HTMLCollection(); + this[PropertySymbol.rootNode] = null; + this[PropertySymbol.formNode] = null; + this[PropertySymbol.selectNode] = null; + this[PropertySymbol.textAreaNode] = null; + this[PropertySymbol.observers] = []; + this[PropertySymbol.isValue] = null; + (this[PropertySymbol.attributes]) = + new HTMLElementNamedNodeMap(this); - for (let i = 0, max = (this.parentNode)._childNodes.length; i < max; i++) { - if ((this.parentNode)._childNodes[i] === this) { - (this.parentNode)._childNodes[i] = newElement; + for ( + let i = 0, + max = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes] + .length; + i < max; + i++ + ) { + if ( + (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] === + this + ) { + (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] = + newElement; break; } } - if ((this.parentNode)._children) { - for (let i = 0, max = (this.parentNode)._children.length; i < max; i++) { - if ((this.parentNode)._children[i] === this) { - (this.parentNode)._children[i] = newElement; + if ((this[PropertySymbol.parentNode])[PropertySymbol.children]) { + for ( + let i = 0, + max = (this[PropertySymbol.parentNode])[PropertySymbol.children] + .length; + i < max; + i++ + ) { + if ( + (this[PropertySymbol.parentNode])[PropertySymbol.children][i] === + this + ) { + (this[PropertySymbol.parentNode])[PropertySymbol.children][i] = + newElement; break; } } } - if (newElement.isConnected && newElement.connectedCallback) { + if (newElement[PropertySymbol.isConnected] && newElement.connectedCallback) { newElement.connectedCallback(); } - this._connectToNode(null); + this[PropertySymbol.connectToNode](null); } }; callbacks[tagName] = callbacks[tagName] || []; callbacks[tagName].push(callback); - this._customElementDefineCallback = callback; - } else if (!parentNode && callbacks[tagName] && this._customElementDefineCallback) { - const index = callbacks[tagName].indexOf(this._customElementDefineCallback); + this.#customElementDefineCallback = callback; + } else if (!parentNode && callbacks[tagName] && this.#customElementDefineCallback) { + const index = callbacks[tagName].indexOf(this.#customElementDefineCallback); if (index !== -1) { callbacks[tagName].splice(index, 1); } if (!callbacks[tagName].length) { delete callbacks[tagName]; } - this._customElementDefineCallback = null; + this.#customElementDefineCallback = null; } } - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); } } diff --git a/packages/happy-dom/src/nodes/node/INode.ts b/packages/happy-dom/src/nodes/node/INode.ts index 0676fd189..a13ab5808 100644 --- a/packages/happy-dom/src/nodes/node/INode.ts +++ b/packages/happy-dom/src/nodes/node/INode.ts @@ -22,8 +22,8 @@ export default interface INode extends IEventTarget { readonly DOCUMENT_POSITION_CONTAINED_BY: NodeDocumentPositionEnum; readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: NodeDocumentPositionEnum; readonly ownerDocument: IDocument; - readonly parentNode: INode; - readonly parentElement: IElement; + readonly parentNode: INode | null; + readonly parentElement: IElement | null; readonly nodeType: number; readonly childNodes: INodeList; readonly isConnected: boolean; diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts index f156fe569..924039645 100644 --- a/packages/happy-dom/src/nodes/node/Node.ts +++ b/packages/happy-dom/src/nodes/node/Node.ts @@ -1,4 +1,5 @@ import EventTarget from '../../event/EventTarget.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import MutationListener from '../../mutation-observer/MutationListener.js'; import INode from './INode.js'; import IDocument from '../document/IDocument.js'; @@ -10,13 +11,14 @@ import NodeUtility from './NodeUtility.js'; import IAttr from '../attr/IAttr.js'; import NodeList from './NodeList.js'; import INodeList from './INodeList.js'; +import NodeFactory from '../NodeFactory.js'; /** * Node. */ export default class Node extends EventTarget implements INode { - // Owner document is set when the Node is created by the Document - public static _ownerDocument: IDocument = null; + // This is used when overriding a Node class and set it in a owner document context (used in BrowserWindow.constructor()). + public static [PropertySymbol.ownerDocument]: IDocument | null; // Public properties public static readonly ELEMENT_NODE = NodeTypeEnum.elementNode; @@ -35,41 +37,54 @@ export default class Node extends EventTarget implements INode { public static readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = NodeDocumentPositionEnum.implementationSpecific; public static readonly DOCUMENT_POSITION_PRECEDING = NodeDocumentPositionEnum.preceding; - public readonly ELEMENT_NODE = NodeTypeEnum.elementNode; - public readonly ATTRIBUTE_NODE = NodeTypeEnum.attributeNode; - public readonly TEXT_NODE = NodeTypeEnum.textNode; - public readonly CDATA_SECTION_NODE = NodeTypeEnum.cdataSectionNode; - public readonly COMMENT_NODE = NodeTypeEnum.commentNode; - public readonly DOCUMENT_NODE = NodeTypeEnum.documentNode; - public readonly DOCUMENT_TYPE_NODE = NodeTypeEnum.documentTypeNode; - public readonly DOCUMENT_FRAGMENT_NODE = NodeTypeEnum.documentFragmentNode; - public readonly PROCESSING_INSTRUCTION_NODE = NodeTypeEnum.processingInstructionNode; - public readonly DOCUMENT_POSITION_CONTAINED_BY = NodeDocumentPositionEnum.containedBy; - public readonly DOCUMENT_POSITION_CONTAINS = NodeDocumentPositionEnum.contains; - public readonly DOCUMENT_POSITION_DISCONNECTED = NodeDocumentPositionEnum.disconnect; - public readonly DOCUMENT_POSITION_FOLLOWING = NodeDocumentPositionEnum.following; - public readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = - NodeDocumentPositionEnum.implementationSpecific; - public readonly DOCUMENT_POSITION_PRECEDING = NodeDocumentPositionEnum.preceding; - public readonly ownerDocument: IDocument = null; - public readonly parentNode: INode = null; - public readonly nodeType: number; - public readonly isConnected: boolean = false; - // Custom Properties (not part of HTML standard) - public _rootNode: INode = null; - public _formNode: INode = null; - public _selectNode: INode = null; - public _textAreaNode: INode = null; - public _observers: MutationListener[] = []; - public readonly _childNodes: INodeList = new NodeList(); + // Defined on the prototype + public readonly ELEMENT_NODE; + public readonly ATTRIBUTE_NODE; + public readonly TEXT_NODE; + public readonly CDATA_SECTION_NODE; + public readonly COMMENT_NODE; + public readonly DOCUMENT_NODE; + public readonly DOCUMENT_TYPE_NODE; + public readonly DOCUMENT_FRAGMENT_NODE; + public readonly PROCESSING_INSTRUCTION_NODE; + public readonly DOCUMENT_POSITION_CONTAINED_BY; + public readonly DOCUMENT_POSITION_CONTAINS; + public readonly DOCUMENT_POSITION_DISCONNECTED; + public readonly DOCUMENT_POSITION_FOLLOWING; + public readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC; + public readonly DOCUMENT_POSITION_PRECEDING; + + // Internal properties + public [PropertySymbol.isConnected] = false; + public [PropertySymbol.ownerDocument]: IDocument; + public [PropertySymbol.parentNode]: INode | null = null; + public [PropertySymbol.nodeType]: NodeTypeEnum; + public [PropertySymbol.rootNode]: INode = null; + public [PropertySymbol.formNode]: INode = null; + public [PropertySymbol.selectNode]: INode = null; + public [PropertySymbol.textAreaNode]: INode = null; + public [PropertySymbol.observers]: MutationListener[] = []; + public [PropertySymbol.childNodes]: INodeList = new NodeList(); /** * Constructor. */ constructor() { super(); - this.ownerDocument = (this.constructor)._ownerDocument; + if ((this.constructor)[PropertySymbol.ownerDocument] !== undefined) { + this[PropertySymbol.ownerDocument] = (this.constructor)[ + PropertySymbol.ownerDocument + ]; + } else { + const ownerDocument = NodeFactory.pullOwnerDocument(); + if (!ownerDocument) { + throw new Error( + 'Failed to construct "Node": No owner document in queue. Please use "NodeFactory" to create instances of a Node.' + ); + } + this[PropertySymbol.ownerDocument] = ownerDocument; + } } /** @@ -81,13 +96,49 @@ export default class Node extends EventTarget implements INode { return this.constructor.name; } + /** + * Returns connected state. + * + * @returns Connected state. + */ + public get isConnected(): boolean { + return this[PropertySymbol.isConnected]; + } + + /** + * Returns owner document. + * + * @returns Owner document. + */ + public get ownerDocument(): IDocument { + return this[PropertySymbol.ownerDocument]; + } + + /** + * Returns parent node. + * + * @returns Parent node. + */ + public get parentNode(): INode | null { + return this[PropertySymbol.parentNode]; + } + + /** + * Returns node type. + * + * @returns Node type. + */ + public get nodeType(): number { + return this[PropertySymbol.nodeType]; + } + /** * Get child nodes. * * @returns Child nodes list. */ public get childNodes(): INodeList { - return this._childNodes; + return this[PropertySymbol.childNodes]; } /** @@ -141,10 +192,12 @@ export default class Node extends EventTarget implements INode { * @returns Node. */ public get previousSibling(): INode { - if (this.parentNode) { - const index = (this.parentNode)._childNodes.indexOf(this); + if (this[PropertySymbol.parentNode]) { + const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + this + ); if (index > 0) { - return (this.parentNode)._childNodes[index - 1]; + return (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][index - 1]; } } return null; @@ -156,10 +209,15 @@ export default class Node extends EventTarget implements INode { * @returns Node. */ public get nextSibling(): INode { - if (this.parentNode) { - const index = (this.parentNode)._childNodes.indexOf(this); - if (index > -1 && index + 1 < (this.parentNode)._childNodes.length) { - return (this.parentNode)._childNodes[index + 1]; + if (this[PropertySymbol.parentNode]) { + const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + this + ); + if ( + index > -1 && + index + 1 < (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].length + ) { + return (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][index + 1]; } } return null; @@ -171,8 +229,8 @@ export default class Node extends EventTarget implements INode { * @returns Node. */ public get firstChild(): INode { - if (this._childNodes.length > 0) { - return this._childNodes[0]; + if (this[PropertySymbol.childNodes].length > 0) { + return this[PropertySymbol.childNodes][0]; } return null; } @@ -183,8 +241,8 @@ export default class Node extends EventTarget implements INode { * @returns Node. */ public get lastChild(): INode { - if (this._childNodes.length > 0) { - return this._childNodes[this._childNodes.length - 1]; + if (this[PropertySymbol.childNodes].length > 0) { + return this[PropertySymbol.childNodes][this[PropertySymbol.childNodes].length - 1]; } return null; } @@ -194,10 +252,10 @@ export default class Node extends EventTarget implements INode { * * @returns Element. */ - public get parentElement(): IElement { - let parent = this.parentNode; - while (parent && parent.nodeType !== NodeTypeEnum.elementNode) { - parent = parent.parentNode; + public get parentElement(): IElement | null { + let parent = this[PropertySymbol.parentNode]; + while (parent && parent[PropertySymbol.nodeType] !== NodeTypeEnum.elementNode) { + parent = parent[PropertySymbol.parentNode]; } return parent; } @@ -208,11 +266,11 @@ export default class Node extends EventTarget implements INode { * @returns Base URI. */ public get baseURI(): string { - const base = this.ownerDocument.querySelector('base'); + const base = this[PropertySymbol.ownerDocument].querySelector('base'); if (base) { return base.href; } - return this.ownerDocument.defaultView.location.href; + return this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href; } /** @@ -231,7 +289,7 @@ export default class Node extends EventTarget implements INode { * @returns "true" if the node has child nodes. */ public hasChildNodes(): boolean { - return this._childNodes.length > 0; + return this[PropertySymbol.childNodes].length > 0; } /** @@ -252,15 +310,15 @@ export default class Node extends EventTarget implements INode { * @returns Node. */ public getRootNode(options?: { composed: boolean }): INode { - if (!this.isConnected) { + if (!this[PropertySymbol.isConnected]) { return this; } - if (this._rootNode && !options?.composed) { - return this._rootNode; + if (this[PropertySymbol.rootNode] && !options?.composed) { + return this[PropertySymbol.rootNode]; } - return this.ownerDocument; + return this[PropertySymbol.ownerDocument]; } /** @@ -270,25 +328,26 @@ export default class Node extends EventTarget implements INode { * @returns Cloned node. */ public cloneNode(deep = false): INode { - const clone = new (this.constructor)(); + const clone = NodeFactory.createNode( + this[PropertySymbol.ownerDocument], + this.constructor + ); // Document has childNodes directly when it is created - if (clone._childNodes.length) { - for (const node of clone._childNodes.slice()) { - node.parentNode.removeChild(node); + if (clone[PropertySymbol.childNodes].length) { + for (const node of clone[PropertySymbol.childNodes].slice()) { + node[PropertySymbol.parentNode].removeChild(node); } } if (deep) { - for (const childNode of this._childNodes) { + for (const childNode of this[PropertySymbol.childNodes]) { const childClone = childNode.cloneNode(true); - (childClone.parentNode) = clone; - clone._childNodes.push(childClone); + childClone[PropertySymbol.parentNode] = clone; + clone[PropertySymbol.childNodes].push(childClone); } } - (clone.ownerDocument) = this.ownerDocument; - return clone; } @@ -357,11 +416,11 @@ export default class Node extends EventTarget implements INode { * * @param listener Listener. */ - public _observe(listener: MutationListener): void { - this._observers.push(listener); + public [PropertySymbol.observe](listener: MutationListener): void { + this[PropertySymbol.observers].push(listener); if (listener.options.subtree) { - for (const node of this._childNodes) { - (node)._observe(listener); + for (const node of this[PropertySymbol.childNodes]) { + (node)[PropertySymbol.observe](listener); } } } @@ -372,14 +431,14 @@ export default class Node extends EventTarget implements INode { * * @param listener Listener. */ - public _unobserve(listener: MutationListener): void { - const index = this._observers.indexOf(listener); + public [PropertySymbol.unobserve](listener: MutationListener): void { + const index = this[PropertySymbol.observers].indexOf(listener); if (index !== -1) { - this._observers.splice(index, 1); + this[PropertySymbol.observers].splice(index, 1); } if (listener.options.subtree) { - for (const node of this._childNodes) { - (node)._unobserve(listener); + for (const node of this[PropertySymbol.childNodes]) { + (node)[PropertySymbol.unobserve](listener); } } } @@ -389,35 +448,42 @@ export default class Node extends EventTarget implements INode { * * @param parentNode Parent node. */ - public _connectToNode(parentNode: INode = null): void { - const isConnected = !!parentNode && parentNode.isConnected; - const formNode = (this)._formNode; - const selectNode = (this)._selectNode; - const textAreaNode = (this)._textAreaNode; + public [PropertySymbol.connectToNode](parentNode: INode = null): void { + const isConnected = !!parentNode && parentNode[PropertySymbol.isConnected]; + const formNode = (this)[PropertySymbol.formNode]; + const selectNode = (this)[PropertySymbol.selectNode]; + const textAreaNode = (this)[PropertySymbol.textAreaNode]; - if (this.nodeType !== NodeTypeEnum.documentFragmentNode) { - (this.parentNode) = parentNode; - (this)._rootNode = isConnected && parentNode ? (parentNode)._rootNode : null; + if (this[PropertySymbol.nodeType] !== NodeTypeEnum.documentFragmentNode) { + this[PropertySymbol.parentNode] = parentNode; + this[PropertySymbol.rootNode] = + isConnected && parentNode ? (parentNode)[PropertySymbol.rootNode] : null; if (this['tagName'] !== 'FORM') { - (this)._formNode = parentNode ? (parentNode)._formNode : null; + (this)[PropertySymbol.formNode] = parentNode + ? (parentNode)[PropertySymbol.formNode] + : null; } if (this['tagName'] !== 'SELECT') { - (this)._selectNode = parentNode ? (parentNode)._selectNode : null; + (this)[PropertySymbol.selectNode] = parentNode + ? (parentNode)[PropertySymbol.selectNode] + : null; } if (this['tagName'] !== 'TEXTAREA') { - (this)._textAreaNode = parentNode ? (parentNode)._textAreaNode : null; + (this)[PropertySymbol.textAreaNode] = parentNode + ? (parentNode)[PropertySymbol.textAreaNode] + : null; } } - if (this.isConnected !== isConnected) { - (this.isConnected) = isConnected; + if (this[PropertySymbol.isConnected] !== isConnected) { + this[PropertySymbol.isConnected] = isConnected; if (!isConnected) { - if (this.ownerDocument['_activeElement'] === this) { - this.ownerDocument['_activeElement'] = null; + if (this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === this) { + this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; } } @@ -427,22 +493,22 @@ export default class Node extends EventTarget implements INode { this.disconnectedCallback(); } - for (const child of this._childNodes) { - (child)._connectToNode(this); + for (const child of this[PropertySymbol.childNodes]) { + (child)[PropertySymbol.connectToNode](this); } // eslint-disable-next-line - if ((this)._shadowRoot) { + if ((this)[PropertySymbol.shadowRoot]) { // eslint-disable-next-line - (this)._shadowRoot._connectToNode(this); + (this)[PropertySymbol.shadowRoot][PropertySymbol.connectToNode](this); } } else if ( - formNode !== this._formNode || - selectNode !== this._selectNode || - textAreaNode !== this._textAreaNode + formNode !== this[PropertySymbol.formNode] || + selectNode !== this[PropertySymbol.selectNode] || + textAreaNode !== this[PropertySymbol.textAreaNode] ) { - for (const child of this._childNodes) { - (child)._connectToNode(this); + for (const child of this[PropertySymbol.childNodes]) { + (child)[PropertySymbol.connectToNode](this); } } } @@ -476,18 +542,18 @@ export default class Node extends EventTarget implements INode { /** * 4. If node1 is an attribute, then set attr1 to node1 and node1 to attr1’s element. */ - if (node1.nodeType === Node.ATTRIBUTE_NODE) { + if (node1[PropertySymbol.nodeType] === NodeTypeEnum.attributeNode) { attr1 = node1; - node1 = (attr1).ownerElement; + node1 = (attr1)[PropertySymbol.ownerElement]; } /** * 5. If node2 is an attribute, then: * 5.1. Set attr2 to node2 and node2 to attr2’s element. */ - if (node2.nodeType === Node.ATTRIBUTE_NODE) { + if (node2[PropertySymbol.nodeType] === NodeTypeEnum.attributeNode) { attr2 = node2; - node2 = (attr2).ownerElement; + node2 = (attr2)[PropertySymbol.ownerElement]; /** * 5.2. If attr1 and node1 are non-null, and node2 is node1, then: @@ -496,7 +562,7 @@ export default class Node extends EventTarget implements INode { /** * 5.2.1. For each attr in node2’s attribute list: */ - for (const attr of Object.values((node2).attributes)) { + for (const attr of Object.values((node2)[PropertySymbol.attributes])) { /** * 5.2.1.1. If attr equals attr1, then return the result of adding DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC and DOCUMENT_POSITION_PRECEDING. */ @@ -530,7 +596,7 @@ export default class Node extends EventTarget implements INode { } node2Ancestors.push(node2Ancestor); - node2Ancestor = node2Ancestor.parentNode; + node2Ancestor = node2Ancestor[PropertySymbol.parentNode]; } const node1Ancestors: INode[] = []; @@ -545,7 +611,7 @@ export default class Node extends EventTarget implements INode { } node1Ancestors.push(node1Ancestor); - node1Ancestor = node1Ancestor.parentNode; + node1Ancestor = node1Ancestor[PropertySymbol.parentNode]; } const reverseArrayIndex = (array: INode[], reverseIndex: number): INode => { @@ -593,7 +659,7 @@ export default class Node extends EventTarget implements INode { const computeNodeIndexes = (nodes: INode[]): void => { for (const childNode of nodes) { - computeNodeIndexes((childNode)._childNodes); + computeNodeIndexes((childNode)[PropertySymbol.childNodes]); if (childNode === node2Node) { node2Index = indexes; @@ -609,7 +675,7 @@ export default class Node extends EventTarget implements INode { } }; - computeNodeIndexes((commonAncestor)._childNodes); + computeNodeIndexes((commonAncestor)[PropertySymbol.childNodes]); /** * 9. If node1 is preceding node2, then return DOCUMENT_POSITION_PRECEDING. @@ -661,3 +727,27 @@ export default class Node extends EventTarget implements INode { return this === node; } } + +// According to the spec, these properties should be on the prototype. +(Node.prototype.ELEMENT_NODE) = NodeTypeEnum.elementNode; +(Node.prototype.ATTRIBUTE_NODE) = NodeTypeEnum.attributeNode; + +(Node.prototype.TEXT_NODE) = NodeTypeEnum.textNode; +(Node.prototype.CDATA_SECTION_NODE) = NodeTypeEnum.cdataSectionNode; +(Node.prototype.COMMENT_NODE) = NodeTypeEnum.commentNode; +(Node.prototype.DOCUMENT_NODE) = NodeTypeEnum.documentNode; +(Node.prototype.DOCUMENT_TYPE_NODE) = NodeTypeEnum.documentTypeNode; +(Node.prototype.DOCUMENT_FRAGMENT_NODE) = NodeTypeEnum.documentFragmentNode; +(Node.prototype.PROCESSING_INSTRUCTION_NODE) = NodeTypeEnum.processingInstructionNode; +(Node.prototype.DOCUMENT_POSITION_CONTAINED_BY) = + NodeDocumentPositionEnum.containedBy; +(Node.prototype.DOCUMENT_POSITION_CONTAINS) = + NodeDocumentPositionEnum.contains; +(Node.prototype.DOCUMENT_POSITION_DISCONNECTED) = + NodeDocumentPositionEnum.disconnect; +(Node.prototype.DOCUMENT_POSITION_FOLLOWING) = + NodeDocumentPositionEnum.following; +(Node.prototype.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) = + NodeDocumentPositionEnum.implementationSpecific; +(Node.prototype.DOCUMENT_POSITION_PRECEDING) = + NodeDocumentPositionEnum.preceding; diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index a8dddb376..3d64fc511 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -1,4 +1,5 @@ import IText from '../text/IText.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IComment from '../comment/IComment.js'; import INode from './INode.js'; import NodeTypeEnum from './NodeTypeEnum.js'; @@ -46,42 +47,45 @@ export default class NodeUtility { // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment - if (node.nodeType === NodeTypeEnum.documentFragmentNode) { - for (const child of (node)._childNodes.slice()) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + for (const child of (node)[PropertySymbol.childNodes].slice()) { ancestorNode.appendChild(child); } return node; } // Remove the node from its previous parent if it has any. - if (node.parentNode) { - const index = (node.parentNode)._childNodes.indexOf(node); + if (node[PropertySymbol.parentNode]) { + const index = (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + node + ); if (index !== -1) { - (node.parentNode)._childNodes.splice(index, 1); + (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].splice(index, 1); } } - if (ancestorNode.isConnected) { - (ancestorNode.ownerDocument || this)['_cacheID']++; + if (ancestorNode[PropertySymbol.isConnected]) { + (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; } - (ancestorNode)._childNodes.push(node); + (ancestorNode)[PropertySymbol.childNodes].push(node); - (node)._connectToNode(ancestorNode); + (node)[PropertySymbol.connectToNode](ancestorNode); // MutationObserver - if ((ancestorNode)._observers.length > 0) { - const record = new MutationRecord(); - record.target = ancestorNode; - record.type = MutationTypeEnum.childList; - record.addedNodes = [node]; - - for (const observer of (ancestorNode)._observers) { + if ((ancestorNode)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: ancestorNode, + type: MutationTypeEnum.childList, + addedNodes: [node] + }); + + for (const observer of (ancestorNode)[PropertySymbol.observers]) { if (observer.options.subtree) { - (node)._observe(observer); + (node)[PropertySymbol.observe](observer); } if (observer.options.childList) { - observer.callback([record], observer.observer); + observer.report(record); } } } @@ -97,31 +101,34 @@ export default class NodeUtility { * @returns Removed node. */ public static removeChild(ancestorNode: INode, node: INode): INode { - const index = (ancestorNode)._childNodes.indexOf(node); + const index = (ancestorNode)[PropertySymbol.childNodes].indexOf(node); if (index === -1) { throw new DOMException('Failed to remove node. Node is not child of parent.'); } - if (ancestorNode.isConnected) { - (ancestorNode.ownerDocument || this)['_cacheID']++; + if (ancestorNode[PropertySymbol.isConnected]) { + (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; } - (ancestorNode)._childNodes.splice(index, 1); + (ancestorNode)[PropertySymbol.childNodes].splice(index, 1); - (node)._connectToNode(null); + (node)[PropertySymbol.connectToNode](null); // MutationObserver - if ((ancestorNode)._observers.length > 0) { - const record = new MutationRecord(); - record.target = ancestorNode; - record.type = MutationTypeEnum.childList; - record.removedNodes = [node]; - - for (const observer of (ancestorNode)._observers) { - (node)._unobserve(observer); + if ((ancestorNode)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: ancestorNode, + type: MutationTypeEnum.childList, + removedNodes: [node] + }); + + for (const observer of (ancestorNode)[PropertySymbol.observers]) { + if (observer.options.subtree) { + (node)[PropertySymbol.unobserve](observer); + } if (observer.options.childList) { - observer.callback([record], observer.observer); + observer.report(record); } } } @@ -157,8 +164,8 @@ export default class NodeUtility { // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment - if (newNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - for (const child of (newNode)._childNodes.slice()) { + if (newNode[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + for (const child of (newNode)[PropertySymbol.childNodes].slice()) { ancestorNode.insertBefore(child, referenceNode); } return newNode; @@ -171,44 +178,47 @@ export default class NodeUtility { return newNode; } - if ((ancestorNode)._childNodes.indexOf(referenceNode) === -1) { + if ((ancestorNode)[PropertySymbol.childNodes].indexOf(referenceNode) === -1) { throw new DOMException( "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node." ); } - if (ancestorNode.isConnected) { - (ancestorNode.ownerDocument || this)['_cacheID']++; + if (ancestorNode[PropertySymbol.isConnected]) { + (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; } - if (newNode.parentNode) { - const index = (newNode.parentNode)._childNodes.indexOf(newNode); + if (newNode[PropertySymbol.parentNode]) { + const index = (newNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + newNode + ); if (index !== -1) { - (newNode.parentNode)._childNodes.splice(index, 1); + (newNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].splice(index, 1); } } - (ancestorNode)._childNodes.splice( - (ancestorNode)._childNodes.indexOf(referenceNode), + (ancestorNode)[PropertySymbol.childNodes].splice( + (ancestorNode)[PropertySymbol.childNodes].indexOf(referenceNode), 0, newNode ); - (newNode)._connectToNode(ancestorNode); + (newNode)[PropertySymbol.connectToNode](ancestorNode); // MutationObserver - if ((ancestorNode)._observers.length > 0) { - const record = new MutationRecord(); - record.target = ancestorNode; - record.type = MutationTypeEnum.childList; - record.addedNodes = [newNode]; - - for (const observer of (ancestorNode)._observers) { + if ((ancestorNode)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: ancestorNode, + type: MutationTypeEnum.childList, + addedNodes: [newNode] + }); + + for (const observer of (ancestorNode)[PropertySymbol.observers]) { if (observer.options.subtree) { - (newNode)._observe(observer); + (newNode)[PropertySymbol.observe](observer); } if (observer.options.childList) { - observer.callback([record], observer.observer); + observer.report(record); } } } @@ -223,7 +233,7 @@ export default class NodeUtility { * @returns "true" if the node is a text node. */ public static isTextNode(node: INode | null): node is IText { - return node?.nodeType === NodeTypeEnum.textNode; + return node?.[PropertySymbol.nodeType] === NodeTypeEnum.textNode; } /** @@ -239,8 +249,8 @@ export default class NodeUtility { * @returns "true" if inclusive ancestor. */ public static isInclusiveAncestor( - ancestorNode: INode, - referenceNode: INode, + ancestorNode: INode | null, + referenceNode: INode | null, includeShadowRoots = false ): boolean { if (ancestorNode === null || referenceNode === null) { @@ -251,31 +261,34 @@ export default class NodeUtility { return true; } - if (!(ancestorNode)._childNodes.length) { + if (!(ancestorNode)[PropertySymbol.childNodes].length) { return false; } - if (includeShadowRoots && referenceNode.isConnected !== ancestorNode.isConnected) { + if ( + includeShadowRoots && + referenceNode[PropertySymbol.isConnected] !== ancestorNode[PropertySymbol.isConnected] + ) { return false; } if ( includeShadowRoots && - ancestorNode === referenceNode.ownerDocument && - referenceNode.isConnected + ancestorNode === referenceNode[PropertySymbol.ownerDocument] && + referenceNode[PropertySymbol.isConnected] ) { return true; } - let parent: INode = referenceNode.parentNode; + let parent: INode = referenceNode[PropertySymbol.parentNode]; while (parent) { if (ancestorNode === parent) { return true; } - parent = parent.parentNode - ? parent.parentNode + parent = parent[PropertySymbol.parentNode] + ? parent[PropertySymbol.parentNode] : includeShadowRoots && (parent).host ? (parent).host : null; @@ -324,7 +337,7 @@ export default class NodeUtility { * @returns Node length. */ public static getNodeLength(node: INode): number { - switch (node.nodeType) { + switch (node[PropertySymbol.nodeType]) { case NodeTypeEnum.documentTypeNode: return 0; @@ -334,7 +347,7 @@ export default class NodeUtility { return (node).data.length; default: - return (node)._childNodes.length; + return (node)[PropertySymbol.childNodes].length; } } @@ -368,7 +381,7 @@ export default class NodeUtility { return nextSibling; } - current = current.parentNode; + current = current[PropertySymbol.parentNode]; } return null; @@ -382,7 +395,7 @@ export default class NodeUtility { */ public static nextDescendantNode(node: INode): INode { while (node && !node.nextSibling) { - node = node.parentNode; + node = node[PropertySymbol.parentNode]; } if (!node) { @@ -399,13 +412,13 @@ export default class NodeUtility { * @param elementB */ public static attributeListsEqual(elementA: IElement, elementB: IElement): boolean { - for (let i = 0, max = elementA.attributes.length; i < max; i++) { - const attributeA = elementA.attributes[i]; - const attributeB = elementB.attributes.getNamedItemNS( - attributeA.namespaceURI, + for (let i = 0, max = elementA[PropertySymbol.attributes].length; i < max; i++) { + const attributeA = elementA[PropertySymbol.attributes][i]; + const attributeB = elementB[PropertySymbol.attributes].getNamedItemNS( + attributeA[PropertySymbol.namespaceURI], attributeA.localName ); - if (!attributeB || attributeB.value !== attributeA.value) { + if (!attributeB || attributeB[PropertySymbol.value] !== attributeA[PropertySymbol.value]) { return false; } } @@ -420,11 +433,11 @@ export default class NodeUtility { * @param nodeB Node B. */ public static isEqualNode(nodeA: INode, nodeB: INode): boolean { - if (nodeA.nodeType !== nodeB.nodeType) { + if (nodeA[PropertySymbol.nodeType] !== nodeB[PropertySymbol.nodeType]) { return false; } - switch (nodeA.nodeType) { + switch (nodeA[PropertySymbol.nodeType]) { case NodeTypeEnum.documentTypeNode: const documentTypeA = nodeA; const documentTypeB = nodeB; @@ -442,10 +455,10 @@ export default class NodeUtility { const elementB = nodeB; if ( - elementA.namespaceURI !== elementB.namespaceURI || - elementA.prefix !== elementB.prefix || + elementA[PropertySymbol.namespaceURI] !== elementB[PropertySymbol.namespaceURI] || + elementA[PropertySymbol.prefix] !== elementB[PropertySymbol.prefix] || elementA.localName !== elementB.localName || - elementA.attributes.length !== elementB.attributes.length + elementA[PropertySymbol.attributes].length !== elementB[PropertySymbol.attributes].length ) { return false; } @@ -455,9 +468,9 @@ export default class NodeUtility { const attributeB = nodeB; if ( - attributeA.namespaceURI !== attributeB.namespaceURI || + attributeA[PropertySymbol.namespaceURI] !== attributeB[PropertySymbol.namespaceURI] || attributeA.localName !== attributeB.localName || - attributeA.value !== attributeB.value + attributeA[PropertySymbol.value] !== attributeB[PropertySymbol.value] ) { return false; } @@ -486,19 +499,22 @@ export default class NodeUtility { } if ( - nodeA.nodeType === NodeTypeEnum.elementNode && + nodeA[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && !NodeUtility.attributeListsEqual(nodeA, nodeB) ) { return false; } - if ((nodeA)._childNodes.length !== (nodeB)._childNodes.length) { + if ( + (nodeA)[PropertySymbol.childNodes].length !== + (nodeB)[PropertySymbol.childNodes].length + ) { return false; } - for (let i = 0; i < (nodeA)._childNodes.length; i++) { - const childNodeA = (nodeA)._childNodes[i]; - const childNodeB = (nodeB)._childNodes[i]; + for (let i = 0; i < (nodeA)[PropertySymbol.childNodes].length; i++) { + const childNodeA = (nodeA)[PropertySymbol.childNodes][i]; + const childNodeB = (nodeB)[PropertySymbol.childNodes][i]; if (!NodeUtility.isEqualNode(childNodeA, childNodeB)) { return false; diff --git a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts index 1e6ecc215..dfae298c6 100644 --- a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts +++ b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts @@ -1,4 +1,5 @@ import XMLParser from '../../xml-parser/XMLParser.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import IDocumentFragment from '../document-fragment/IDocumentFragment.js'; import IDocument from '../document/IDocument.js'; import IElement from '../element/IElement.js'; @@ -23,7 +24,9 @@ export default class ParentNodeUtility { ): void { for (const node of nodes) { if (typeof node === 'string') { - XMLParser.parse(parentNode.ownerDocument, node, { rootNode: parentNode }); + XMLParser.parse(parentNode[PropertySymbol.ownerDocument], node, { + rootNode: parentNode + }); } else { parentNode.appendChild(node); } @@ -44,8 +47,8 @@ export default class ParentNodeUtility { for (const node of nodes) { if (typeof node === 'string') { const newChildNodes = (( - XMLParser.parse(parentNode.ownerDocument, node) - ))._childNodes.slice(); + XMLParser.parse(parentNode[PropertySymbol.ownerDocument], node) + ))[PropertySymbol.childNodes].slice(); for (const newChildNode of newChildNodes) { parentNode.insertBefore(newChildNode, firstChild); } @@ -65,7 +68,7 @@ export default class ParentNodeUtility { parentNode: IElement | IDocument | IDocumentFragment, ...nodes: (string | INode)[] ): void { - for (const node of (parentNode)._childNodes.slice()) { + for (const node of (parentNode)[PropertySymbol.childNodes].slice()) { parentNode.removeChild(node); } @@ -84,7 +87,7 @@ export default class ParentNodeUtility { ): IHTMLCollection { let matches = new HTMLCollection(); - for (const child of (parentNode)._children) { + for (const child of (parentNode)[PropertySymbol.children]) { if (child.className.split(' ').includes(className)) { matches.push(child); } @@ -111,8 +114,8 @@ export default class ParentNodeUtility { const includeAll = tagName === '*'; let matches = new HTMLCollection(); - for (const child of (parentNode)._children) { - if (includeAll || child.tagName === upperTagName) { + for (const child of (parentNode)[PropertySymbol.children]) { + if (includeAll || child[PropertySymbol.tagName] === upperTagName) { matches.push(child); } matches = >( @@ -140,8 +143,11 @@ export default class ParentNodeUtility { const includeAll = tagName === '*'; let matches = new HTMLCollection(); - for (const child of (parentNode)._children) { - if ((includeAll || child.tagName === upperTagName) && child.namespaceURI === namespaceURI) { + for (const child of (parentNode)[PropertySymbol.children]) { + if ( + (includeAll || child[PropertySymbol.tagName] === upperTagName) && + child[PropertySymbol.namespaceURI] === namespaceURI + ) { matches.push(child); } matches = >( @@ -166,8 +172,8 @@ export default class ParentNodeUtility { ): IElement { const upperTagName = tagName.toUpperCase(); - for (const child of (parentNode)._children) { - if (child.tagName === upperTagName) { + for (const child of (parentNode)[PropertySymbol.children]) { + if (child[PropertySymbol.tagName] === upperTagName) { return child; } const match = this.getElementByTagName(child, tagName); @@ -191,7 +197,7 @@ export default class ParentNodeUtility { id: string ): IElement { id = String(id); - for (const child of (parentNode)._children) { + for (const child of (parentNode)[PropertySymbol.children]) { if (child.id === id) { return child; } diff --git a/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts b/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts index 9aac25dfd..c7de93cfd 100644 --- a/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts +++ b/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts @@ -1,6 +1,7 @@ import IProcessingInstruction from './IProcessingInstruction.js'; import CharacterData from '../character-data/CharacterData.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; /** * Processing instruction node interface. @@ -8,6 +9,15 @@ import NodeTypeEnum from '../node/NodeTypeEnum.js'; * Reference: https://developer.mozilla.org/en-US/docs/Web/API/ProcessingInstruction. */ export default class ProcessingInstruction extends CharacterData implements IProcessingInstruction { - public readonly nodeType = NodeTypeEnum.processingInstructionNode; - public target: string; + public [PropertySymbol.nodeType] = NodeTypeEnum.processingInstructionNode; + public [PropertySymbol.target]: string; + + /** + * Returns target. + * + * @returns Target. + */ + public get target(): string { + return this[PropertySymbol.target]; + } } diff --git a/packages/happy-dom/src/nodes/shadow-root/IShadowRoot.ts b/packages/happy-dom/src/nodes/shadow-root/IShadowRoot.ts index ccc42a528..68bb5be25 100644 --- a/packages/happy-dom/src/nodes/shadow-root/IShadowRoot.ts +++ b/packages/happy-dom/src/nodes/shadow-root/IShadowRoot.ts @@ -1,6 +1,7 @@ import IDocumentFragment from '../document-fragment/IDocumentFragment.js'; import IElement from '../element/IElement.js'; import Event from '../../event/Event.js'; +import { CSSStyleSheet } from '../../index.js'; /** * ShadowRoot. @@ -9,6 +10,7 @@ export default interface IShadowRoot extends IDocumentFragment { mode: string; innerHTML: string; host: IElement; + adoptedStyleSheets: CSSStyleSheet[]; readonly activeElement: IElement | null; // Events diff --git a/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts b/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts index 279bfc0c7..b419f11b3 100644 --- a/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts +++ b/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts @@ -1,4 +1,5 @@ import DocumentFragment from '../document-fragment/DocumentFragment.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import XMLParser from '../../xml-parser/XMLParser.js'; import XMLSerializer from '../../xml-serializer/XMLSerializer.js'; import IElement from '../element/IElement.js'; @@ -11,13 +12,32 @@ import Event from '../../event/Event.js'; * ShadowRoot. */ export default class ShadowRoot extends DocumentFragment implements IShadowRoot { - public readonly mode = 'open'; - public readonly host: IElement = null; - public adoptedStyleSheets: CSSStyleSheet[] = []; - // Events public onslotchange: (event: Event) => void | null = null; + // Internal properties + public [PropertySymbol.adoptedStyleSheets]: CSSStyleSheet[] = []; + public [PropertySymbol.mode] = 'open'; + public [PropertySymbol.host]: IElement | null = null; + + /** + * Returns mode. + * + * @returns Mode. + */ + public get mode(): string { + return this[PropertySymbol.mode]; + } + + /** + * Returns host. + * + * @returns Host. + */ + public get host(): IElement { + return this[PropertySymbol.host]; + } + /** * Returns inner HTML. * @@ -28,7 +48,7 @@ export default class ShadowRoot extends DocumentFragment implements IShadowRoot escapeEntities: false }); let xml = ''; - for (const node of this._childNodes) { + for (const node of this[PropertySymbol.childNodes]) { xml += xmlSerializer.serializeToString(node); } return xml; @@ -40,11 +60,29 @@ export default class ShadowRoot extends DocumentFragment implements IShadowRoot * @param html HTML. */ public set innerHTML(html: string) { - for (const child of this._childNodes.slice()) { + for (const child of this[PropertySymbol.childNodes].slice()) { this.removeChild(child); } - XMLParser.parse(this.ownerDocument, html, { rootNode: this }); + XMLParser.parse(this[PropertySymbol.ownerDocument], html, { rootNode: this }); + } + + /** + * Returns adopted style sheets. + * + * @returns Adopted style sheets. + */ + public get adoptedStyleSheets(): CSSStyleSheet[] { + return this[PropertySymbol.adoptedStyleSheets]; + } + + /** + * Sets adopted style sheets. + * + * @param value Adopted style sheets. + */ + public set adoptedStyleSheets(value: CSSStyleSheet[]) { + this[PropertySymbol.adoptedStyleSheets] = value; } /** @@ -53,8 +91,13 @@ export default class ShadowRoot extends DocumentFragment implements IShadowRoot * @returns Active element. */ public get activeElement(): IHTMLElement | null { - const activeElement: IHTMLElement = this.ownerDocument['_activeElement']; - if (activeElement && activeElement.isConnected && activeElement.getRootNode() === this) { + const activeElement: IHTMLElement = + this[PropertySymbol.ownerDocument][PropertySymbol.activeElement]; + if ( + activeElement && + activeElement[PropertySymbol.isConnected] && + activeElement.getRootNode() === this + ) { return activeElement; } return null; @@ -78,7 +121,7 @@ export default class ShadowRoot extends DocumentFragment implements IShadowRoot */ public cloneNode(deep = false): IShadowRoot { const clone = super.cloneNode(deep); - (clone.mode) = this.mode; + clone[PropertySymbol.mode] = this.mode; return clone; } } diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts index 61fa568e3..12d1f33e8 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts @@ -1,4 +1,5 @@ import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import Element from '../element/Element.js'; import ISVGElement from './ISVGElement.js'; import ISVGSVGElement from './ISVGSVGElement.js'; @@ -15,8 +16,6 @@ import SVGElementNamedNodeMap from './SVGElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/SVGElement. */ export default class SVGElement extends Element implements ISVGElement { - public override readonly attributes: INamedNodeMap = new SVGElementNamedNodeMap(this); - // Events public onabort: (event: Event) => void | null = null; public onerror: (event: Event) => void | null = null; @@ -25,9 +24,12 @@ export default class SVGElement extends Element implements ISVGElement { public onscroll: (event: Event) => void | null = null; public onunload: (event: Event) => void | null = null; + // Internal properties + public override [PropertySymbol.attributes]: INamedNodeMap = new SVGElementNamedNodeMap(this); + public [PropertySymbol.style]: CSSStyleDeclaration | null = null; + // Private properties - public _style: CSSStyleDeclaration = null; - private _dataset: Dataset = null; + #dataset: Dataset = null; /** * Returns viewport. @@ -44,13 +46,13 @@ export default class SVGElement extends Element implements ISVGElement { * @returns Element. */ public get ownerSVGElement(): ISVGSVGElement { - let parent = this.parentNode; + let parent = this[PropertySymbol.parentNode]; while (parent) { if (parent['tagName'] === 'SVG') { return parent; } - parent = parent.parentNode; + parent = parent[PropertySymbol.parentNode]; } return null; } @@ -61,7 +63,7 @@ export default class SVGElement extends Element implements ISVGElement { * @returns Data set. */ public get dataset(): { [key: string]: string } { - return (this._dataset ??= new Dataset(this)).proxy; + return (this.#dataset ??= new Dataset(this)).proxy; } /** @@ -70,10 +72,10 @@ export default class SVGElement extends Element implements ISVGElement { * @returns Style. */ public get style(): CSSStyleDeclaration { - if (!this._style) { - this._style = new CSSStyleDeclaration(this); + if (!this[PropertySymbol.style]) { + this[PropertySymbol.style] = new CSSStyleDeclaration(this); } - return this._style; + return this[PropertySymbol.style]; } /** diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts index e7573ae27..e2fd4fcc2 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts @@ -1,4 +1,5 @@ import IAttr from '../attr/IAttr.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import ElementNamedNodeMap from '../element/ElementNamedNodeMap.js'; import SVGElement from './SVGElement.js'; @@ -8,7 +9,7 @@ import SVGElement from './SVGElement.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap */ export default class SVGElementNamedNodeMap extends ElementNamedNodeMap { - protected _ownerElement: SVGElement; + protected [PropertySymbol.ownerElement]: SVGElement; /** * @override @@ -16,8 +17,11 @@ export default class SVGElementNamedNodeMap extends ElementNamedNodeMap { public override setNamedItem(item: IAttr): IAttr | null { const replacedItem = super.setNamedItem(item); - if (item.name === 'style' && this._ownerElement._style) { - this._ownerElement._style.cssText = item.value; + if ( + item[PropertySymbol.name] === 'style' && + this[PropertySymbol.ownerElement][PropertySymbol.style] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = item[PropertySymbol.value]; } return replacedItem || null; @@ -26,11 +30,15 @@ export default class SVGElementNamedNodeMap extends ElementNamedNodeMap { /** * @override */ - public override _removeNamedItem(name: string): IAttr | null { - const removedItem = super._removeNamedItem(name); + public override [PropertySymbol.removeNamedItem](name: string): IAttr | null { + const removedItem = super[PropertySymbol.removeNamedItem](name); - if (removedItem && removedItem.name === 'style' && this._ownerElement._style) { - this._ownerElement._style.cssText = ''; + if ( + removedItem && + removedItem[PropertySymbol.name] === 'style' && + this[PropertySymbol.ownerElement][PropertySymbol.style] + ) { + this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = ''; } return removedItem; diff --git a/packages/happy-dom/src/nodes/text/Text.ts b/packages/happy-dom/src/nodes/text/Text.ts index 46b4605da..acb5ba0b3 100644 --- a/packages/happy-dom/src/nodes/text/Text.ts +++ b/packages/happy-dom/src/nodes/text/Text.ts @@ -1,16 +1,17 @@ -import Node from '../node/Node.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; import CharacterData from '../character-data/CharacterData.js'; import IText from './IText.js'; import DOMException from '../../exception/DOMException.js'; import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; import INode from '../node/INode.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; /** * Text node. */ export default class Text extends CharacterData implements IText { - public readonly nodeType = Node.TEXT_NODE; + public override [PropertySymbol.nodeType] = NodeTypeEnum.textNode; /** * Node name. @@ -25,7 +26,7 @@ export default class Text extends CharacterData implements IText { * @override */ public override get data(): string { - return this._data; + return this[PropertySymbol.data]; } /** @@ -34,8 +35,8 @@ export default class Text extends CharacterData implements IText { public override set data(data: string) { super.data = data; - if (this._textAreaNode) { - (this._textAreaNode)._resetSelection(); + if (this[PropertySymbol.textAreaNode]) { + (this[PropertySymbol.textAreaNode])[PropertySymbol.resetSelection](); } } @@ -47,7 +48,7 @@ export default class Text extends CharacterData implements IText { * @returns New text node. */ public splitText(offset: number): IText { - const length = this._data.length; + const length = this[PropertySymbol.data].length; if (offset < 0 || offset > length) { throw new DOMException( @@ -58,10 +59,10 @@ export default class Text extends CharacterData implements IText { const count = length - offset; const newData = this.substringData(offset, count); - const newNode = this.ownerDocument.createTextNode(newData); + const newNode = this[PropertySymbol.ownerDocument].createTextNode(newData); - if (this.parentNode !== null) { - this.parentNode.insertBefore(newNode, this.nextSibling); + if (this[PropertySymbol.parentNode] !== null) { + this[PropertySymbol.parentNode].insertBefore(newNode, this.nextSibling); } this.replaceData(offset, count, ''); @@ -92,17 +93,17 @@ export default class Text extends CharacterData implements IText { /** * @override */ - public override _connectToNode(parentNode: INode = null): void { - const oldTextAreaNode = this._textAreaNode; + public override [PropertySymbol.connectToNode](parentNode: INode = null): void { + const oldTextAreaNode = this[PropertySymbol.textAreaNode]; - super._connectToNode(parentNode); + super[PropertySymbol.connectToNode](parentNode); - if (oldTextAreaNode !== this._textAreaNode) { + if (oldTextAreaNode !== this[PropertySymbol.textAreaNode]) { if (oldTextAreaNode) { - oldTextAreaNode._resetSelection(); + oldTextAreaNode[PropertySymbol.resetSelection](); } - if (this._textAreaNode) { - (this._textAreaNode)._resetSelection(); + if (this[PropertySymbol.textAreaNode]) { + (this[PropertySymbol.textAreaNode])[PropertySymbol.resetSelection](); } } } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index e0bd1f163..e7ad2df85 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -1,4 +1,5 @@ import IElement from '../nodes/element/IElement.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import INodeList from '../nodes/node/INodeList.js'; import SelectorItem from './SelectorItem.js'; import NodeList from '../nodes/node/NodeList.js'; @@ -47,9 +48,9 @@ export default class QuerySelector { for (const items of groups) { matches = matches.concat( - node.nodeType === NodeTypeEnum.elementNode + node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? this.findAll(node, [node], items) - : this.findAll(null, (node)._children, items) + : this.findAll(null, (node)[PropertySymbol.children], items) ); } @@ -91,9 +92,9 @@ export default class QuerySelector { for (const items of SelectorParser.getSelectorGroups(selector)) { const match = - node.nodeType === NodeTypeEnum.elementNode + node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? this.findFirst(node, [node], items) - : this.findFirst(null, (node)._children, items); + : this.findFirst(null, (node)[PropertySymbol.children], items); if (match) { return match; @@ -251,7 +252,7 @@ export default class QuerySelector { matched = matched.concat( this.findAll( rootElement, - (child)._children, + (child)[PropertySymbol.children], selectorItems.slice(1), position ) @@ -263,10 +264,15 @@ export default class QuerySelector { if ( selectorItem.combinator === SelectorCombinatorEnum.descendant && - (child)._children.length + (child)[PropertySymbol.children].length ) { matched = matched.concat( - this.findAll(rootElement, (child)._children, selectorItems, position) + this.findAll( + rootElement, + (child)[PropertySymbol.children], + selectorItems, + position + ) ); } } @@ -314,7 +320,7 @@ export default class QuerySelector { case SelectorCombinatorEnum.child: const match = this.findFirst( rootElement, - (child)._children, + (child)[PropertySymbol.children], selectorItems.slice(1) ); if (match) { @@ -327,9 +333,13 @@ export default class QuerySelector { if ( selectorItem.combinator === SelectorCombinatorEnum.descendant && - (child)._children.length + (child)[PropertySymbol.children].length ) { - const match = this.findFirst(rootElement, (child)._children, selectorItems); + const match = this.findFirst( + rootElement, + (child)[PropertySymbol.children], + selectorItems + ); if (match) { return match; diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 6b79a3d99..2038090af 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -1,4 +1,5 @@ import DOMException from '../exception/DOMException.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IElement from '../nodes/element/IElement.js'; import Element from '../nodes/element/Element.js'; import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement.js'; @@ -64,7 +65,7 @@ export default class SelectorItem { // Tag name match if (this.tagName) { - if (this.tagName !== '*' && this.tagName !== element.tagName) { + if (this.tagName !== '*' && this.tagName !== element[PropertySymbol.tagName]) { return null; } priorityWeight += 1; @@ -111,8 +112,10 @@ export default class SelectorItem { * @returns Result. */ private matchPsuedo(element: IElement): boolean { - const parent = element.parentNode; - const parentChildren = element.parentNode ? (element.parentNode)._children : []; + const parent = element[PropertySymbol.parentNode]; + const parentChildren = element[PropertySymbol.parentNode] + ? (element[PropertySymbol.parentNode])[PropertySymbol.children] + : []; if (!this.pseudos) { return true; @@ -158,7 +161,7 @@ export default class SelectorItem { return parentChildren.length === 1 && parentChildren[0] === element; case 'first-of-type': for (const child of parentChildren) { - if (child.tagName === element.tagName) { + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { return child === element; } } @@ -166,7 +169,7 @@ export default class SelectorItem { case 'last-of-type': for (let i = parentChildren.length - 1; i >= 0; i--) { const child = parentChildren[i]; - if (child.tagName === element.tagName) { + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { return child === element; } } @@ -174,7 +177,7 @@ export default class SelectorItem { case 'only-of-type': let isFound = false; for (const child of parentChildren) { - if (child.tagName === element.tagName) { + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { if (isFound || child !== element) { return false; } @@ -183,11 +186,13 @@ export default class SelectorItem { } return isFound; case 'checked': - return element.tagName === 'INPUT' && (element).checked; + return ( + element[PropertySymbol.tagName] === 'INPUT' && (element).checked + ); case 'empty': - return !(element)._children.length; + return !(element)[PropertySymbol.children].length; case 'root': - return element.tagName === 'HTML'; + return element[PropertySymbol.tagName] === 'HTML'; case 'not': return !psuedo.selectorItem.match(element); case 'nth-child': @@ -196,11 +201,11 @@ export default class SelectorItem { : parentChildren.indexOf(element); return nthChildIndex !== -1 && psuedo.nthFunction(nthChildIndex + 1); case 'nth-of-type': - if (!element.parentNode) { + if (!element[PropertySymbol.parentNode]) { return false; } const nthOfTypeIndex = parentChildren - .filter((child) => child.tagName === element.tagName) + .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) .indexOf(element); return nthOfTypeIndex !== -1 && psuedo.nthFunction(nthOfTypeIndex + 1); case 'nth-last-child': @@ -213,7 +218,7 @@ export default class SelectorItem { return nthLastChildIndex !== -1 && psuedo.nthFunction(nthLastChildIndex + 1); case 'nth-last-of-type': const nthLastOfTypeIndex = parentChildren - .filter((child) => child.tagName === element.tagName) + .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) .reverse() .indexOf(element); return nthLastOfTypeIndex !== -1 && psuedo.nthFunction(nthLastOfTypeIndex + 1); @@ -237,7 +242,9 @@ export default class SelectorItem { let priorityWeight = 0; for (const attribute of this.attributes) { - const elementAttribute = (element).attributes.getNamedItem(attribute.name); + const elementAttribute = (element)[PropertySymbol.attributes].getNamedItem( + attribute.name + ); if (!elementAttribute) { return null; @@ -247,9 +254,9 @@ export default class SelectorItem { if ( attribute.value !== null && - (elementAttribute.value === null || - (attribute.regExp && !attribute.regExp.test(elementAttribute.value)) || - (!attribute.regExp && attribute.value !== elementAttribute.value)) + (elementAttribute[PropertySymbol.value] === null || + (attribute.regExp && !attribute.regExp.test(elementAttribute[PropertySymbol.value])) || + (!attribute.regExp && attribute.value !== elementAttribute[PropertySymbol.value])) ) { return null; } diff --git a/packages/happy-dom/src/range/Range.ts b/packages/happy-dom/src/range/Range.ts index 9bdb9ddde..3d984de6f 100644 --- a/packages/happy-dom/src/range/Range.ts +++ b/packages/happy-dom/src/range/Range.ts @@ -1,4 +1,5 @@ import INode from '../nodes/node/INode.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import Node from '../nodes/node/Node.js'; import IDocument from '../nodes/document/IDocument.js'; import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment.js'; @@ -15,6 +16,7 @@ import IText from '../nodes/text/IText.js'; import DOMRectListFactory from '../nodes/element/DOMRectListFactory.js'; import IDOMRectList from '../nodes/element/IDOMRectList.js'; import IRangeBoundaryPoint from './IRangeBoundaryPoint.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; /** * Range. @@ -26,8 +28,6 @@ import IRangeBoundaryPoint from './IRangeBoundaryPoint.js'; * https://developer.mozilla.org/en-US/docs/Web/API/Range. */ export default class Range { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; public static readonly END_TO_END: number = RangeHowEnum.endToEnd; public static readonly END_TO_START: number = RangeHowEnum.endToStart; public static readonly START_TO_END: number = RangeHowEnum.startToEnd; @@ -36,17 +36,21 @@ export default class Range { public readonly END_TO_START: number = RangeHowEnum.endToStart; public readonly START_TO_END: number = RangeHowEnum.startToEnd; public readonly START_TO_START: number = RangeHowEnum.startToStart; - public readonly _ownerDocument: IDocument = null; - public _start: IRangeBoundaryPoint = null; - public _end: IRangeBoundaryPoint = null; + public [PropertySymbol.start]: IRangeBoundaryPoint | null = null; + public [PropertySymbol.end]: IRangeBoundaryPoint | null = null; + #window: IBrowserWindow; + public readonly [PropertySymbol.ownerDocument]: IDocument; /** * Constructor. + * + * @param window Window. */ - constructor() { - this._ownerDocument = (this.constructor)._ownerDocument; - this._start = { node: this._ownerDocument, offset: 0 }; - this._end = { node: this._ownerDocument, offset: 0 }; + constructor(window: IBrowserWindow) { + this.#window = window; + this[PropertySymbol.ownerDocument] = window.document; + this[PropertySymbol.start] = { node: window.document, offset: 0 }; + this[PropertySymbol.end] = { node: window.document, offset: 0 }; } /** @@ -56,7 +60,7 @@ export default class Range { * @returns Start container. */ public get startContainer(): INode { - return this._start.node; + return this[PropertySymbol.start].node; } /** @@ -66,7 +70,7 @@ export default class Range { * @returns End container. */ public get endContainer(): INode { - return this._end.node; + return this[PropertySymbol.end].node; } /** @@ -76,14 +80,14 @@ export default class Range { * @returns Start offset. */ public get startOffset(): number { - if (this._start.offset > 0) { - const length = NodeUtility.getNodeLength(this._start.node); - if (this._start.offset > length) { - this._start.offset = length; + if (this[PropertySymbol.start].offset > 0) { + const length = NodeUtility.getNodeLength(this[PropertySymbol.start].node); + if (this[PropertySymbol.start].offset > length) { + this[PropertySymbol.start].offset = length; } } - return this._start.offset; + return this[PropertySymbol.start].offset; } /** @@ -93,14 +97,14 @@ export default class Range { * @returns End offset. */ public get endOffset(): number { - if (this._end.offset > 0) { - const length = NodeUtility.getNodeLength(this._end.node); - if (this._end.offset > length) { - this._end.offset = length; + if (this[PropertySymbol.end].offset > 0) { + const length = NodeUtility.getNodeLength(this[PropertySymbol.end].node); + if (this[PropertySymbol.end].offset > length) { + this[PropertySymbol.end].offset = length; } } - return this._end.offset; + return this[PropertySymbol.end].offset; } /** @@ -110,7 +114,10 @@ export default class Range { * @returns Collapsed. */ public get collapsed(): boolean { - return this._start.node === this._end.node && this.startOffset === this.endOffset; + return ( + this[PropertySymbol.start].node === this[PropertySymbol.end].node && + this.startOffset === this.endOffset + ); } /** @@ -120,13 +127,13 @@ export default class Range { * @returns Node. */ public get commonAncestorContainer(): INode { - let container = this._start.node; + let container = this[PropertySymbol.start].node; while (container) { - if (NodeUtility.isInclusiveAncestor(container, this._end.node)) { + if (NodeUtility.isInclusiveAncestor(container, this[PropertySymbol.end].node)) { return container; } - container = container.parentNode; + container = container[PropertySymbol.parentNode]; } return null; @@ -140,9 +147,9 @@ export default class Range { */ public collapse(toStart = false): void { if (toStart) { - this._end = Object.assign({}, this._start); + this[PropertySymbol.end] = Object.assign({}, this[PropertySymbol.start]); } else { - this._start = Object.assign({}, this._end); + this[PropertySymbol.start] = Object.assign({}, this[PropertySymbol.end]); } } @@ -167,7 +174,7 @@ export default class Range { ); } - if (this._ownerDocument !== sourceRange._ownerDocument) { + if (this[PropertySymbol.ownerDocument] !== sourceRange[PropertySymbol.ownerDocument]) { throw new DOMException( `The two Ranges are not in the same tree.`, DOMExceptionNameEnum.wrongDocumentError @@ -185,27 +192,27 @@ export default class Range { switch (how) { case RangeHowEnum.startToStart: - thisPoint.node = this._start.node; + thisPoint.node = this[PropertySymbol.start].node; thisPoint.offset = this.startOffset; - sourcePoint.node = sourceRange._start.node; + sourcePoint.node = sourceRange[PropertySymbol.start].node; sourcePoint.offset = sourceRange.startOffset; break; case RangeHowEnum.startToEnd: - thisPoint.node = this._end.node; + thisPoint.node = this[PropertySymbol.end].node; thisPoint.offset = this.endOffset; - sourcePoint.node = sourceRange._start.node; + sourcePoint.node = sourceRange[PropertySymbol.start].node; sourcePoint.offset = sourceRange.startOffset; break; case RangeHowEnum.endToEnd: - thisPoint.node = this._end.node; + thisPoint.node = this[PropertySymbol.end].node; thisPoint.offset = this.endOffset; - sourcePoint.node = sourceRange._end.node; + sourcePoint.node = sourceRange[PropertySymbol.end].node; sourcePoint.offset = sourceRange.endOffset; break; case RangeHowEnum.endToStart: - thisPoint.node = this._start.node; + thisPoint.node = this[PropertySymbol.start].node; thisPoint.offset = this.startOffset; - sourcePoint.node = sourceRange._end.node; + sourcePoint.node = sourceRange[PropertySymbol.end].node; sourcePoint.offset = sourceRange.endOffset; break; } @@ -222,7 +229,7 @@ export default class Range { * @returns -1,0, or 1. */ public comparePoint(node: INode, offset): number { - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this[PropertySymbol.ownerDocument]) { throw new DOMException( `The two Ranges are not in the same tree.`, DOMExceptionNameEnum.wrongDocumentError @@ -235,14 +242,14 @@ export default class Range { if ( RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._start.node, + node: this[PropertySymbol.start].node, offset: this.startOffset }) === -1 ) { return -1; } else if ( RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._end.node, + node: this[PropertySymbol.end].node, offset: this.endOffset }) === 1 ) { @@ -259,7 +266,7 @@ export default class Range { * @returns Document fragment. */ public cloneContents(): IDocumentFragment { - const fragment = this._ownerDocument.createDocumentFragment(); + const fragment = this[PropertySymbol.ownerDocument].createDocumentFragment(); const startOffset = this.startOffset; const endOffset = this.endOffset; @@ -268,24 +275,30 @@ export default class Range { } if ( - this._start.node === this._end.node && - (this._start.node.nodeType === NodeTypeEnum.textNode || - this._start.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._start.node.nodeType === NodeTypeEnum.commentNode) + this[PropertySymbol.start].node === this[PropertySymbol.end].node && + (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = (this._start.node).cloneNode(false); - clone['_data'] = clone.substringData(startOffset, endOffset - startOffset); + const clone = (this[PropertySymbol.start].node).cloneNode(false); + clone[PropertySymbol.data] = clone.substringData(startOffset, endOffset - startOffset); fragment.appendChild(clone); return fragment; } - let commonAncestor = this._start.node; - while (!NodeUtility.isInclusiveAncestor(commonAncestor, this._end.node)) { - commonAncestor = commonAncestor.parentNode; + let commonAncestor = this[PropertySymbol.start].node; + while (!NodeUtility.isInclusiveAncestor(commonAncestor, this[PropertySymbol.end].node)) { + commonAncestor = commonAncestor[PropertySymbol.parentNode]; } let firstPartialContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(this._start.node, this._end.node)) { + if ( + !NodeUtility.isInclusiveAncestor( + this[PropertySymbol.start].node, + this[PropertySymbol.end].node + ) + ) { let candidate = commonAncestor.firstChild; while (!firstPartialContainedChild) { if (RangeUtility.isPartiallyContained(candidate, this)) { @@ -297,7 +310,12 @@ export default class Range { } let lastPartiallyContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(this._end.node, this._start.node)) { + if ( + !NodeUtility.isInclusiveAncestor( + this[PropertySymbol.end].node, + this[PropertySymbol.start].node + ) + ) { let candidate = commonAncestor.lastChild; while (!lastPartiallyContainedChild) { if (RangeUtility.isPartiallyContained(candidate, this)) { @@ -310,9 +328,9 @@ export default class Range { const containedChildren = []; - for (const node of (commonAncestor)._childNodes) { + for (const node of (commonAncestor)[PropertySymbol.childNodes]) { if (RangeUtility.isContained(node, this)) { - if (node.nodeType === NodeTypeEnum.documentTypeNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( 'Invalid document type element.', DOMExceptionNameEnum.hierarchyRequestError @@ -324,14 +342,15 @@ export default class Range { if ( firstPartialContainedChild !== null && - (firstPartialContainedChild.nodeType === NodeTypeEnum.textNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.commentNode) + (firstPartialContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + firstPartialContainedChild[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + firstPartialContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = (this._start.node).cloneNode(false); - clone['_data'] = clone.substringData( + const clone = (this[PropertySymbol.start].node).cloneNode(false); + clone[PropertySymbol.data] = clone.substringData( startOffset, - NodeUtility.getNodeLength(this._start.node) - startOffset + NodeUtility.getNodeLength(this[PropertySymbol.start].node) - startOffset ); fragment.appendChild(clone); @@ -339,11 +358,11 @@ export default class Range { const clone = firstPartialContainedChild.cloneNode(); fragment.appendChild(clone); - const subRange = new Range(); - subRange._start.node = this._start.node; - subRange._start.offset = startOffset; - subRange._end.node = firstPartialContainedChild; - subRange._end.offset = NodeUtility.getNodeLength(firstPartialContainedChild); + const subRange = new this.#window.Range(); + subRange[PropertySymbol.start].node = this[PropertySymbol.start].node; + subRange[PropertySymbol.start].offset = startOffset; + subRange[PropertySymbol.end].node = firstPartialContainedChild; + subRange[PropertySymbol.end].offset = NodeUtility.getNodeLength(firstPartialContainedChild); const subDocumentFragment = subRange.cloneContents(); clone.appendChild(subDocumentFragment); @@ -356,23 +375,24 @@ export default class Range { if ( lastPartiallyContainedChild !== null && - (lastPartiallyContainedChild.nodeType === NodeTypeEnum.textNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.commentNode) + (lastPartiallyContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + lastPartiallyContainedChild[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + lastPartiallyContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = (this._end.node).cloneNode(false); - clone['_data'] = clone.substringData(0, endOffset); + const clone = (this[PropertySymbol.end].node).cloneNode(false); + clone[PropertySymbol.data] = clone.substringData(0, endOffset); fragment.appendChild(clone); } else if (lastPartiallyContainedChild !== null) { const clone = lastPartiallyContainedChild.cloneNode(false); fragment.appendChild(clone); - const subRange = new Range(); - subRange._start.node = lastPartiallyContainedChild; - subRange._start.offset = 0; - subRange._end.node = this._end.node; - subRange._end.offset = endOffset; + const subRange = new this.#window.Range(); + subRange[PropertySymbol.start].node = lastPartiallyContainedChild; + subRange[PropertySymbol.start].offset = 0; + subRange[PropertySymbol.end].node = this[PropertySymbol.end].node; + subRange[PropertySymbol.end].offset = endOffset; const subFragment = subRange.cloneContents(); clone.appendChild(subFragment); @@ -388,12 +408,12 @@ export default class Range { * @returns Range. */ public cloneRange(): Range { - const clone = new Range(); + const clone = new this.#window.Range(); - clone._start.node = this._start.node; - clone._start.offset = this._start.offset; - clone._end.node = this._end.node; - clone._end.offset = this._end.offset; + clone[PropertySymbol.start].node = this[PropertySymbol.start].node; + clone[PropertySymbol.start].offset = this[PropertySymbol.start].offset; + clone[PropertySymbol.end].node = this[PropertySymbol.end].node; + clone[PropertySymbol.end].offset = this[PropertySymbol.end].offset; return clone; } @@ -407,7 +427,7 @@ export default class Range { */ public createContextualFragment(tagString: string): IDocumentFragment { // TODO: We only have support for HTML in the parser currently, so it is not necessary to check which context it is - return XMLParser.parse(this._ownerDocument, tagString); + return XMLParser.parse(this[PropertySymbol.ownerDocument], tagString); } /** @@ -424,22 +444,27 @@ export default class Range { } if ( - this._start.node === this._end.node && - (this._start.node.nodeType === NodeTypeEnum.textNode || - this._start.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._start.node.nodeType === NodeTypeEnum.commentNode) + this[PropertySymbol.start].node === this[PropertySymbol.end].node && + (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - (this._start.node).replaceData(startOffset, endOffset - startOffset, ''); + (this[PropertySymbol.start].node).replaceData( + startOffset, + endOffset - startOffset, + '' + ); return; } const nodesToRemove = []; - let currentNode = this._start.node; - const endNode = NodeUtility.nextDescendantNode(this._end.node); + let currentNode = this[PropertySymbol.start].node; + const endNode = NodeUtility.nextDescendantNode(this[PropertySymbol.end].node); while (currentNode && currentNode !== endNode) { if ( RangeUtility.isContained(currentNode, this) && - !RangeUtility.isContained(currentNode.parentNode, this) + !RangeUtility.isContained(currentNode[PropertySymbol.parentNode], this) ) { nodesToRemove.push(currentNode); } @@ -449,52 +474,65 @@ export default class Range { let newNode; let newOffset; - if (NodeUtility.isInclusiveAncestor(this._start.node, this._end.node)) { - newNode = this._start.node; + if ( + NodeUtility.isInclusiveAncestor( + this[PropertySymbol.start].node, + this[PropertySymbol.end].node + ) + ) { + newNode = this[PropertySymbol.start].node; newOffset = startOffset; } else { - let referenceNode = this._start.node; + let referenceNode = this[PropertySymbol.start].node; while ( referenceNode && - !NodeUtility.isInclusiveAncestor(referenceNode.parentNode, this._end.node) + !NodeUtility.isInclusiveAncestor( + referenceNode[PropertySymbol.parentNode], + this[PropertySymbol.end].node + ) ) { - referenceNode = referenceNode.parentNode; + referenceNode = referenceNode[PropertySymbol.parentNode]; } - newNode = referenceNode.parentNode; - newOffset = (referenceNode.parentNode)._childNodes.indexOf(referenceNode) + 1; + newNode = referenceNode[PropertySymbol.parentNode]; + newOffset = + (referenceNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + referenceNode + ) + 1; } if ( - this._start.node.nodeType === NodeTypeEnum.textNode || - this._start.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._start.node.nodeType === NodeTypeEnum.commentNode + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode ) { - (this._start.node).replaceData( + (this[PropertySymbol.start].node).replaceData( this.startOffset, - NodeUtility.getNodeLength(this._start.node) - this.startOffset, + NodeUtility.getNodeLength(this[PropertySymbol.start].node) - this.startOffset, '' ); } for (const node of nodesToRemove) { - const parent = node.parentNode; + const parent = node[PropertySymbol.parentNode]; parent.removeChild(node); } if ( - this._end.node.nodeType === NodeTypeEnum.textNode || - this._end.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._end.node.nodeType === NodeTypeEnum.commentNode + this[PropertySymbol.end].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + this[PropertySymbol.end].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.end].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode ) { - (this._end.node).replaceData(0, endOffset, ''); + (this[PropertySymbol.end].node).replaceData(0, endOffset, ''); } - this._start.node = newNode; - this._start.offset = newOffset; - this._end.node = newNode; - this._end.offset = newOffset; + this[PropertySymbol.start].node = newNode; + this[PropertySymbol.start].offset = newOffset; + this[PropertySymbol.end].node = newNode; + this[PropertySymbol.end].offset = newOffset; } /** @@ -513,7 +551,7 @@ export default class Range { * @returns Document fragment. */ public extractContents(): IDocumentFragment { - const fragment = this._ownerDocument.createDocumentFragment(); + const fragment = this[PropertySymbol.ownerDocument].createDocumentFragment(); const startOffset = this.startOffset; const endOffset = this.endOffset; @@ -522,28 +560,38 @@ export default class Range { } if ( - this._start.node === this._end.node && - (this._start.node.nodeType === NodeTypeEnum.textNode || - this._start.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._start.node.nodeType === NodeTypeEnum.commentNode) + this[PropertySymbol.start].node === this[PropertySymbol.end].node && + (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = this._start.node.cloneNode(false); - clone['_data'] = clone.substringData(startOffset, endOffset - startOffset); + const clone = this[PropertySymbol.start].node.cloneNode(false); + clone[PropertySymbol.data] = clone.substringData(startOffset, endOffset - startOffset); fragment.appendChild(clone); - (this._start.node).replaceData(startOffset, endOffset - startOffset, ''); + (this[PropertySymbol.start].node).replaceData( + startOffset, + endOffset - startOffset, + '' + ); return fragment; } - let commonAncestor = this._start.node; - while (!NodeUtility.isInclusiveAncestor(commonAncestor, this._end.node)) { - commonAncestor = commonAncestor.parentNode; + let commonAncestor = this[PropertySymbol.start].node; + while (!NodeUtility.isInclusiveAncestor(commonAncestor, this[PropertySymbol.end].node)) { + commonAncestor = commonAncestor[PropertySymbol.parentNode]; } let firstPartialContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(this._start.node, this._end.node)) { + if ( + !NodeUtility.isInclusiveAncestor( + this[PropertySymbol.start].node, + this[PropertySymbol.end].node + ) + ) { let candidate = commonAncestor.firstChild; while (!firstPartialContainedChild) { if (RangeUtility.isPartiallyContained(candidate, this)) { @@ -555,7 +603,12 @@ export default class Range { } let lastPartiallyContainedChild = null; - if (!NodeUtility.isInclusiveAncestor(this._end.node, this._start.node)) { + if ( + !NodeUtility.isInclusiveAncestor( + this[PropertySymbol.end].node, + this[PropertySymbol.start].node + ) + ) { let candidate = commonAncestor.lastChild; while (!lastPartiallyContainedChild) { if (RangeUtility.isPartiallyContained(candidate, this)) { @@ -568,9 +621,9 @@ export default class Range { const containedChildren = []; - for (const node of (commonAncestor)._childNodes) { + for (const node of (commonAncestor)[PropertySymbol.childNodes]) { if (RangeUtility.isContained(node, this)) { - if (node.nodeType === NodeTypeEnum.documentTypeNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( 'Invalid document type element.', DOMExceptionNameEnum.hierarchyRequestError @@ -582,51 +635,63 @@ export default class Range { let newNode; let newOffset; - if (NodeUtility.isInclusiveAncestor(this._start.node, this._end.node)) { - newNode = this._start.node; + if ( + NodeUtility.isInclusiveAncestor( + this[PropertySymbol.start].node, + this[PropertySymbol.end].node + ) + ) { + newNode = this[PropertySymbol.start].node; newOffset = startOffset; } else { - let referenceNode = this._start.node; + let referenceNode = this[PropertySymbol.start].node; while ( referenceNode && - !NodeUtility.isInclusiveAncestor(referenceNode.parentNode, this._end.node) + !NodeUtility.isInclusiveAncestor( + referenceNode[PropertySymbol.parentNode], + this[PropertySymbol.end].node + ) ) { - referenceNode = referenceNode.parentNode; + referenceNode = referenceNode[PropertySymbol.parentNode]; } - newNode = referenceNode.parentNode; - newOffset = (referenceNode.parentNode)._childNodes.indexOf(referenceNode) + 1; + newNode = referenceNode[PropertySymbol.parentNode]; + newOffset = + (referenceNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + referenceNode + ) + 1; } if ( firstPartialContainedChild !== null && - (firstPartialContainedChild.nodeType === NodeTypeEnum.textNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - firstPartialContainedChild.nodeType === NodeTypeEnum.commentNode) + (firstPartialContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + firstPartialContainedChild[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + firstPartialContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = this._start.node.cloneNode(false); - clone['_data'] = clone.substringData( + const clone = this[PropertySymbol.start].node.cloneNode(false); + clone[PropertySymbol.data] = clone.substringData( startOffset, - NodeUtility.getNodeLength(this._start.node) - startOffset + NodeUtility.getNodeLength(this[PropertySymbol.start].node) - startOffset ); fragment.appendChild(clone); - (this._start.node).replaceData( + (this[PropertySymbol.start].node).replaceData( startOffset, - NodeUtility.getNodeLength(this._start.node) - startOffset, + NodeUtility.getNodeLength(this[PropertySymbol.start].node) - startOffset, '' ); } else if (firstPartialContainedChild !== null) { const clone = firstPartialContainedChild.cloneNode(false); fragment.appendChild(clone); - const subRange = new Range(); - subRange._start.node = this._start.node; - subRange._start.offset = startOffset; - subRange._end.node = firstPartialContainedChild; - subRange._end.offset = NodeUtility.getNodeLength(firstPartialContainedChild); + const subRange = new this.#window.Range(); + subRange[PropertySymbol.start].node = this[PropertySymbol.start].node; + subRange[PropertySymbol.start].offset = startOffset; + subRange[PropertySymbol.end].node = firstPartialContainedChild; + subRange[PropertySymbol.end].offset = NodeUtility.getNodeLength(firstPartialContainedChild); const subFragment = subRange.extractContents(); clone.appendChild(subFragment); @@ -638,34 +703,35 @@ export default class Range { if ( lastPartiallyContainedChild !== null && - (lastPartiallyContainedChild.nodeType === NodeTypeEnum.textNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.processingInstructionNode || - lastPartiallyContainedChild.nodeType === NodeTypeEnum.commentNode) + (lastPartiallyContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.textNode || + lastPartiallyContainedChild[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + lastPartiallyContainedChild[PropertySymbol.nodeType] === NodeTypeEnum.commentNode) ) { - const clone = this._end.node.cloneNode(false); - clone['_data'] = clone.substringData(0, endOffset); + const clone = this[PropertySymbol.end].node.cloneNode(false); + clone[PropertySymbol.data] = clone.substringData(0, endOffset); fragment.appendChild(clone); - (this._end.node).replaceData(0, endOffset, ''); + (this[PropertySymbol.end].node).replaceData(0, endOffset, ''); } else if (lastPartiallyContainedChild !== null) { const clone = lastPartiallyContainedChild.cloneNode(false); fragment.appendChild(clone); - const subRange = new Range(); - subRange._start.node = lastPartiallyContainedChild; - subRange._start.offset = 0; - subRange._end.node = this._end.node; - subRange._end.offset = endOffset; + const subRange = new this.#window.Range(); + subRange[PropertySymbol.start].node = lastPartiallyContainedChild; + subRange[PropertySymbol.start].offset = 0; + subRange[PropertySymbol.end].node = this[PropertySymbol.end].node; + subRange[PropertySymbol.end].offset = endOffset; const subFragment = subRange.extractContents(); clone.appendChild(subFragment); } - this._start.node = newNode; - this._start.offset = newOffset; - this._end.node = newNode; - this._end.offset = newOffset; + this[PropertySymbol.start].node = newNode; + this[PropertySymbol.start].offset = newOffset; + this[PropertySymbol.end].node = newNode; + this[PropertySymbol.end].offset = newOffset; return fragment; } @@ -699,7 +765,7 @@ export default class Range { * @returns "true" if in range. */ public isPointInRange(node: INode, offset = 0): boolean { - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this[PropertySymbol.ownerDocument]) { return false; } @@ -709,11 +775,11 @@ export default class Range { if ( RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._start.node, + node: this[PropertySymbol.start].node, offset: this.startOffset }) === -1 || RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._end.node, + node: this[PropertySymbol.end].node, offset: this.endOffset }) === 1 ) { @@ -731,46 +797,53 @@ export default class Range { */ public insertNode(newNode: INode): void { if ( - this._start.node.nodeType === NodeTypeEnum.processingInstructionNode || - this._start.node.nodeType === NodeTypeEnum.commentNode || - (this._start.node.nodeType === NodeTypeEnum.textNode && !this._start.node.parentNode) || - newNode === this._start.node + this[PropertySymbol.start].node[PropertySymbol.nodeType] === + NodeTypeEnum.processingInstructionNode || + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.commentNode || + (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode && + !this[PropertySymbol.start].node[PropertySymbol.parentNode]) || + newNode === this[PropertySymbol.start].node ) { throw new DOMException('Invalid start node.', DOMExceptionNameEnum.hierarchyRequestError); } let referenceNode = - this._start.node.nodeType === NodeTypeEnum.textNode - ? this._start.node - : (this._start.node)._childNodes[this.startOffset] || null; - const parent = !referenceNode ? this._start.node : referenceNode.parentNode; - - if (this._start.node.nodeType === NodeTypeEnum.textNode) { - referenceNode = (this._start.node).splitText(this.startOffset); + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode + ? this[PropertySymbol.start].node + : (this[PropertySymbol.start].node)[PropertySymbol.childNodes][this.startOffset] || + null; + const parent = !referenceNode + ? this[PropertySymbol.start].node + : referenceNode[PropertySymbol.parentNode]; + + if (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode) { + referenceNode = (this[PropertySymbol.start].node).splitText(this.startOffset); } if (newNode === referenceNode) { referenceNode = referenceNode.nextSibling; } - const nodeParent = newNode.parentNode; + const nodeParent = newNode[PropertySymbol.parentNode]; if (nodeParent) { nodeParent.removeChild(newNode); } let newOffset = !referenceNode ? NodeUtility.getNodeLength(parent) - : (referenceNode.parentNode)._childNodes.indexOf(referenceNode); + : (referenceNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( + referenceNode + ); newOffset += - newNode.nodeType === NodeTypeEnum.documentFragmentNode + newNode[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode ? NodeUtility.getNodeLength(newNode) : 1; parent.insertBefore(newNode, referenceNode); if (this.collapsed) { - this._end.node = parent; - this._end.offset = newOffset; + this[PropertySymbol.end].node = parent; + this[PropertySymbol.end].offset = newOffset; } } @@ -782,26 +855,26 @@ export default class Range { * @returns "true" if it intersects. */ public intersectsNode(node: INode): boolean { - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this[PropertySymbol.ownerDocument]) { return false; } - const parent = node.parentNode; + const parent = node[PropertySymbol.parentNode]; if (!parent) { return true; } - const offset = (parent)._childNodes.indexOf(node); + const offset = (parent)[PropertySymbol.childNodes].indexOf(node); return ( RangeUtility.compareBoundaryPointsPosition( { node: parent, offset }, - { node: this._end.node, offset: this.endOffset } + { node: this[PropertySymbol.end].node, offset: this.endOffset } ) === -1 && RangeUtility.compareBoundaryPointsPosition( { node: parent, offset: offset + 1 }, - { node: this._start.node, offset: this.startOffset } + { node: this[PropertySymbol.start].node, offset: this.startOffset } ) === 1 ); } @@ -813,19 +886,19 @@ export default class Range { * @param node Reference node. */ public selectNode(node: INode): void { - if (!node.parentNode) { + if (!node[PropertySymbol.parentNode]) { throw new DOMException( `The given Node has no parent.`, DOMExceptionNameEnum.invalidNodeTypeError ); } - const index = (node.parentNode)._childNodes.indexOf(node); + const index = (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(node); - this._start.node = node.parentNode; - this._start.offset = index; - this._end.node = node.parentNode; - this._end.offset = index + 1; + this[PropertySymbol.start].node = node[PropertySymbol.parentNode]; + this[PropertySymbol.start].offset = index; + this[PropertySymbol.end].node = node[PropertySymbol.parentNode]; + this[PropertySymbol.end].offset = index + 1; } /** @@ -835,17 +908,17 @@ export default class Range { * @param node Reference node. */ public selectNodeContents(node: INode): void { - if (node.nodeType === NodeTypeEnum.documentTypeNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( "DocumentType Node can't be used as boundary point.", DOMExceptionNameEnum.invalidNodeTypeError ); } - this._start.node = node; - this._start.offset = 0; - this._end.node = node; - this._end.offset = NodeUtility.getNodeLength(node); + this[PropertySymbol.start].node = node; + this[PropertySymbol.start].offset = 0; + this[PropertySymbol.end].node = node; + this[PropertySymbol.end].offset = NodeUtility.getNodeLength(node); } /** @@ -861,18 +934,18 @@ export default class Range { const boundaryPoint = { node, offset }; if ( - node.ownerDocument !== this._ownerDocument || + node[PropertySymbol.ownerDocument] !== this[PropertySymbol.ownerDocument] || RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._start.node, + node: this[PropertySymbol.start].node, offset: this.startOffset }) === -1 ) { - this._start.node = node; - this._start.offset = offset; + this[PropertySymbol.start].node = node; + this[PropertySymbol.start].offset = offset; } - this._end.node = node; - this._end.offset = offset; + this[PropertySymbol.end].node = node; + this[PropertySymbol.end].offset = offset; } /** @@ -888,18 +961,18 @@ export default class Range { const boundaryPoint = { node, offset }; if ( - node.ownerDocument !== this._ownerDocument || + node[PropertySymbol.ownerDocument] !== this[PropertySymbol.ownerDocument] || RangeUtility.compareBoundaryPointsPosition(boundaryPoint, { - node: this._end.node, + node: this[PropertySymbol.end].node, offset: this.endOffset }) === 1 ) { - this._end.node = node; - this._end.offset = offset; + this[PropertySymbol.end].node = node; + this[PropertySymbol.end].offset = offset; } - this._start.node = node; - this._start.offset = offset; + this[PropertySymbol.start].node = node; + this[PropertySymbol.start].offset = offset; } /** @@ -909,13 +982,16 @@ export default class Range { * @param node Reference node. */ public setEndAfter(node: INode): void { - if (!node.parentNode) { + if (!node[PropertySymbol.parentNode]) { throw new DOMException( 'The given Node has no parent.', DOMExceptionNameEnum.invalidNodeTypeError ); } - this.setEnd(node.parentNode, (node.parentNode)._childNodes.indexOf(node) + 1); + this.setEnd( + node[PropertySymbol.parentNode], + (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(node) + 1 + ); } /** @@ -925,13 +1001,16 @@ export default class Range { * @param node Reference node. */ public setEndBefore(node: INode): void { - if (!node.parentNode) { + if (!node[PropertySymbol.parentNode]) { throw new DOMException( 'The given Node has no parent.', DOMExceptionNameEnum.invalidNodeTypeError ); } - this.setEnd(node.parentNode, (node.parentNode)._childNodes.indexOf(node)); + this.setEnd( + node[PropertySymbol.parentNode], + (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(node) + ); } /** @@ -941,13 +1020,16 @@ export default class Range { * @param node Reference node. */ public setStartAfter(node: INode): void { - if (!node.parentNode) { + if (!node[PropertySymbol.parentNode]) { throw new DOMException( 'The given Node has no parent.', DOMExceptionNameEnum.invalidNodeTypeError ); } - this.setStart(node.parentNode, (node.parentNode)._childNodes.indexOf(node) + 1); + this.setStart( + node[PropertySymbol.parentNode], + (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(node) + 1 + ); } /** @@ -957,13 +1039,16 @@ export default class Range { * @param node Reference node. */ public setStartBefore(node: INode): void { - if (!node.parentNode) { + if (!node[PropertySymbol.parentNode]) { throw new DOMException( 'The given Node has no parent.', DOMExceptionNameEnum.invalidNodeTypeError ); } - this.setStart(node.parentNode, (node.parentNode)._childNodes.indexOf(node)); + this.setStart( + node[PropertySymbol.parentNode], + (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(node) + ); } /** @@ -977,7 +1062,7 @@ export default class Range { const endNode = NodeUtility.nextDescendantNode(node); while (node !== endNode) { if ( - node.nodeType !== NodeTypeEnum.textNode && + node[PropertySymbol.nodeType] !== NodeTypeEnum.textNode && RangeUtility.isPartiallyContained(node, this) ) { throw new DOMException( @@ -990,9 +1075,9 @@ export default class Range { } if ( - newParent.nodeType === NodeTypeEnum.documentNode || - newParent.nodeType === NodeTypeEnum.documentTypeNode || - newParent.nodeType === NodeTypeEnum.documentFragmentNode + newParent[PropertySymbol.nodeType] === NodeTypeEnum.documentNode || + newParent[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode || + newParent[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode ) { throw new DOMException('Invalid element type.', DOMExceptionNameEnum.invalidNodeTypeError); } @@ -1021,22 +1106,22 @@ export default class Range { let string = ''; if ( - this._start.node === this._end.node && - this._start.node.nodeType === NodeTypeEnum.textNode + this[PropertySymbol.start].node === this[PropertySymbol.end].node && + this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode ) { - return (this._start.node).data.slice(startOffset, endOffset); + return (this[PropertySymbol.start].node).data.slice(startOffset, endOffset); } - if (this._start.node.nodeType === NodeTypeEnum.textNode) { - string += (this._start.node).data.slice(startOffset); + if (this[PropertySymbol.start].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode) { + string += (this[PropertySymbol.start].node).data.slice(startOffset); } - const endNode = NodeUtility.nextDescendantNode(this._end.node); - let currentNode = this._start.node; + const endNode = NodeUtility.nextDescendantNode(this[PropertySymbol.end].node); + let currentNode = this[PropertySymbol.start].node; while (currentNode && currentNode !== endNode) { if ( - currentNode.nodeType === NodeTypeEnum.textNode && + currentNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode && RangeUtility.isContained(currentNode, this) ) { string += (currentNode).data; @@ -1045,8 +1130,8 @@ export default class Range { currentNode = NodeUtility.following(currentNode); } - if (this._end.node.nodeType === NodeTypeEnum.textNode) { - string += (this._end.node).data.slice(0, endOffset); + if (this[PropertySymbol.end].node[PropertySymbol.nodeType] === NodeTypeEnum.textNode) { + string += (this[PropertySymbol.end].node).data.slice(0, endOffset); } return string; diff --git a/packages/happy-dom/src/range/RangeUtility.ts b/packages/happy-dom/src/range/RangeUtility.ts index 6c5725eb8..81d591b2d 100644 --- a/packages/happy-dom/src/range/RangeUtility.ts +++ b/packages/happy-dom/src/range/RangeUtility.ts @@ -1,4 +1,5 @@ import DOMException from '../exception/DOMException.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; import INode from '../nodes/node/INode.js'; @@ -46,11 +47,14 @@ export default class RangeUtility { if (NodeUtility.isInclusiveAncestor(pointA.node, pointB.node)) { let child = pointB.node; - while (child.parentNode !== pointA.node) { - child = child.parentNode; + while (child[PropertySymbol.parentNode] !== pointA.node) { + child = child[PropertySymbol.parentNode]; } - if ((child.parentNode)._childNodes.indexOf(child) < pointA.offset) { + if ( + (child[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf(child) < + pointA.offset + ) { return 1; } } @@ -65,7 +69,7 @@ export default class RangeUtility { * @param point Boundary point. */ public static validateBoundaryPoint(point: IRangeBoundaryPoint): void { - if (point.node.nodeType === NodeTypeEnum.documentTypeNode) { + if (point.node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( `DocumentType Node can't be used as boundary point.`, DOMExceptionNameEnum.invalidNodeTypeError diff --git a/packages/happy-dom/src/selection/Selection.ts b/packages/happy-dom/src/selection/Selection.ts index 68952f52f..b142cbdd6 100644 --- a/packages/happy-dom/src/selection/Selection.ts +++ b/packages/happy-dom/src/selection/Selection.ts @@ -1,4 +1,5 @@ import Event from '../event/Event.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; import IDocument from '../nodes/document/IDocument.js'; @@ -19,9 +20,9 @@ import SelectionDirectionEnum from './SelectionDirectionEnum.js'; * https://developer.mozilla.org/en-US/docs/Web/API/Selection. */ export default class Selection { - private readonly _ownerDocument: IDocument = null; - private _range: Range = null; - private _direction: SelectionDirectionEnum = SelectionDirectionEnum.directionless; + readonly #ownerDocument: IDocument = null; + #range: Range = null; + #direction: SelectionDirectionEnum = SelectionDirectionEnum.directionless; /** * Constructor. @@ -29,7 +30,7 @@ export default class Selection { * @param ownerDocument Owner document. */ constructor(ownerDocument: IDocument) { - this._ownerDocument = ownerDocument; + this.#ownerDocument = ownerDocument; } /** @@ -39,7 +40,7 @@ export default class Selection { * @returns Range count. */ public get rangeCount(): number { - return this._range ? 1 : 0; + return this.#range ? 1 : 0; } /** @@ -49,7 +50,7 @@ export default class Selection { * @returns "true" if collapsed. */ public get isCollapsed(): boolean { - return this._range === null || this._range.collapsed; + return this.#range === null || this.#range.collapsed; } /** @@ -59,9 +60,9 @@ export default class Selection { * @returns Type. */ public get type(): string { - if (!this._range) { + if (!this.#range) { return 'None'; - } else if (this._range.collapsed) { + } else if (this.#range.collapsed) { return 'Caret'; } @@ -75,12 +76,12 @@ export default class Selection { * @returns Node. */ public get anchorNode(): INode { - if (!this._range) { + if (!this.#range) { return null; } - return this._direction === SelectionDirectionEnum.forwards - ? this._range.startContainer - : this._range.endContainer; + return this.#direction === SelectionDirectionEnum.forwards + ? this.#range.startContainer + : this.#range.endContainer; } /** @@ -90,12 +91,12 @@ export default class Selection { * @returns Node. */ public get anchorOffset(): number { - if (!this._range) { + if (!this.#range) { return null; } - return this._direction === SelectionDirectionEnum.forwards - ? this._range.startOffset - : this._range.endOffset; + return this.#direction === SelectionDirectionEnum.forwards + ? this.#range.startOffset + : this.#range.endOffset; } /** @@ -172,8 +173,8 @@ export default class Selection { if (!newRange) { throw new Error('Failed to execute addRange on Selection. Parameter 1 is not of type Range.'); } - if (!this._range && newRange._ownerDocument === this._ownerDocument) { - this._associateRange(newRange); + if (!this.#range && newRange[PropertySymbol.ownerDocument] === this.#ownerDocument) { + this.#associateRange(newRange); } } @@ -185,11 +186,11 @@ export default class Selection { * @returns Range. */ public getRangeAt(index: number): Range { - if (!this._range || index !== 0) { + if (!this.#range || index !== 0) { throw new DOMException('Invalid range index.', DOMExceptionNameEnum.indexSizeError); } - return this._range; + return this.#range; } /** @@ -199,17 +200,17 @@ export default class Selection { * @param range Range. */ public removeRange(range: Range): void { - if (this._range !== range) { + if (this.#range !== range) { throw new DOMException('Invalid range.', DOMExceptionNameEnum.notFoundError); } - this._associateRange(null); + this.#associateRange(null); } /** * Removes all ranges. */ public removeAllRanges(): void { - this._associateRange(null); + this.#associateRange(null); } /** @@ -234,7 +235,7 @@ export default class Selection { return; } - if (node.nodeType === NodeTypeEnum.documentTypeNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( "DocumentType Node can't be used as boundary point.", DOMExceptionNameEnum.invalidNodeTypeError @@ -245,18 +246,18 @@ export default class Selection { throw new DOMException('Invalid range index.', DOMExceptionNameEnum.indexSizeError); } - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this.#ownerDocument) { return; } - const newRange = new Range(); + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); - newRange._start.node = node; - newRange._start.offset = offset; - newRange._end.node = node; - newRange._end.offset = offset; + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = offset; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = offset; - this._associateRange(newRange); + this.#associateRange(newRange); } /** @@ -277,22 +278,22 @@ export default class Selection { * @see https://w3c.github.io/selection-api/#dom-selection-collapsetoend */ public collapseToEnd(): void { - if (this._range === null) { + if (this.#range === null) { throw new DOMException( 'There is no selection to collapse.', DOMExceptionNameEnum.invalidStateError ); } - const { node, offset } = this._range._end; - const newRange = new Range(); + const { node, offset } = this.#range[PropertySymbol.end]; + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); - newRange._start.node = node; - newRange._start.offset = offset; - newRange._end.node = node; - newRange._end.offset = offset; + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = offset; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = offset; - this._associateRange(newRange); + this.#associateRange(newRange); } /** @@ -301,22 +302,22 @@ export default class Selection { * @see https://w3c.github.io/selection-api/#dom-selection-collapsetostart */ public collapseToStart(): void { - if (!this._range) { + if (!this.#range) { throw new DOMException( 'There is no selection to collapse.', DOMExceptionNameEnum.invalidStateError ); } - const { node, offset } = this._range._start; - const newRange = new Range(); + const { node, offset } = this.#range[PropertySymbol.start]; + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); - newRange._start.node = node; - newRange._start.offset = offset; - newRange._end.node = node; - newRange._end.offset = offset; + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = offset; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = offset; - this._associateRange(newRange); + this.#associateRange(newRange); } /** @@ -328,16 +329,17 @@ export default class Selection { * @returns Always returns "true" for now. */ public containsNode(node: INode, allowPartialContainment = false): boolean { - if (!this._range || node.ownerDocument !== this._ownerDocument) { + if (!this.#range || node[PropertySymbol.ownerDocument] !== this.#ownerDocument) { return false; } - const { _start, _end } = this._range; - const startIsBeforeNode = - RangeUtility.compareBoundaryPointsPosition(_start, { node, offset: 0 }) === -1; + RangeUtility.compareBoundaryPointsPosition(this.#range[PropertySymbol.start], { + node, + offset: 0 + }) === -1; const endIsAfterNode = - RangeUtility.compareBoundaryPointsPosition(_end, { + RangeUtility.compareBoundaryPointsPosition(this.#range[PropertySymbol.end], { node, offset: NodeUtility.getNodeLength(node) }) === 1; @@ -353,8 +355,8 @@ export default class Selection { * @see https://w3c.github.io/selection-api/#dom-selection-deletefromdocument */ public deleteFromDocument(): void { - if (this._range) { - this._range.deleteContents(); + if (this.#range) { + this.#range.deleteContents(); } } @@ -366,11 +368,11 @@ export default class Selection { * @param offset Offset. */ public extend(node: INode, offset: number): void { - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this.#ownerDocument) { return; } - if (!this._range) { + if (!this.#range) { throw new DOMException( 'There is no selection to extend.', DOMExceptionNameEnum.invalidStateError @@ -379,34 +381,34 @@ export default class Selection { const anchorNode = this.anchorNode; const anchorOffset = this.anchorOffset; - const newRange = new Range(); - newRange._start.node = node; - newRange._start.offset = 0; - newRange._end.node = node; - newRange._end.offset = 0; - - if (node.ownerDocument !== this._range._ownerDocument) { - newRange._start.offset = offset; - newRange._end.offset = offset; + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = 0; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = 0; + + if (node[PropertySymbol.ownerDocument] !== this.#range[PropertySymbol.ownerDocument]) { + newRange[PropertySymbol.start].offset = offset; + newRange[PropertySymbol.end].offset = offset; } else if ( RangeUtility.compareBoundaryPointsPosition( { node: anchorNode, offset: anchorOffset }, { node, offset } ) <= 0 ) { - newRange._start.node = anchorNode; - newRange._start.offset = anchorOffset; - newRange._end.node = node; - newRange._end.offset = offset; + newRange[PropertySymbol.start].node = anchorNode; + newRange[PropertySymbol.start].offset = anchorOffset; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = offset; } else { - newRange._start.node = node; - newRange._start.offset = offset; - newRange._end.node = anchorNode; - newRange._end.offset = anchorOffset; + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = offset; + newRange[PropertySymbol.end].node = anchorNode; + newRange[PropertySymbol.end].offset = anchorOffset; } - this._associateRange(newRange); - this._direction = + this.#associateRange(newRange); + this.#direction = RangeUtility.compareBoundaryPointsPosition( { node, offset }, { node: anchorNode, offset: anchorOffset } @@ -419,30 +421,29 @@ export default class Selection { * Selects all children. * * @see https://w3c.github.io/selection-api/#dom-selection-selectallchildren - * @param node - * @param _parentNode Parent node. + * @param node Node. */ public selectAllChildren(node: INode): void { - if (node.nodeType === NodeTypeEnum.documentTypeNode) { + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { throw new DOMException( "DocumentType Node can't be used as boundary point.", DOMExceptionNameEnum.invalidNodeTypeError ); } - if (node.ownerDocument !== this._ownerDocument) { + if (node[PropertySymbol.ownerDocument] !== this.#ownerDocument) { return; } const length = node.childNodes.length; - const newRange = new Range(); + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); - newRange._start.node = node; - newRange._start.offset = 0; - newRange._end.node = node; - newRange._end.offset = length; + newRange[PropertySymbol.start].node = node; + newRange[PropertySymbol.start].offset = 0; + newRange[PropertySymbol.end].node = node; + newRange[PropertySymbol.end].offset = length; - this._associateRange(newRange); + this.#associateRange(newRange); } /** @@ -471,26 +472,26 @@ export default class Selection { } if ( - anchorNode.ownerDocument !== this._ownerDocument || - focusNode.ownerDocument !== this._ownerDocument + anchorNode[PropertySymbol.ownerDocument] !== this.#ownerDocument || + focusNode[PropertySymbol.ownerDocument] !== this.#ownerDocument ) { return; } const anchor = { node: anchorNode, offset: anchorOffset }; const focus = { node: focusNode, offset: focusOffset }; - const newRange = new Range(); + const newRange = new this.#ownerDocument[PropertySymbol.ownerWindow].Range(); if (RangeUtility.compareBoundaryPointsPosition(anchor, focus) === -1) { - newRange._start = anchor; - newRange._end = focus; + newRange[PropertySymbol.start] = anchor; + newRange[PropertySymbol.end] = focus; } else { - newRange._start = focus; - newRange._end = anchor; + newRange[PropertySymbol.start] = focus; + newRange[PropertySymbol.end] = anchor; } - this._associateRange(newRange); - this._direction = + this.#associateRange(newRange); + this.#direction = RangeUtility.compareBoundaryPointsPosition(focus, anchor) === -1 ? SelectionDirectionEnum.backwards : SelectionDirectionEnum.forwards; @@ -502,7 +503,7 @@ export default class Selection { * @returns Selection as string. */ public toString(): string { - return this._range ? this._range.toString() : ''; + return this.#range ? this.#range.toString() : ''; } /** @@ -510,15 +511,15 @@ export default class Selection { * * @param range Range. */ - protected _associateRange(range: Range): void { - const oldRange = this._range; - this._range = range; - this._direction = + #associateRange(range: Range): void { + const oldRange = this.#range; + this.#range = range; + this.#direction = range === null ? SelectionDirectionEnum.directionless : SelectionDirectionEnum.forwards; - if (oldRange !== this._range) { + if (oldRange !== this.#range) { // https://w3c.github.io/selection-api/#selectionchange-event - this._ownerDocument.dispatchEvent(new Event('selectionchange')); + this.#ownerDocument.dispatchEvent(new Event('selectionchange')); } } } diff --git a/packages/happy-dom/src/storage/Storage.ts b/packages/happy-dom/src/storage/Storage.ts index 4414681af..a68432029 100644 --- a/packages/happy-dom/src/storage/Storage.ts +++ b/packages/happy-dom/src/storage/Storage.ts @@ -1,8 +1,10 @@ /** + * Storage. * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Storage */ export default class Storage { - private _store: { [k: string]: string } = {}; + #store: { [k: string]: string } = {}; /** * Returns length. @@ -10,7 +12,7 @@ export default class Storage { * @returns Length. */ public get length(): number { - return Object.keys(this._store).length; + return Object.keys(this.#store).length; } /** @@ -20,7 +22,7 @@ export default class Storage { * @returns Name. */ public key(index: number): string { - const name = Object.keys(this._store)[index]; + const name = Object.keys(this.#store)[index]; return name === undefined ? null : name; } @@ -31,7 +33,7 @@ export default class Storage { * @param item Item. */ public setItem(name: string, item: string): void { - this._store[name] = item; + this.#store[name] = item; } /** @@ -41,7 +43,7 @@ export default class Storage { * @returns Item. */ public getItem(name: string): string { - return this._store[name] === undefined ? null : this._store[name]; + return this.#store[name] === undefined ? null : this.#store[name]; } /** @@ -50,13 +52,13 @@ export default class Storage { * @param name Name. */ public removeItem(name: string): void { - delete this._store[name]; + delete this.#store[name]; } /** * Clears storage. */ public clear(): void { - this._store = {}; + this.#store = {}; } } diff --git a/packages/happy-dom/src/tree-walker/NodeIterator.ts b/packages/happy-dom/src/tree-walker/NodeIterator.ts index 82520507c..c533112f6 100644 --- a/packages/happy-dom/src/tree-walker/NodeIterator.ts +++ b/packages/happy-dom/src/tree-walker/NodeIterator.ts @@ -13,7 +13,7 @@ export default class NodeIterator { public whatToShow = -1; public filter: INodeFilter = null; - private readonly _walker: TreeWalker; + readonly #walker: TreeWalker; /** * Constructor. @@ -26,7 +26,7 @@ export default class NodeIterator { this.root = root; this.whatToShow = whatToShow; this.filter = filter; - this._walker = new TreeWalker(root, whatToShow, filter); + this.#walker = new TreeWalker(root, whatToShow, filter); } /** @@ -35,7 +35,7 @@ export default class NodeIterator { * @returns Current node. */ public nextNode(): INode { - return this._walker.nextNode(); + return this.#walker.nextNode(); } /** @@ -44,6 +44,6 @@ export default class NodeIterator { * @returns Current node. */ public previousNode(): INode { - return this._walker.previousNode(); + return this.#walker.previousNode(); } } diff --git a/packages/happy-dom/src/tree-walker/TreeWalker.ts b/packages/happy-dom/src/tree-walker/TreeWalker.ts index c77532e71..5a264a0e5 100644 --- a/packages/happy-dom/src/tree-walker/TreeWalker.ts +++ b/packages/happy-dom/src/tree-walker/TreeWalker.ts @@ -1,4 +1,5 @@ import Node from '../nodes/node/Node.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import NodeFilter from './NodeFilter.js'; import INodeFilter from './INodeFilter.js'; import NodeFilterMask from './NodeFilterMask.js'; @@ -62,8 +63,12 @@ export default class TreeWalker { * @returns Current node. */ public parentNode(): INode { - if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { - this.currentNode = this.currentNode.parentNode; + if ( + this.currentNode !== this.root && + this.currentNode && + this.currentNode[PropertySymbol.parentNode] + ) { + this.currentNode = this.currentNode[PropertySymbol.parentNode]; if (this.filterNode(this.currentNode) === NodeFilter.FILTER_ACCEPT) { return this.currentNode; @@ -83,7 +88,7 @@ export default class TreeWalker { * @returns Current node. */ public firstChild(): INode { - const childNodes = this.currentNode ? (this.currentNode)._childNodes : []; + const childNodes = this.currentNode ? (this.currentNode)[PropertySymbol.childNodes] : []; if (childNodes.length > 0) { this.currentNode = childNodes[0]; @@ -104,7 +109,7 @@ export default class TreeWalker { * @returns Current node. */ public lastChild(): INode { - const childNodes = this.currentNode ? (this.currentNode)._childNodes : []; + const childNodes = this.currentNode ? (this.currentNode)[PropertySymbol.childNodes] : []; if (childNodes.length > 0) { this.currentNode = childNodes[childNodes.length - 1]; @@ -125,8 +130,14 @@ export default class TreeWalker { * @returns Current node. */ public previousSibling(): INode { - if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { - const siblings = (this.currentNode.parentNode)._childNodes; + if ( + this.currentNode !== this.root && + this.currentNode && + this.currentNode[PropertySymbol.parentNode] + ) { + const siblings = (this.currentNode[PropertySymbol.parentNode])[ + PropertySymbol.childNodes + ]; const index = siblings.indexOf(this.currentNode); if (index > 0) { @@ -149,8 +160,14 @@ export default class TreeWalker { * @returns Current node. */ public nextSibling(): INode { - if (this.currentNode !== this.root && this.currentNode && this.currentNode.parentNode) { - const siblings = (this.currentNode.parentNode)._childNodes; + if ( + this.currentNode !== this.root && + this.currentNode && + this.currentNode[PropertySymbol.parentNode] + ) { + const siblings = (this.currentNode[PropertySymbol.parentNode])[ + PropertySymbol.childNodes + ]; const index = siblings.indexOf(this.currentNode); if (index + 1 < siblings.length) { diff --git a/packages/happy-dom/src/url/URL.ts b/packages/happy-dom/src/url/URL.ts index 39ffae780..4d5dc93d8 100644 --- a/packages/happy-dom/src/url/URL.ts +++ b/packages/happy-dom/src/url/URL.ts @@ -1,4 +1,5 @@ import { URL as NodeJSURL } from 'url'; +import * as PropertySymbol from '../PropertySymbol.js'; import { Blob as NodeJSBlob } from 'buffer'; import Blob from '../file/Blob.js'; @@ -14,7 +15,7 @@ export default class URL extends NodeJSURL { */ public static override createObjectURL(object: NodeJSBlob | Blob): string { if (object instanceof Blob) { - const blob = new NodeJSBlob([object._buffer], { type: object.type }); + const blob = new NodeJSBlob([object[PropertySymbol.buffer]], { type: object.type }); return super.createObjectURL(blob); } return super.createObjectURL(object); diff --git a/packages/happy-dom/src/validity-state/ValidityState.ts b/packages/happy-dom/src/validity-state/ValidityState.ts index 9e51667c8..c3bb9f5f6 100644 --- a/packages/happy-dom/src/validity-state/ValidityState.ts +++ b/packages/happy-dom/src/validity-state/ValidityState.ts @@ -1,4 +1,5 @@ import IHTMLButtonElement from '../nodes/html-button-element/IHTMLButtonElement.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import IHTMLFormElement from '../nodes/html-form-element/IHTMLFormElement.js'; import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement.js'; @@ -58,7 +59,7 @@ export default class ValidityState { * @returns "true" if valid. */ public get customError(): boolean { - return this.element.validationMessage.length > 0; + return this.element[PropertySymbol.validationMessage].length > 0; } /** @@ -182,7 +183,8 @@ export default class ValidityState { return true; } const root = - this.element._formNode || this.element.getRootNode(); + this.element[PropertySymbol.formNode] || + this.element.getRootNode(); return !root || !root.querySelector(`input[name="${this.element.name}"]:checked`); } } diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts new file mode 100644 index 000000000..b45136935 --- /dev/null +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -0,0 +1,1262 @@ +import CustomElementRegistry from '../custom-element/CustomElementRegistry.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import DocumentImplementation from '../nodes/document/Document.js'; +import HTMLDocumentImplementation from '../nodes/html-document/HTMLDocument.js'; +import XMLDocumentImplementation from '../nodes/xml-document/XMLDocument.js'; +import SVGDocumentImplementation from '../nodes/svg-document/SVGDocument.js'; +import Node from '../nodes/node/Node.js'; +import NodeFilter from '../tree-walker/NodeFilter.js'; +import Text from '../nodes/text/Text.js'; +import Comment from '../nodes/comment/Comment.js'; +import ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; +import Element from '../nodes/element/Element.js'; +import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement.js'; +import HTMLFormElement from '../nodes/html-form-element/HTMLFormElement.js'; +import HTMLElement from '../nodes/html-element/HTMLElement.js'; +import HTMLUnknownElement from '../nodes/html-unknown-element/HTMLUnknownElement.js'; +import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; +import HTMLSelectElement from '../nodes/html-select-element/HTMLSelectElement.js'; +import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement.js'; +import HTMLLinkElementImplementation from '../nodes/html-link-element/HTMLLinkElement.js'; +import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement.js'; +import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement.js'; +import HTMLLabelElement from '../nodes/html-label-element/HTMLLabelElement.js'; +import HTMLMetaElement from '../nodes/html-meta-element/HTMLMetaElement.js'; +import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement.js'; +import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement.js'; +import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement.js'; +import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement.js'; +import HTMLIFrameElementImplementation from '../nodes/html-iframe-element/HTMLIFrameElement.js'; +import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement.js'; +import SVGSVGElement from '../nodes/svg-element/SVGSVGElement.js'; +import SVGElement from '../nodes/svg-element/SVGElement.js'; +import SVGGraphicsElement from '../nodes/svg-element/SVGGraphicsElement.js'; +import HTMLScriptElementImplementation from '../nodes/html-script-element/HTMLScriptElement.js'; +import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement.js'; +import CharacterData from '../nodes/character-data/CharacterData.js'; +import DocumentType from '../nodes/document-type/DocumentType.js'; +import NodeIterator from '../tree-walker/NodeIterator.js'; +import TreeWalker from '../tree-walker/TreeWalker.js'; +import Event from '../event/Event.js'; +import CustomEvent from '../event/events/CustomEvent.js'; +import AnimationEvent from '../event/events/AnimationEvent.js'; +import KeyboardEvent from '../event/events/KeyboardEvent.js'; +import MessageEvent from '../event/events/MessageEvent.js'; +import ProgressEvent from '../event/events/ProgressEvent.js'; +import MediaQueryListEvent from '../event/events/MediaQueryListEvent.js'; +import EventTarget from '../event/EventTarget.js'; +import MessagePort from '../event/MessagePort.js'; +import { URLSearchParams } from 'url'; +import URL from '../url/URL.js'; +import Location from '../location/Location.js'; +import MutationObserver from '../mutation-observer/MutationObserver.js'; +import MutationRecord from '../mutation-observer/MutationRecord.js'; +import XMLSerializer from '../xml-serializer/XMLSerializer.js'; +import ResizeObserver from '../resize-observer/ResizeObserver.js'; +import Blob from '../file/Blob.js'; +import File from '../file/File.js'; +import DOMException from '../exception/DOMException.js'; +import History from '../history/History.js'; +import CSSStyleSheet from '../css/CSSStyleSheet.js'; +import CSSStyleDeclaration from '../css/declaration/CSSStyleDeclaration.js'; +import CSS from '../css/CSS.js'; +import CSSUnitValue from '../css/CSSUnitValue.js'; +import CSSRule from '../css/CSSRule.js'; +import CSSContainerRule from '../css/rules/CSSContainerRule.js'; +import CSSFontFaceRule from '../css/rules/CSSFontFaceRule.js'; +import CSSKeyframeRule from '../css/rules/CSSKeyframeRule.js'; +import CSSKeyframesRule from '../css/rules/CSSKeyframesRule.js'; +import CSSMediaRule from '../css/rules/CSSMediaRule.js'; +import CSSStyleRule from '../css/rules/CSSStyleRule.js'; +import CSSSupportsRule from '../css/rules/CSSSupportsRule.js'; +import MouseEvent from '../event/events/MouseEvent.js'; +import PointerEvent from '../event/events/PointerEvent.js'; +import FocusEvent from '../event/events/FocusEvent.js'; +import WheelEvent from '../event/events/WheelEvent.js'; +import DataTransfer from '../event/DataTransfer.js'; +import DataTransferItem from '../event/DataTransferItem.js'; +import DataTransferItemList from '../event/DataTransferItemList.js'; +import InputEvent from '../event/events/InputEvent.js'; +import UIEvent from '../event/UIEvent.js'; +import ErrorEvent from '../event/events/ErrorEvent.js'; +import StorageEvent from '../event/events/StorageEvent.js'; +import SubmitEvent from '../event/events/SubmitEvent.js'; +import Screen from '../screen/Screen.js'; +import IResponse from '../fetch/types/IResponse.js'; +import IRequestInit from '../fetch/types/IRequestInit.js'; +import Storage from '../storage/Storage.js'; +import HTMLCollection from '../nodes/element/HTMLCollection.js'; +import HTMLFormControlsCollection from '../nodes/html-form-element/HTMLFormControlsCollection.js'; +import NodeList from '../nodes/node/NodeList.js'; +import MediaQueryList from '../match-media/MediaQueryList.js'; +import Selection from '../selection/Selection.js'; +import Navigator from '../navigator/Navigator.js'; +import MimeType from '../navigator/MimeType.js'; +import MimeTypeArray from '../navigator/MimeTypeArray.js'; +import Plugin from '../navigator/Plugin.js'; +import PluginArray from '../navigator/PluginArray.js'; +import Fetch from '../fetch/Fetch.js'; +import DOMRect from '../nodes/element/DOMRect.js'; +import VMGlobalPropertyScript from './VMGlobalPropertyScript.js'; +import * as PerfHooks from 'perf_hooks'; +import VM from 'vm'; +import { Buffer } from 'buffer'; +import { webcrypto } from 'crypto'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload.js'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget.js'; +import Base64 from '../base64/Base64.js'; +import Attr from '../nodes/attr/Attr.js'; +import NamedNodeMap from '../named-node-map/NamedNodeMap.js'; +import IElement from '../nodes/element/IElement.js'; +import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction.js'; +import RequestInfo from '../fetch/types/IRequestInfo.js'; +import FileList from '../nodes/html-input-element/FileList.js'; +import Stream from 'stream'; +import FormData from '../form-data/FormData.js'; +import AbortController from '../fetch/AbortController.js'; +import AbortSignal from '../fetch/AbortSignal.js'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; +import RadioNodeList from '../nodes/html-form-element/RadioNodeList.js'; +import ValidityState from '../validity-state/ValidityState.js'; +import WindowErrorUtility from './WindowErrorUtility.js'; +import ICrossOriginBrowserWindow from './ICrossOriginBrowserWindow.js'; +import Permissions from '../permissions/Permissions.js'; +import PermissionStatus from '../permissions/PermissionStatus.js'; +import Clipboard from '../clipboard/Clipboard.js'; +import ClipboardItem from '../clipboard/ClipboardItem.js'; +import ClipboardEvent from '../event/events/ClipboardEvent.js'; +import Headers from '../fetch/Headers.js'; +import XMLHttpRequestImplementation from '../xml-http-request/XMLHttpRequest.js'; +import WindowBrowserSettingsReader from './WindowBrowserSettingsReader.js'; +import DocumentReadyStateManager from '../nodes/document/DocumentReadyStateManager.js'; +import DocumentReadyStateEnum from '../nodes/document/DocumentReadyStateEnum.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import HTMLAnchorElement from '../nodes/html-anchor-element/HTMLAnchorElement.js'; +import HTMLButtonElement from '../nodes/html-button-element/HTMLButtonElement.js'; +import HTMLOptionElement from '../nodes/html-option-element/HTMLOptionElement.js'; +import HTMLOptGroupElement from '../nodes/html-opt-group-element/HTMLOptGroupElement.js'; +import WindowPageOpenUtility from './WindowPageOpenUtility.js'; +import IResponseBody from '../fetch/types/IResponseBody.js'; +import IResponseInit from '../fetch/types/IResponseInit.js'; +import IRequestInfo from '../fetch/types/IRequestInfo.js'; +import IBrowserWindow from './IBrowserWindow.js'; +import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; +import AudioImplementation from '../nodes/html-audio-element/Audio.js'; +import ImageImplementation from '../nodes/html-image-element/Image.js'; +import DocumentFragmentImplementation from '../nodes/document-fragment/DocumentFragment.js'; +import DOMParserImplementation from '../dom-parser/DOMParser.js'; +import FileReaderImplementation from '../file/FileReader.js'; +import RequestImplementation from '../fetch/Request.js'; +import ResponseImplementation from '../fetch/Response.js'; +import RangeImplementation from '../range/Range.js'; + +const TIMER = { + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval, + queueMicrotask: queueMicrotask, + setImmediate: setImmediate, + clearImmediate: clearImmediate +}; +const IS_NODE_JS_TIMEOUT_ENVIRONMENT = setTimeout.toString().includes('new Timeout'); + +/** + * Browser window. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/Window. + */ +export default class BrowserWindow extends EventTarget implements IBrowserWindow { + // Nodes + public readonly Node: typeof Node = Node; + public readonly Attr: typeof Attr = Attr; + public readonly SVGSVGElement: typeof SVGSVGElement = SVGSVGElement; + public readonly SVGElement: typeof SVGElement = SVGElement; + public readonly SVGGraphicsElement: typeof SVGGraphicsElement = SVGGraphicsElement; + public readonly Text: typeof Text = Text; + public readonly Comment: typeof Comment = Comment; + public readonly ShadowRoot: typeof ShadowRoot = ShadowRoot; + public readonly ProcessingInstruction: typeof ProcessingInstruction = ProcessingInstruction; + public readonly Element: typeof Element = Element; + public readonly CharacterData: typeof CharacterData = CharacterData; + public readonly DocumentType: typeof DocumentType = DocumentType; + public readonly Document: new () => DocumentImplementation; + public readonly HTMLDocument: new () => HTMLDocumentImplementation; + public readonly XMLDocument: new () => XMLDocumentImplementation; + public readonly SVGDocument: new () => SVGDocumentImplementation; + + // Element classes + public readonly HTMLAnchorElement: typeof HTMLAnchorElement = HTMLAnchorElement; + public readonly HTMLButtonElement: typeof HTMLButtonElement = HTMLButtonElement; + public readonly HTMLOptGroupElement: typeof HTMLOptGroupElement = HTMLOptGroupElement; + public readonly HTMLOptionElement: typeof HTMLOptionElement = HTMLOptionElement; + public readonly HTMLElement: typeof HTMLElement = HTMLElement; + public readonly HTMLUnknownElement: typeof HTMLUnknownElement = HTMLUnknownElement; + public readonly HTMLTemplateElement: typeof HTMLTemplateElement = HTMLTemplateElement; + public readonly HTMLFormElement: typeof HTMLFormElement = HTMLFormElement; + public readonly HTMLInputElement: typeof HTMLInputElement = HTMLInputElement; + public readonly HTMLSelectElement: typeof HTMLSelectElement = HTMLSelectElement; + public readonly HTMLTextAreaElement: typeof HTMLTextAreaElement = HTMLTextAreaElement; + public readonly HTMLImageElement: typeof HTMLImageElement = HTMLImageElement; + public readonly HTMLStyleElement: typeof HTMLStyleElement = HTMLStyleElement; + public readonly HTMLLabelElement: typeof HTMLLabelElement = HTMLLabelElement; + public readonly HTMLSlotElement: typeof HTMLSlotElement = HTMLSlotElement; + public readonly HTMLMetaElement: typeof HTMLMetaElement = HTMLMetaElement; + public readonly HTMLMediaElement: typeof HTMLMediaElement = HTMLMediaElement; + public readonly HTMLAudioElement: typeof HTMLAudioElement = HTMLAudioElement; + public readonly HTMLVideoElement: typeof HTMLVideoElement = HTMLVideoElement; + public readonly HTMLBaseElement: typeof HTMLBaseElement = HTMLBaseElement; + public readonly HTMLDialogElement: typeof HTMLDialogElement = HTMLDialogElement; + public readonly HTMLScriptElement: typeof HTMLScriptElementImplementation; + public readonly HTMLLinkElement: typeof HTMLLinkElementImplementation; + public readonly HTMLIFrameElement: typeof HTMLIFrameElementImplementation; + + // Non-implemented element classes + public readonly HTMLHeadElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTitleElement: typeof HTMLElement = HTMLElement; + public readonly HTMLBodyElement: typeof HTMLElement = HTMLElement; + public readonly HTMLHeadingElement: typeof HTMLElement = HTMLElement; + public readonly HTMLParagraphElement: typeof HTMLElement = HTMLElement; + public readonly HTMLHRElement: typeof HTMLElement = HTMLElement; + public readonly HTMLPreElement: typeof HTMLElement = HTMLElement; + public readonly HTMLUListElement: typeof HTMLElement = HTMLElement; + public readonly HTMLOListElement: typeof HTMLElement = HTMLElement; + public readonly HTMLLIElement: typeof HTMLElement = HTMLElement; + public readonly HTMLMenuElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDListElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDivElement: typeof HTMLElement = HTMLElement; + public readonly HTMLAreaElement: typeof HTMLElement = HTMLElement; + public readonly HTMLBRElement: typeof HTMLElement = HTMLElement; + public readonly HTMLCanvasElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDataElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDataListElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDetailsElement: typeof HTMLElement = HTMLElement; + public readonly HTMLDirectoryElement: typeof HTMLElement = HTMLElement; + public readonly HTMLFieldSetElement: typeof HTMLElement = HTMLElement; + public readonly HTMLFontElement: typeof HTMLElement = HTMLElement; + public readonly HTMLHtmlElement: typeof HTMLElement = HTMLElement; + public readonly HTMLLegendElement: typeof HTMLElement = HTMLElement; + public readonly HTMLMapElement: typeof HTMLElement = HTMLElement; + public readonly HTMLMarqueeElement: typeof HTMLElement = HTMLElement; + public readonly HTMLMeterElement: typeof HTMLElement = HTMLElement; + public readonly HTMLModElement: typeof HTMLElement = HTMLElement; + public readonly HTMLOutputElement: typeof HTMLElement = HTMLElement; + public readonly HTMLPictureElement: typeof HTMLElement = HTMLElement; + public readonly HTMLProgressElement: typeof HTMLElement = HTMLElement; + public readonly HTMLQuoteElement: typeof HTMLElement = HTMLElement; + public readonly HTMLSourceElement: typeof HTMLElement = HTMLElement; + public readonly HTMLSpanElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableCaptionElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableCellElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableColElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTimeElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableRowElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTableSectionElement: typeof HTMLElement = HTMLElement; + public readonly HTMLFrameElement: typeof HTMLElement = HTMLElement; + public readonly HTMLFrameSetElement: typeof HTMLElement = HTMLElement; + public readonly HTMLEmbedElement: typeof HTMLElement = HTMLElement; + public readonly HTMLObjectElement: typeof HTMLElement = HTMLElement; + public readonly HTMLParamElement: typeof HTMLElement = HTMLElement; + public readonly HTMLTrackElement: typeof HTMLElement = HTMLElement; + + // Events classes + public readonly Event = Event; + public readonly UIEvent = UIEvent; + public readonly CustomEvent = CustomEvent; + public readonly AnimationEvent = AnimationEvent; + public readonly KeyboardEvent = KeyboardEvent; + public readonly MessageEvent = MessageEvent; + public readonly MouseEvent = MouseEvent; + public readonly PointerEvent = PointerEvent; + public readonly FocusEvent = FocusEvent; + public readonly WheelEvent = WheelEvent; + public readonly InputEvent = InputEvent; + public readonly ErrorEvent = ErrorEvent; + public readonly StorageEvent = StorageEvent; + public readonly SubmitEvent = SubmitEvent; + public readonly ProgressEvent = ProgressEvent; + public readonly MediaQueryListEvent = MediaQueryListEvent; + public readonly ClipboardEvent = ClipboardEvent; + + // Non-implemented event classes + public readonly AudioProcessingEvent = Event; + public readonly BeforeInputEvent = Event; + public readonly BeforeUnloadEvent = Event; + public readonly BlobEvent = Event; + public readonly CloseEvent = Event; + public readonly CompositionEvent = Event; + public readonly CSSFontFaceLoadEvent = Event; + public readonly DeviceLightEvent = Event; + public readonly DeviceMotionEvent = Event; + public readonly DeviceOrientationEvent = Event; + public readonly DeviceProximityEvent = Event; + public readonly DOMTransactionEvent = Event; + public readonly DragEvent = Event; + public readonly EditingBeforeInputEvent = Event; + public readonly FetchEvent = Event; + public readonly GamepadEvent = Event; + public readonly HashChangeEvent = Event; + public readonly IDBVersionChangeEvent = Event; + public readonly MediaStreamEvent = Event; + public readonly MutationEvent = Event; + public readonly OfflineAudioCompletionEvent = Event; + public readonly OverconstrainedError = Event; + public readonly PageTransitionEvent = Event; + public readonly PaymentRequestUpdateEvent = Event; + public readonly PopStateEvent = Event; + public readonly RelatedEvent = Event; + public readonly RTCDataChannelEvent = Event; + public readonly RTCIdentityErrorEvent = Event; + public readonly RTCIdentityEvent = Event; + public readonly RTCPeerConnectionIceEvent = Event; + public readonly SensorEvent = Event; + public readonly SVGEvent = Event; + public readonly SVGZoomEvent = Event; + public readonly TimeEvent = Event; + public readonly TouchEvent = Event; + public readonly TrackEvent = Event; + public readonly TransitionEvent = Event; + public readonly UserProximityEvent = Event; + public readonly WebGLContextEvent = Event; + public readonly TextEvent = Event; + + // Other classes + public readonly NamedNodeMap = NamedNodeMap; + public readonly NodeFilter = NodeFilter; + public readonly NodeIterator = NodeIterator; + public readonly TreeWalker = TreeWalker; + public readonly MutationObserver = MutationObserver; + public readonly MutationRecord = MutationRecord; + public readonly CSSStyleDeclaration = CSSStyleDeclaration; + public readonly EventTarget = EventTarget; + public readonly MessagePort = MessagePort; + public readonly DataTransfer = DataTransfer; + public readonly DataTransferItem = DataTransferItem; + public readonly DataTransferItemList = DataTransferItemList; + public readonly URL = URL; + public readonly Location = Location; + public readonly CustomElementRegistry = CustomElementRegistry; + public readonly Window = this.constructor; + public readonly XMLSerializer = XMLSerializer; + public readonly ResizeObserver = ResizeObserver; + public readonly CSSStyleSheet = CSSStyleSheet; + public readonly Blob = Blob; + public readonly File = File; + public readonly DOMException = DOMException; + public readonly History = History; + public readonly Screen = Screen; + public readonly Storage = Storage; + public readonly URLSearchParams = URLSearchParams; + public readonly HTMLCollection = HTMLCollection; + public readonly HTMLFormControlsCollection = HTMLFormControlsCollection; + public readonly NodeList = NodeList; + public readonly CSSUnitValue = CSSUnitValue; + public readonly CSSRule = CSSRule; + public readonly CSSContainerRule = CSSContainerRule; + public readonly CSSFontFaceRule = CSSFontFaceRule; + public readonly CSSKeyframeRule = CSSKeyframeRule; + public readonly CSSKeyframesRule = CSSKeyframesRule; + public readonly CSSMediaRule = CSSMediaRule; + public readonly CSSStyleRule = CSSStyleRule; + public readonly CSSSupportsRule = CSSSupportsRule; + public readonly Selection = Selection; + public readonly Navigator = Navigator; + public readonly MimeType = MimeType; + public readonly MimeTypeArray = MimeTypeArray; + public readonly Plugin = Plugin; + public readonly PluginArray = PluginArray; + public readonly FileList = FileList; + public readonly DOMRect = DOMRect; + public readonly RadioNodeList = RadioNodeList; + public readonly ValidityState = ValidityState; + public readonly Headers = Headers; + public readonly Request: new (input: IRequestInfo, init?: IRequestInit) => RequestImplementation; + public readonly Response: { + redirect: (url: string, status?: number) => ResponseImplementation; + error: () => ResponseImplementation; + json: (data: object, init?: IResponseInit) => ResponseImplementation; + new (body?: IResponseBody, init?: IResponseInit): ResponseImplementation; + }; + public readonly XMLHttpRequestUpload = XMLHttpRequestUpload; + public readonly XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; + public readonly ReadableStream = Stream.Readable; + public readonly WritableStream = Stream.Writable; + public readonly TransformStream = Stream.Transform; + public readonly AbortController = AbortController; + public readonly AbortSignal = AbortSignal; + public readonly FormData = FormData; + public readonly Permissions = Permissions; + public readonly PermissionStatus = PermissionStatus; + public readonly Clipboard = Clipboard; + public readonly ClipboardItem = ClipboardItem; + public readonly XMLHttpRequest: new () => XMLHttpRequestImplementation; + public readonly DOMParser: new () => DOMParserImplementation; + public readonly Range: new () => RangeImplementation; + public readonly FileReader: new () => FileReaderImplementation; + public readonly Image: typeof ImageImplementation; + public readonly DocumentFragment: typeof DocumentFragmentImplementation; + public readonly Audio: typeof AudioImplementation; + + // Events + public onload: ((event: Event) => void) | null = null; + public onerror: ((event: ErrorEvent) => void) | null = null; + + // Public properties. + public readonly document: DocumentImplementation; + public readonly customElements: CustomElementRegistry; + public readonly location: Location; + public readonly history: History; + public readonly navigator: Navigator; + public readonly console: Console; + public readonly self: IBrowserWindow = this; + public readonly top: IBrowserWindow = this; + public readonly parent: IBrowserWindow = this; + public readonly window: IBrowserWindow = this; + public readonly globalThis: IBrowserWindow = this; + public readonly screen: Screen; + public readonly sessionStorage: Storage; + public readonly localStorage: Storage; + public readonly performance = PerfHooks.performance; + public readonly screenLeft: number = 0; + public readonly screenTop: number = 0; + public readonly screenX: number = 0; + public readonly screenY: number = 0; + public readonly crypto = webcrypto; + public readonly closed = false; + public name = ''; + + // Node.js Globals + public Array: typeof Array; + public ArrayBuffer: typeof ArrayBuffer; + public Boolean: typeof Boolean; + public Buffer = Buffer; + public DataView: typeof DataView; + public Date: typeof Date; + public Error: typeof Error; + public EvalError: typeof EvalError; + public Float32Array: typeof Float32Array; + public Float64Array: typeof Float64Array; + public Function: typeof Function; + public Infinity: typeof Infinity; + public Int16Array: typeof Int16Array; + public Int32Array: typeof Int32Array; + public Int8Array: typeof Int8Array; + public Intl: typeof Intl; + public JSON: typeof JSON; + public Map: MapConstructor; + public Math: typeof Math; + public NaN: typeof NaN; + public Number: typeof Number; + public Object: typeof Object; + public Promise: typeof Promise; + public RangeError: typeof RangeError; + public ReferenceError: typeof ReferenceError; + public RegExp: typeof RegExp; + public Set: SetConstructor; + public String: typeof String; + public Symbol: Function; + public SyntaxError: typeof SyntaxError; + public TypeError: typeof TypeError; + public URIError: typeof URIError; + public Uint16Array: typeof Uint16Array; + public Uint32Array: typeof Uint32Array; + public Uint8Array: typeof Uint8Array; + public Uint8ClampedArray: typeof Uint8ClampedArray; + public WeakMap: WeakMapConstructor; + public WeakSet: WeakSetConstructor; + public decodeURI: typeof decodeURI; + public decodeURIComponent: typeof decodeURIComponent; + public encodeURI: typeof encodeURI; + public encodeURIComponent: typeof encodeURIComponent; + public eval: typeof eval; + /** + * @deprecated + */ + public escape: (str: string) => string; + public global: typeof globalThis; + public isFinite: typeof isFinite; + public isNaN: typeof isNaN; + public parseFloat: typeof parseFloat; + public parseInt: typeof parseInt; + public undefined: typeof undefined; + /** + * @deprecated + */ + public unescape: (str: string) => string; + public gc: () => void; + public v8debug?: unknown; + + // Public internal properties + + // Used for tracking capture event listeners to improve performance when they are not used. + // See EventTarget class. + public [PropertySymbol.captureEventListenerCount]: { [eventType: string]: number } = {}; + public readonly [PropertySymbol.mutationObservers]: MutationObserver[] = []; + public readonly [PropertySymbol.readyStateManager] = new DocumentReadyStateManager(this); + + // Private properties + #browserFrame: IBrowserFrame; + #innerWidth: number | null = null; + #innerHeight: number | null = null; + #outerWidth: number | null = null; + #outerHeight: number | null = null; + #devicePixelRatio: number | null = null; + + /** + * Constructor. + * + * @param browserFrame Browser frame. + * @param [options] Options. + * @param [options.url] URL. + */ + constructor(browserFrame: IBrowserFrame, options?: { url?: string }) { + super(); + + this.#browserFrame = browserFrame; + + this.customElements = new CustomElementRegistry(); + this.navigator = new Navigator(this); + this.history = new History(); + this.screen = new Screen(); + this.sessionStorage = new Storage(); + this.localStorage = new Storage(); + this.location = new Location(this.#browserFrame, options?.url ?? 'about:blank'); + this.console = browserFrame.page.console; + + WindowBrowserSettingsReader.setSettings(this, this.#browserFrame.page.context.browser.settings); + + // Binds all methods to "this", so that it will use the correct context when called globally. + for (const key of Object.getOwnPropertyNames(BrowserWindow.prototype).concat( + Object.getOwnPropertyNames(EventTarget.prototype) + )) { + if ( + key !== 'constructor' && + key[0] !== '_' && + key[0] === key[0].toLowerCase() && + typeof this[key] === 'function' && + !this[key].toString().startsWith('class ') + ) { + this[key] = this[key].bind(this); + } + } + + const window = this; + const asyncTaskManager = this.#browserFrame[PropertySymbol.asyncTaskManager]; + + this[PropertySymbol.setupVMContext](); + + // Class overrides + // For classes that need to be bound to the correct context. + + /* eslint-disable jsdoc/require-jsdoc */ + + class Request extends RequestImplementation { + constructor(input: IRequestInfo, init?: IRequestInit) { + super({ window, asyncTaskManager }, input, init); + } + } + class Response extends ResponseImplementation { + protected static [PropertySymbol.window] = window; + constructor(body?: IResponseBody, init?: IResponseInit) { + super({ window, browserFrame }, body, init); + } + } + class XMLHttpRequest extends XMLHttpRequestImplementation { + constructor() { + super({ window, browserFrame }); + } + } + class FileReader extends FileReaderImplementation { + constructor() { + super(window); + } + } + class DOMParser extends DOMParserImplementation { + constructor() { + super(window); + } + } + class Range extends RangeImplementation { + constructor() { + super(window); + } + } + class HTMLScriptElement extends HTMLScriptElementImplementation { + constructor() { + super(browserFrame); + } + } + class HTMLLinkElement extends HTMLLinkElementImplementation { + constructor() { + super(browserFrame); + } + } + class HTMLIFrameElement extends HTMLIFrameElementImplementation { + constructor() { + super(browserFrame); + } + } + class Document extends DocumentImplementation { + constructor() { + super({ window, browserFrame }); + } + } + class HTMLDocument extends HTMLDocumentImplementation { + constructor() { + super({ window, browserFrame }); + } + } + class XMLDocument extends XMLDocumentImplementation { + constructor() { + super({ window, browserFrame }); + } + } + class SVGDocument extends SVGDocumentImplementation { + constructor() { + super({ window, browserFrame }); + } + } + + class Audio extends AudioImplementation {} + class Image extends ImageImplementation {} + class DocumentFragment extends DocumentFragmentImplementation {} + + /* eslint-enable jsdoc/require-jsdoc */ + + this.Response = Response; + this.Request = Request; + this.Image = Image; + this.DocumentFragment = DocumentFragment; + this.FileReader = FileReader; + this.DOMParser = DOMParser; + this.XMLHttpRequest = XMLHttpRequest; + this.Range = Range; + this.Audio = Audio; + this.HTMLScriptElement = HTMLScriptElement; + this.HTMLLinkElement = HTMLLinkElement; + this.HTMLIFrameElement = HTMLIFrameElement; + this.Document = Document; + this.HTMLDocument = HTMLDocument; + this.XMLDocument = XMLDocument; + this.SVGDocument = SVGDocument; + + // Override owner document + this.Document[PropertySymbol.ownerDocument] = null; + this.HTMLDocument[PropertySymbol.ownerDocument] = null; + this.XMLDocument[PropertySymbol.ownerDocument] = null; + this.SVGDocument[PropertySymbol.ownerDocument] = null; + + // Document + this.document = new HTMLDocument(); + this.document[PropertySymbol.defaultView] = this; + + // Override owner document + this.Audio[PropertySymbol.ownerDocument] = this.document; + this.Image[PropertySymbol.ownerDocument] = this.document; + this.DocumentFragment[PropertySymbol.ownerDocument] = this.document; + + // Ready state manager + this[PropertySymbol.readyStateManager].waitUntilComplete().then(() => { + this.document[PropertySymbol.readyState] = DocumentReadyStateEnum.complete; + this.document.dispatchEvent(new Event('readystatechange')); + this.document.dispatchEvent(new Event('load', { bubbles: true })); + }); + } + + /** + * Returns opener. + * + * @returns Opener. + */ + public get opener(): IBrowserWindow | ICrossOriginBrowserWindow | null { + return this.#browserFrame[PropertySymbol.openerWindow]; + } + + /** + * The number of pixels that the document is currently scrolled horizontally. + * + * @returns Scroll X. + */ + public get scrollX(): number { + return this.document?.documentElement?.scrollLeft ?? 0; + } + + /** + * The read-only Window property pageXOffset is an alias for scrollX. + * + * @returns Scroll X. + */ + public get pageXOffset(): number { + return this.scrollX; + } + + /** + * The number of pixels that the document is currently scrolled vertically. + * + * @returns Scroll Y. + */ + public get scrollY(): number { + return this.document?.documentElement?.scrollTop ?? 0; + } + + /** + * The read-only Window property pageYOffset is an alias for scrollY. + * + * @returns Scroll Y. + */ + public get pageYOffset(): number { + return this.scrollY; + } + + /** + * The CSS interface holds useful CSS-related methods. + * + * @returns CSS interface. + */ + public get CSS(): CSS { + return new CSS(); + } + + /** + * Returns inner width. + * + * @returns Inner width. + */ + public get innerWidth(): number { + if (this.#innerWidth === null) { + return this.#browserFrame.page.viewport.width; + } + return this.#innerWidth; + } + + /** + * Sets inner width. + * + * @param value Inner width. + */ + public set innerWidth(value: number) { + this.#innerWidth = value; + } + + /** + * Returns inner height. + * + * @returns Inner height. + */ + public get innerHeight(): number { + // It seems like this value can be defined according to spec, but changing it has no effect on the actual viewport. + if (this.#innerHeight === null) { + return this.#browserFrame.page.viewport.height; + } + return this.#innerHeight; + } + + /** + * Sets inner height. + * + * @param value Inner height. + */ + public set innerHeight(value: number) { + this.#innerHeight = value; + } + + /** + * Returns outer width. + * + * @returns Outer width. + */ + public get outerWidth(): number { + // It seems like this value can be defined according to spec, but changing it has no effect on the actual viewport. + if (this.#outerWidth === null) { + return this.#browserFrame.page.viewport.width; + } + return this.#outerWidth; + } + + /** + * Sets outer width. + * + * @param value Outer width. + */ + public set outerWidth(value: number) { + this.#outerWidth = value; + } + + /** + * Returns outer height. + * + * @returns Outer height. + */ + public get outerHeight(): number { + if (this.#outerHeight === null) { + return this.#browserFrame.page.viewport.height; + } + return this.#outerHeight; + } + + /** + * Sets outer height. + * + * @param value Outer height. + */ + public set outerHeight(value: number) { + this.#outerHeight = value; + } + + /** + * Returns device pixel ratio. + * + * @returns Device pixel ratio. + */ + public get devicePixelRatio(): number { + // It seems like this value can be defined according to spec, but changing it has no effect on the actual viewport. + if (this.#devicePixelRatio === null) { + return this.#browserFrame.page.viewport.devicePixelRatio; + } + return this.#devicePixelRatio; + } + + /** + * Sets device pixel ratio. + * + * @param value Device pixel ratio. + */ + public set devicePixelRatio(value: number) { + this.#devicePixelRatio = value; + } + + /** + * Returns an object containing the values of all CSS properties of an element. + * + * @param element Element. + * @returns CSS style declaration. + */ + public getComputedStyle(element: IElement): CSSStyleDeclaration { + element[PropertySymbol.computedStyle] = + element[PropertySymbol.computedStyle] || new CSSStyleDeclaration(element, true); + return element[PropertySymbol.computedStyle]; + } + + /** + * Returns selection. + * + * @returns Selection. + */ + public getSelection(): Selection { + return this.document.getSelection(); + } + + /** + * Scrolls to a particular set of coordinates. + * + * @param x X position or options object. + * @param y Y position. + */ + public scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void { + if (typeof x === 'object') { + if (x.behavior === 'smooth') { + this.setTimeout(() => { + if (x.top !== undefined) { + (this.document.documentElement.scrollTop) = x.top; + } + if (x.left !== undefined) { + (this.document.documentElement.scrollLeft) = x.left; + } + }); + } else { + if (x.top !== undefined) { + (this.document.documentElement.scrollTop) = x.top; + } + if (x.left !== undefined) { + (this.document.documentElement.scrollLeft) = x.left; + } + } + } else if (x !== undefined && y !== undefined) { + (this.document.documentElement.scrollLeft) = x; + (this.document.documentElement.scrollTop) = y; + } + } + + /** + * Scrolls to a particular set of coordinates. + * + * @param x X position or options object. + * @param y Y position. + */ + public scrollTo( + x: { top?: number; left?: number; behavior?: string } | number, + y?: number + ): void { + this.scroll(x, y); + } + + /** + * Shifts focus away from the window. + */ + public blur(): void { + // TODO: Implement. + } + + /** + * Gives focus to the window. + */ + public focus(): void { + // TODO: Implement. + } + + /** + * Loads a specified resource into a new or existing browsing context (that is, a tab, a window, or an iframe) under a specified name. + * + * @param [url] URL. + * @param [target] Target. + * @param [features] Window features. + * @returns Window. + */ + public open( + url?: string, + target?: string, + features?: string + ): IBrowserWindow | ICrossOriginBrowserWindow | null { + return WindowPageOpenUtility.openPage(this.#browserFrame, { + url, + target, + features + }); + } + + /** + * Closes the window. + */ + public close(): void { + // When using a Window instance directly, the Window instance is the main frame and we will close the page and destroy the browser. + // When using the Browser API we should only close the page when the Window instance is connected to the main frame (we should not close child frames such as iframes). + if (this.#browserFrame.page?.mainFrame === this.#browserFrame) { + this.#browserFrame.page.close(); + } + } + + /** + * Returns a new MediaQueryList object that can then be used to determine if the document matches the media query string. + * + * @param mediaQueryString A string specifying the media query to parse into a MediaQueryList. + * @returns A new MediaQueryList. + */ + public matchMedia(mediaQueryString: string): MediaQueryList { + return new MediaQueryList({ ownerWindow: this, media: mediaQueryString }); + } + + /** + * Sets a timer which executes a function once the timer expires. + * + * @param callback Function to be executed. + * @param [delay=0] Delay in ms. + * @param args Arguments passed to the callback function. + * @returns Timeout ID. + */ + public setTimeout(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { + const settings = this.#browserFrame.page?.context?.browser?.settings; + const useTryCatch = + !settings || + !settings.disableErrorCapturing || + settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; + const id = TIMER.setTimeout(() => { + if (useTryCatch) { + WindowErrorUtility.captureError(this, () => callback(...args)); + } else { + callback(...args); + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); + }, delay); + this.#browserFrame[PropertySymbol.asyncTaskManager].startTimer(id); + return id; + } + + /** + * Cancels a timeout previously established by calling setTimeout(). + * + * @param id ID of the timeout. + */ + public clearTimeout(id: NodeJS.Timeout): void { + // We need to make sure that the ID is a Timeout object, otherwise Node.js might throw an error. + // This is only necessary if we are in a Node.js environment. + if (IS_NODE_JS_TIMEOUT_ENVIRONMENT && (!id || id.constructor.name !== 'Timeout')) { + return; + } + TIMER.clearTimeout(id); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); + } + + /** + * Calls a function with a fixed time delay between each call. + * + * @param callback Function to be executed. + * @param [delay=0] Delay in ms. + * @param args Arguments passed to the callback function. + * @returns Interval ID. + */ + public setInterval(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { + const settings = this.#browserFrame.page?.context?.browser?.settings; + const useTryCatch = + !settings || + !settings.disableErrorCapturing || + settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; + const id = TIMER.setInterval(() => { + if (useTryCatch) { + WindowErrorUtility.captureError( + this, + () => callback(...args), + () => this.clearInterval(id) + ); + } else { + callback(...args); + } + }, delay); + this.#browserFrame[PropertySymbol.asyncTaskManager].startTimer(id); + return id; + } + + /** + * Cancels a timed repeating action which was previously established by a call to setInterval(). + * + * @param id ID of the interval. + */ + public clearInterval(id: NodeJS.Timeout): void { + // We need to make sure that the ID is a Timeout object, otherwise Node.js might throw an error. + // This is only necessary if we are in a Node.js environment. + if (IS_NODE_JS_TIMEOUT_ENVIRONMENT && (!id || id.constructor.name !== 'Timeout')) { + return; + } + TIMER.clearInterval(id); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); + } + + /** + * Mock animation frames with timeouts. + * + * @param callback Callback. + * @returns ID. + */ + public requestAnimationFrame(callback: (timestamp: number) => void): NodeJS.Immediate { + const settings = this.#browserFrame.page?.context?.browser?.settings; + const useTryCatch = + !settings || + !settings.disableErrorCapturing || + settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; + const id = TIMER.setImmediate(() => { + if (useTryCatch) { + WindowErrorUtility.captureError(this, () => callback(this.performance.now())); + } else { + callback(this.performance.now()); + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endImmediate(id); + }); + this.#browserFrame[PropertySymbol.asyncTaskManager].startImmediate(id); + return id; + } + + /** + * Mock animation frames with timeouts. + * + * @param id ID. + */ + public cancelAnimationFrame(id: NodeJS.Immediate): void { + // We need to make sure that the ID is an Immediate object, otherwise Node.js might throw an error. + // This is only necessary if we are in a Node.js environment. + if (IS_NODE_JS_TIMEOUT_ENVIRONMENT && (!id || id.constructor.name !== 'Immediate')) { + return; + } + TIMER.clearImmediate(id); + this.#browserFrame[PropertySymbol.asyncTaskManager].endImmediate(id); + } + + /** + * Queues a microtask to be executed at a safe time prior to control returning to the browser's event loop. + * + * @param callback Function to be executed. + */ + public queueMicrotask(callback: Function): void { + let isAborted = false; + const taskId = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask( + () => (isAborted = true) + ); + const settings = this.#browserFrame.page?.context?.browser?.settings; + const useTryCatch = + !settings || + !settings.disableErrorCapturing || + settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; + TIMER.queueMicrotask(() => { + if (!isAborted) { + if (useTryCatch) { + WindowErrorUtility.captureError(this, <() => unknown>callback); + } else { + callback(); + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskId); + } + }); + } + + /** + * This method provides an easy, logical way to fetch resources asynchronously across the network. + * + * @param url URL. + * @param [init] Init. + * @returns Promise. + */ + public async fetch(url: RequestInfo, init?: IRequestInit): Promise { + return await new Fetch({ + browserFrame: this.#browserFrame, + window: this, + url, + init + }).send(); + } + + /** + * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa + * @param data Binay data. + * @returns Base64-encoded string. + */ + public btoa(data: unknown): string { + return Base64.btoa(data); + } + + /** + * Decodes a string of data which has been encoded using Base64 encoding. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/atob + * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. + * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. + * @param data Binay string. + * @returns An ASCII string containing decoded data from encodedData. + */ + public atob(data: unknown): string { + return Base64.atob(data); + } + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param [targetOrigin=*] Target origin. + * @param _transfer Transfer. Not implemented. + */ + public postMessage(message: unknown, targetOrigin = '*', _transfer?: unknown[]): void { + // TODO: Implement transfer. + + if (targetOrigin && targetOrigin !== '*' && this.location.origin !== targetOrigin) { + throw new DOMException( + `Failed to execute 'postMessage' on 'Window': The target origin provided ('${targetOrigin}') does not match the recipient window\'s origin ('${this.location.origin}').`, + DOMExceptionNameEnum.securityError + ); + } + + try { + JSON.stringify(message); + } catch (error) { + throw new DOMException( + `Failed to execute 'postMessage' on 'Window': The provided message cannot be serialized.`, + DOMExceptionNameEnum.invalidStateError + ); + } + + this.setTimeout(() => + this.dispatchEvent( + new MessageEvent('message', { + data: message, + origin: this.#browserFrame.parentFrame + ? this.#browserFrame.parentFrame.window.location.origin + : this.#browserFrame.window.location.origin, + source: this.#browserFrame.parentFrame + ? this.#browserFrame.parentFrame.window + : this.#browserFrame.window, + lastEventId: '' + }) + ) + ); + } + + /** + * Resizes the window. + * + * @param width Width. + * @param height Height. + */ + public resizeTo(width: number, height: number): void { + if (!width || !height) { + throw new DOMException( + `Failed to execute 'resizeTo' on 'Window': 2 arguments required, but only ${arguments.length} present.` + ); + } + + // We can only resize the window if it is a popup. + if (this.#browserFrame[PropertySymbol.popup]) { + this.#browserFrame.page.setViewport({ width, height }); + } + } + + /** + * Resizes the current window by a specified amount. + * + * @param width Width. + * @param height Height. + */ + public resizeBy(width: number, height: number): void { + if (!width || !height) { + throw new DOMException( + `Failed to execute 'resizeBy' on 'Window': 2 arguments required, but only ${arguments.length} present.` + ); + } + + // We can only resize the window if it is a popup. + if (this.#browserFrame[PropertySymbol.popup]) { + const viewport = this.#browserFrame.page.viewport; + this.#browserFrame.page.setViewport({ + width: viewport.width + width, + height: viewport.height + height + }); + } + } + + /** + * Setup of VM context. + */ + protected [PropertySymbol.setupVMContext](): void { + if (!VM.isContext(this)) { + VM.createContext(this); + + // Sets global properties from the VM to the Window object. + // Otherwise "this.Array" will be undefined for example. + VMGlobalPropertyScript.runInContext(this); + } + } + + /** + * Destroys the window. + */ + public [PropertySymbol.destroy](): void { + (this.closed) = true; + this.Audio[PropertySymbol.ownerDocument] = null; + this.Image[PropertySymbol.ownerDocument] = null; + this.DocumentFragment[PropertySymbol.ownerDocument] = null; + for (const mutationObserver of this[PropertySymbol.mutationObservers]) { + mutationObserver.disconnect(); + } + + // Disconnects nodes from the document, so that they can be garbage collected. + for (const node of this.document[PropertySymbol.childNodes].slice()) { + this.document.removeChild(node); + } + + this.document[PropertySymbol.activeElement] = null; + this.document[PropertySymbol.nextActiveElement] = null; + this.document[PropertySymbol.currentScript] = null; + this.document[PropertySymbol.selection] = null; + + WindowBrowserSettingsReader.removeSettings(this); + } +} diff --git a/packages/happy-dom/src/window/CrossOriginBrowserWindow.ts b/packages/happy-dom/src/window/CrossOriginBrowserWindow.ts new file mode 100644 index 000000000..1f24c8683 --- /dev/null +++ b/packages/happy-dom/src/window/CrossOriginBrowserWindow.ts @@ -0,0 +1,102 @@ +import EventTarget from '../event/EventTarget.js'; +import IBrowserWindow from './IBrowserWindow.js'; +import DOMException from '../exception/DOMException.js'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; +import Location from '../location/Location.js'; +import ICrossOriginBrowserWindow from './ICrossOriginBrowserWindow.js'; + +/** + * Browser window with limited access due to CORS restrictions in iframes. + */ +export default class CrossOriginBrowserWindow + extends EventTarget + implements ICrossOriginBrowserWindow +{ + public readonly self = this; + public readonly window = this; + public readonly parent: IBrowserWindow | ICrossOriginBrowserWindow; + public readonly top: IBrowserWindow | ICrossOriginBrowserWindow; + public readonly location: Location; + #targetWindow: IBrowserWindow; + + /** + * Constructor. + * + * @param target Target window. + * @param [parent] Parent window. + */ + constructor(target: IBrowserWindow, parent?: IBrowserWindow) { + super(); + + this.parent = parent ?? this; + this.top = parent ?? this; + this.location = new Proxy( + {}, + { + get: () => { + throw new DOMException( + `Blocked a frame with origin "${this.parent.location.origin}" from accessing a cross-origin frame.`, + DOMExceptionNameEnum.securityError + ); + }, + set: () => { + throw new DOMException( + `Blocked a frame with origin "${this.parent.location.origin}" from accessing a cross-origin frame.`, + DOMExceptionNameEnum.securityError + ); + } + } + ); + this.#targetWindow = target; + } + + /** + * Returns the opener. + * + * @returns Opener. + */ + public get opener(): IBrowserWindow | ICrossOriginBrowserWindow | null { + return this.#targetWindow.opener; + } + + /** + * Returns the closed state. + * + * @returns Closed state. + */ + public get closed(): boolean { + return this.#targetWindow.closed; + } + + /** + * Shifts focus away from the window. + */ + public blur(): void { + this.#targetWindow.blur(); + } + + /** + * Gives focus to the window. + */ + public focus(): void { + this.#targetWindow.focus(); + } + + /** + * Closes the window. + */ + public close(): void { + this.#targetWindow.close(); + } + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param [targetOrigin=*] Target origin. + * @param transfer Transfer. Not implemented. + */ + public postMessage(message: unknown, targetOrigin = '*', transfer?: unknown[]): void { + this.#targetWindow.postMessage(message, targetOrigin, transfer); + } +} diff --git a/packages/happy-dom/src/window/DetachedWindowAPI.ts b/packages/happy-dom/src/window/DetachedWindowAPI.ts new file mode 100644 index 000000000..a645ceed7 --- /dev/null +++ b/packages/happy-dom/src/window/DetachedWindowAPI.ts @@ -0,0 +1,126 @@ +import VirtualConsolePrinter from '../console/VirtualConsolePrinter.js'; +import IOptionalBrowserPageViewport from '../browser/types/IOptionalBrowserPageViewport.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import IBrowserSettings from '../browser/types/IBrowserSettings.js'; + +/** + * API for detached windows to be able to access features of the browser. + */ +export default class DetachedWindowAPI { + #browserFrame?: IBrowserFrame; + + /** + * Constructor. + * + * @param browserFrame Browser frame. + */ + constructor(browserFrame: IBrowserFrame) { + this.#browserFrame = browserFrame; + } + + /** + * Returns settings. + * + * @returns Settings. + */ + public get settings(): IBrowserSettings { + return this.#browserFrame.page.context.browser.settings; + } + + /** + * Returns virtual console printer. + * + * @returns Virtual console printer. + */ + public get virtualConsolePrinter(): VirtualConsolePrinter { + return this.#browserFrame.page.virtualConsolePrinter; + } + + /** + * Waits for all async tasks to complete. + * + * @returns Promise. + */ + public waitUntilComplete(): Promise { + return this.#browserFrame.waitUntilComplete(); + } + + /** + * Waits for all async tasks to complete. + * + * @deprecated Use waitUntilComplete() instead. + * @returns Promise. + */ + public whenAsyncComplete(): Promise { + return this.waitUntilComplete(); + } + + /** + * Aborts all async tasks. + */ + public abort(): Promise { + return this.#browserFrame.abort(); + } + + /** + * Aborts all async tasks. + * + * @deprecated Use abort() instead. + */ + public cancelAsync(): Promise { + return this.abort(); + } + + /** + * Sets the URL without navigating the browser. + * + * @param url URL. + */ + public setURL(url: string): void { + this.#browserFrame.url = url; + } + + /** + * Sets the viewport. + * + * @param viewport Viewport. + */ + public setViewport(viewport: IOptionalBrowserPageViewport): void { + this.#browserFrame.page.setViewport(viewport); + } + + /** + * Sets the window size. + * + * @deprecated Use setViewport() instead. + * @param options Options. + * @param options.width Width. + * @param options.height Height. + */ + public setWindowSize(options: { width?: number; height?: number }): void { + this.setViewport({ + width: options?.width, + height: options?.height + }); + } + + /** + * Sets the window width. + * + * @deprecated Use setViewport() instead. + * @param width Width. + */ + public setInnerWidth(width: number): void { + this.setViewport({ width }); + } + + /** + * Sets the window height. + * + * @deprecated Use setViewport() instead. + * @param height Height. + */ + public setInnerHeight(height: number): void { + this.setViewport({ height }); + } +} diff --git a/packages/happy-dom/src/window/GlobalWindow.ts b/packages/happy-dom/src/window/GlobalWindow.ts index ddde55fca..0769e3eb3 100644 --- a/packages/happy-dom/src/window/GlobalWindow.ts +++ b/packages/happy-dom/src/window/GlobalWindow.ts @@ -1,4 +1,5 @@ import IWindow from './IWindow.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import Window from './Window.js'; import { Buffer } from 'buffer'; @@ -73,7 +74,7 @@ export default class GlobalWindow extends Window implements IWindow { /** * Setup of VM context. */ - protected override _setupVMContext(): void { + protected override [PropertySymbol.setupVMContext](): void { // Do nothing } } diff --git a/packages/happy-dom/src/window/IBrowserWindow.ts b/packages/happy-dom/src/window/IBrowserWindow.ts new file mode 100644 index 000000000..3df28a7ee --- /dev/null +++ b/packages/happy-dom/src/window/IBrowserWindow.ts @@ -0,0 +1,591 @@ +import CustomElementRegistry from '../custom-element/CustomElementRegistry.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import Document from '../nodes/document/Document.js'; +import IDocument from '../nodes/document/IDocument.js'; +import HTMLDocument from '../nodes/html-document/HTMLDocument.js'; +import XMLDocument from '../nodes/xml-document/XMLDocument.js'; +import SVGDocument from '../nodes/svg-document/SVGDocument.js'; +import DocumentType from '../nodes/document-type/DocumentType.js'; +import Node from '../nodes/node/Node.js'; +import Text from '../nodes/text/Text.js'; +import Comment from '../nodes/comment/Comment.js'; +import ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; +import Element from '../nodes/element/Element.js'; +import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement.js'; +import HTMLFormElement from '../nodes/html-form-element/HTMLFormElement.js'; +import HTMLElement from '../nodes/html-element/HTMLElement.js'; +import HTMLUnknownElement from '../nodes/html-unknown-element/HTMLUnknownElement.js'; +import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; +import HTMLSelectElement from '../nodes/html-select-element/HTMLSelectElement.js'; +import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement.js'; +import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; +import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement.js'; +import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement.js'; +import HTMLLabelElement from '../nodes/html-label-element/HTMLLabelElement.js'; +import HTMLMetaElement from '../nodes/html-meta-element/HTMLMetaElement.js'; +import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement.js'; +import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement.js'; +import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement.js'; +import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement.js'; +import HTMLIFrameElement from '../nodes/html-iframe-element/HTMLIFrameElement.js'; +import SVGSVGElement from '../nodes/svg-element/SVGSVGElement.js'; +import SVGElement from '../nodes/svg-element/SVGElement.js'; +import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; +import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement.js'; +import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement.js'; +import Image from '../nodes/html-image-element/Image.js'; +import Audio from '../nodes/html-audio-element/Audio.js'; +import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; +import CharacterData from '../nodes/character-data/CharacterData.js'; +import NodeIterator from '../tree-walker/NodeIterator.js'; +import TreeWalker from '../tree-walker/TreeWalker.js'; +import Event from '../event/Event.js'; +import CustomEvent from '../event/events/CustomEvent.js'; +import AnimationEvent from '../event/events/AnimationEvent.js'; +import KeyboardEvent from '../event/events/KeyboardEvent.js'; +import ProgressEvent from '../event/events/ProgressEvent.js'; +import MediaQueryListEvent from '../event/events/MediaQueryListEvent.js'; +import EventTarget from '../event/EventTarget.js'; +import { URLSearchParams } from 'url'; +import URL from '../url/URL.js'; +import Location from '../location/Location.js'; +import MutationObserver from '../mutation-observer/MutationObserver.js'; +import MutationRecord from '../mutation-observer/MutationRecord.js'; +import DOMParser from '../dom-parser/DOMParser.js'; +import XMLSerializer from '../xml-serializer/XMLSerializer.js'; +import ResizeObserver from '../resize-observer/ResizeObserver.js'; +import Blob from '../file/Blob.js'; +import File from '../file/File.js'; +import DOMException from '../exception/DOMException.js'; +import FileReader from '../file/FileReader.js'; +import History from '../history/History.js'; +import CSSStyleSheet from '../css/CSSStyleSheet.js'; +import CSSStyleDeclaration from '../css/declaration/CSSStyleDeclaration.js'; +import CSS from '../css/CSS.js'; +import CSSUnitValue from '../css/CSSUnitValue.js'; +import CSSRule from '../css/CSSRule.js'; +import CSSContainerRule from '../css/rules/CSSContainerRule.js'; +import CSSFontFaceRule from '../css/rules/CSSFontFaceRule.js'; +import CSSKeyframeRule from '../css/rules/CSSKeyframeRule.js'; +import CSSKeyframesRule from '../css/rules/CSSKeyframesRule.js'; +import CSSMediaRule from '../css/rules/CSSMediaRule.js'; +import CSSStyleRule from '../css/rules/CSSStyleRule.js'; +import CSSSupportsRule from '../css/rules/CSSSupportsRule.js'; +import PointerEvent from '../event/events/PointerEvent.js'; +import MouseEvent from '../event/events/MouseEvent.js'; +import FocusEvent from '../event/events/FocusEvent.js'; +import WheelEvent from '../event/events/WheelEvent.js'; +import DataTransfer from '../event/DataTransfer.js'; +import DataTransferItem from '../event/DataTransferItem.js'; +import DataTransferItemList from '../event/DataTransferItemList.js'; +import InputEvent from '../event/events/InputEvent.js'; +import UIEvent from '../event/UIEvent.js'; +import ErrorEvent from '../event/events/ErrorEvent.js'; +import StorageEvent from '../event/events/StorageEvent.js'; +import SubmitEvent from '../event/events/SubmitEvent.js'; +import MessageEvent from '../event/events/MessageEvent.js'; +import MessagePort from '../event/MessagePort.js'; +import Screen from '../screen/Screen.js'; +import Storage from '../storage/Storage.js'; +import NodeFilter from '../tree-walker/NodeFilter.js'; +import HTMLCollection from '../nodes/element/HTMLCollection.js'; +import HTMLFormControlsCollection from '../nodes/html-form-element/HTMLFormControlsCollection.js'; +import NodeList from '../nodes/node/NodeList.js'; +import Selection from '../selection/Selection.js'; +import IEventTarget from '../event/IEventTarget.js'; +import Navigator from '../navigator/Navigator.js'; +import MimeType from '../navigator/MimeType.js'; +import MimeTypeArray from '../navigator/MimeTypeArray.js'; +import Plugin from '../navigator/Plugin.js'; +import PluginArray from '../navigator/PluginArray.js'; +import IRequestInit from '../fetch/types/IRequestInit.js'; +import IResponse from '../fetch/types/IResponse.js'; +import Range from '../range/Range.js'; +import MediaQueryList from '../match-media/MediaQueryList.js'; +import XMLHttpRequest from '../xml-http-request/XMLHttpRequest.js'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload.js'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget.js'; +import DOMRect from '../nodes/element/DOMRect.js'; +import Attr from '../nodes/attr/Attr.js'; +import NamedNodeMap from '../named-node-map/NamedNodeMap.js'; +import { Performance } from 'perf_hooks'; +import IElement from '../nodes/element/IElement.js'; +import SVGGraphicsElement from '../nodes/svg-element/SVGGraphicsElement.js'; +import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction.js'; +import RequestInfo from '../fetch/types/IRequestInfo.js'; +import FileList from '../nodes/html-input-element/FileList.js'; +import Stream from 'stream'; +import { webcrypto } from 'crypto'; +import FormData from '../form-data/FormData.js'; +import AbortController from '../fetch/AbortController.js'; +import AbortSignal from '../fetch/AbortSignal.js'; +import RadioNodeList from '../nodes/html-form-element/RadioNodeList.js'; +import ValidityState from '../validity-state/ValidityState.js'; +import INodeJSGlobal from './INodeJSGlobal.js'; +import ICrossOriginBrowserWindow from './ICrossOriginBrowserWindow.js'; +import Permissions from '../permissions/Permissions.js'; +import PermissionStatus from '../permissions/PermissionStatus.js'; +import Clipboard from '../clipboard/Clipboard.js'; +import ClipboardItem from '../clipboard/ClipboardItem.js'; +import ClipboardEvent from '../event/events/ClipboardEvent.js'; +import Headers from '../fetch/Headers.js'; +import Request from '../fetch/Request.js'; +import Response from '../fetch/Response.js'; +import HTMLAnchorElement from '../nodes/html-anchor-element/HTMLAnchorElement.js'; +import HTMLButtonElement from '../nodes/html-button-element/HTMLButtonElement.js'; +import HTMLOptGroupElement from '../nodes/html-opt-group-element/HTMLOptGroupElement.js'; +import HTMLOptionElement from '../nodes/html-option-element/HTMLOptionElement.js'; +import IResponseBody from '../fetch/types/IResponseBody.js'; +import IResponseInit from '../fetch/types/IResponseInit.js'; +import BrowserWindow from './BrowserWindow.js'; + +/** + * Window without dependencies to server side specific packages. + */ +export default interface IBrowserWindow extends IEventTarget, INodeJSGlobal { + // Nodes + readonly Node: typeof Node; + readonly Attr: typeof Attr; + readonly SVGSVGElement: typeof SVGSVGElement; + readonly SVGElement: typeof SVGElement; + readonly SVGGraphicsElement: typeof SVGGraphicsElement; + readonly Text: typeof Text; + readonly Comment: typeof Comment; + readonly ShadowRoot: typeof ShadowRoot; + readonly Element: typeof Element; + readonly DocumentFragment: typeof DocumentFragment; + readonly CharacterData: typeof CharacterData; + readonly ProcessingInstruction: typeof ProcessingInstruction; + readonly Document: new () => Document; + readonly HTMLDocument: new () => HTMLDocument; + readonly XMLDocument: new () => XMLDocument; + readonly SVGDocument: new () => SVGDocument; + readonly DocumentType: typeof DocumentType; + + // Element classes + readonly HTMLAnchorElement: typeof HTMLAnchorElement; + readonly HTMLButtonElement: typeof HTMLButtonElement; + readonly HTMLOptGroupElement: typeof HTMLOptGroupElement; + readonly HTMLOptionElement: typeof HTMLOptionElement; + readonly HTMLElement: typeof HTMLElement; + readonly HTMLUnknownElement: typeof HTMLUnknownElement; + readonly HTMLTemplateElement: typeof HTMLTemplateElement; + readonly HTMLFormElement: typeof HTMLFormElement; + readonly HTMLInputElement: typeof HTMLInputElement; + readonly HTMLSelectElement: typeof HTMLSelectElement; + readonly HTMLTextAreaElement: typeof HTMLTextAreaElement; + readonly HTMLImageElement: typeof HTMLImageElement; + readonly HTMLScriptElement: typeof HTMLScriptElement; + readonly HTMLLinkElement: typeof HTMLLinkElement; + readonly HTMLStyleElement: typeof HTMLStyleElement; + readonly HTMLSlotElement: typeof HTMLSlotElement; + readonly HTMLLabelElement: typeof HTMLLabelElement; + readonly HTMLMetaElement: typeof HTMLMetaElement; + readonly HTMLMediaElement: typeof HTMLMediaElement; + readonly HTMLAudioElement: typeof HTMLAudioElement; + readonly HTMLVideoElement: typeof HTMLVideoElement; + readonly HTMLBaseElement: typeof HTMLBaseElement; + readonly HTMLIFrameElement: typeof HTMLIFrameElement; + readonly HTMLDialogElement: typeof HTMLDialogElement; + + /** + * Non-implemented element classes + */ + readonly HTMLHeadElement: typeof HTMLElement; + readonly HTMLTitleElement: typeof HTMLElement; + readonly HTMLBodyElement: typeof HTMLElement; + readonly HTMLHeadingElement: typeof HTMLElement; + readonly HTMLParagraphElement: typeof HTMLElement; + readonly HTMLHRElement: typeof HTMLElement; + readonly HTMLPreElement: typeof HTMLElement; + readonly HTMLUListElement: typeof HTMLElement; + readonly HTMLOListElement: typeof HTMLElement; + readonly HTMLLIElement: typeof HTMLElement; + readonly HTMLMenuElement: typeof HTMLElement; + readonly HTMLDListElement: typeof HTMLElement; + readonly HTMLDivElement: typeof HTMLElement; + readonly HTMLAreaElement: typeof HTMLElement; + readonly HTMLBRElement: typeof HTMLElement; + readonly HTMLCanvasElement: typeof HTMLElement; + readonly HTMLDataElement: typeof HTMLElement; + readonly HTMLDataListElement: typeof HTMLElement; + readonly HTMLDetailsElement: typeof HTMLElement; + readonly HTMLDirectoryElement: typeof HTMLElement; + readonly HTMLFieldSetElement: typeof HTMLElement; + readonly HTMLFontElement: typeof HTMLElement; + readonly HTMLHtmlElement: typeof HTMLElement; + readonly HTMLLegendElement: typeof HTMLElement; + readonly HTMLMapElement: typeof HTMLElement; + readonly HTMLMarqueeElement: typeof HTMLElement; + readonly HTMLMeterElement: typeof HTMLElement; + readonly HTMLModElement: typeof HTMLElement; + readonly HTMLOutputElement: typeof HTMLElement; + readonly HTMLPictureElement: typeof HTMLElement; + readonly HTMLProgressElement: typeof HTMLElement; + readonly HTMLQuoteElement: typeof HTMLElement; + readonly HTMLSourceElement: typeof HTMLElement; + readonly HTMLSpanElement: typeof HTMLElement; + readonly HTMLTableCaptionElement: typeof HTMLElement; + readonly HTMLTableCellElement: typeof HTMLElement; + readonly HTMLTableColElement: typeof HTMLElement; + readonly HTMLTableElement: typeof HTMLElement; + readonly HTMLTimeElement: typeof HTMLElement; + readonly HTMLTableRowElement: typeof HTMLElement; + readonly HTMLTableSectionElement: typeof HTMLElement; + readonly HTMLFrameElement: typeof HTMLElement; + readonly HTMLFrameSetElement: typeof HTMLElement; + readonly HTMLEmbedElement: typeof HTMLElement; + readonly HTMLObjectElement: typeof HTMLElement; + readonly HTMLParamElement: typeof HTMLElement; + readonly HTMLTrackElement: typeof HTMLElement; + + // Event classes + readonly Event: typeof Event; + readonly UIEvent: typeof UIEvent; + readonly CustomEvent: typeof CustomEvent; + readonly AnimationEvent: typeof AnimationEvent; + readonly KeyboardEvent: typeof KeyboardEvent; + readonly PointerEvent: typeof PointerEvent; + readonly MouseEvent: typeof MouseEvent; + readonly FocusEvent: typeof FocusEvent; + readonly WheelEvent: typeof WheelEvent; + readonly InputEvent: typeof InputEvent; + readonly ErrorEvent: typeof ErrorEvent; + readonly StorageEvent: typeof StorageEvent; + readonly SubmitEvent: typeof SubmitEvent; + readonly MessageEvent: typeof MessageEvent; + readonly MessagePort: typeof MessagePort; + readonly ProgressEvent: typeof ProgressEvent; + readonly MediaQueryListEvent: typeof MediaQueryListEvent; + readonly ClipboardEvent: typeof ClipboardEvent; + + /** + * Non-implemented event classes + */ + readonly AudioProcessingEvent: typeof Event; + readonly BeforeInputEvent: typeof Event; + readonly BeforeUnloadEvent: typeof Event; + readonly BlobEvent: typeof Event; + readonly CloseEvent: typeof Event; + readonly CompositionEvent: typeof Event; + readonly CSSFontFaceLoadEvent: typeof Event; + readonly DeviceLightEvent: typeof Event; + readonly DeviceMotionEvent: typeof Event; + readonly DeviceOrientationEvent: typeof Event; + readonly DeviceProximityEvent: typeof Event; + readonly DOMTransactionEvent: typeof Event; + readonly DragEvent: typeof Event; + readonly EditingBeforeInputEvent: typeof Event; + readonly FetchEvent: typeof Event; + readonly GamepadEvent: typeof Event; + readonly HashChangeEvent: typeof Event; + readonly IDBVersionChangeEvent: typeof Event; + readonly MediaStreamEvent: typeof Event; + readonly MutationEvent: typeof Event; + readonly OfflineAudioCompletionEvent: typeof Event; + readonly OverconstrainedError: typeof Event; + readonly PageTransitionEvent: typeof Event; + readonly PaymentRequestUpdateEvent: typeof Event; + readonly PopStateEvent: typeof Event; + readonly RelatedEvent: typeof Event; + readonly RTCDataChannelEvent: typeof Event; + readonly RTCIdentityErrorEvent: typeof Event; + readonly RTCIdentityEvent: typeof Event; + readonly RTCPeerConnectionIceEvent: typeof Event; + readonly SensorEvent: typeof Event; + readonly SVGEvent: typeof Event; + readonly SVGZoomEvent: typeof Event; + readonly TimeEvent: typeof Event; + readonly TouchEvent: typeof Event; + readonly TrackEvent: typeof Event; + readonly TransitionEvent: typeof Event; + readonly UserProximityEvent: typeof Event; + readonly WebGLContextEvent: typeof Event; + readonly TextEvent: typeof Event; + + // Other classes + readonly Image: typeof Image; + readonly Audio: typeof Audio; + readonly NamedNodeMap: typeof NamedNodeMap; + readonly EventTarget: typeof EventTarget; + readonly DataTransfer: typeof DataTransfer; + readonly DataTransferItem: typeof DataTransferItem; + readonly DataTransferItemList: typeof DataTransferItemList; + readonly URL: typeof URL; + readonly URLSearchParams: typeof URLSearchParams; + readonly Location: typeof Location; + readonly CustomElementRegistry: typeof CustomElementRegistry; + readonly Window: typeof BrowserWindow; + readonly XMLSerializer: typeof XMLSerializer; + readonly ResizeObserver: typeof ResizeObserver; + readonly CSSStyleSheet: typeof CSSStyleSheet; + readonly Blob: typeof Blob; + readonly File: typeof File; + readonly FileReader: new () => FileReader; + readonly DOMException: typeof DOMException; + readonly History: typeof History; + readonly Screen: typeof Screen; + readonly Storage: typeof Storage; + readonly HTMLCollection: typeof HTMLCollection; + readonly HTMLFormControlsCollection: typeof HTMLFormControlsCollection; + readonly NodeList: typeof NodeList; + readonly CSSUnitValue: typeof CSSUnitValue; + readonly CSS: CSS; + readonly CSSRule: typeof CSSRule; + readonly CSSContainerRule: typeof CSSContainerRule; + readonly CSSFontFaceRule: typeof CSSFontFaceRule; + readonly CSSKeyframeRule: typeof CSSKeyframeRule; + readonly CSSKeyframesRule: typeof CSSKeyframesRule; + readonly CSSMediaRule: typeof CSSMediaRule; + readonly CSSStyleRule: typeof CSSStyleRule; + readonly CSSSupportsRule: typeof CSSSupportsRule; + readonly Selection: typeof Selection; + readonly Navigator: typeof Navigator; + readonly MimeType: typeof MimeType; + readonly MimeTypeArray: typeof MimeTypeArray; + readonly Plugin: typeof Plugin; + readonly PluginArray: typeof PluginArray; + readonly Headers: typeof Headers; + readonly Request: new (input: RequestInfo, init?: IRequestInit) => Request; + readonly Response: { + redirect: (url: string, status?: number) => Response; + error: () => Response; + json: (data: object, init?: IResponseInit) => Response; + new (body?: IResponseBody, init?: IResponseInit): Response; + }; + readonly Range: new () => Range; + readonly DOMRect: typeof DOMRect; + readonly XMLHttpRequest: new () => XMLHttpRequest; + readonly XMLHttpRequestUpload: typeof XMLHttpRequestUpload; + readonly XMLHttpRequestEventTarget: typeof XMLHttpRequestEventTarget; + readonly FileList: typeof FileList; + readonly ReadableStream: typeof Stream.Readable; + readonly WritableStream: typeof Stream.Writable; + readonly TransformStream: typeof Stream.Transform; + readonly FormData: typeof FormData; + readonly AbortController: typeof AbortController; + readonly AbortSignal: typeof AbortSignal; + readonly RadioNodeList: typeof RadioNodeList; + readonly ValidityState: typeof ValidityState; + readonly Permissions: typeof Permissions; + readonly PermissionStatus: typeof PermissionStatus; + readonly Clipboard: typeof Clipboard; + readonly ClipboardItem: typeof ClipboardItem; + + readonly NodeFilter: typeof NodeFilter; + readonly NodeIterator: typeof NodeIterator; + readonly TreeWalker: typeof TreeWalker; + readonly DOMParser: new () => DOMParser; + readonly MutationObserver: typeof MutationObserver; + readonly MutationRecord: typeof MutationRecord; + readonly CSSStyleDeclaration: typeof CSSStyleDeclaration; + + // Events + onload: ((event: Event) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + + // Public Properties + readonly document: IDocument; + readonly customElements: CustomElementRegistry; + readonly location: Location; + readonly history: History; + readonly navigator: Navigator; + readonly console: Console; + readonly self: IBrowserWindow; + readonly top: IBrowserWindow | ICrossOriginBrowserWindow; + readonly opener: IBrowserWindow | ICrossOriginBrowserWindow | null; + readonly parent: IBrowserWindow | ICrossOriginBrowserWindow; + readonly window: IBrowserWindow; + readonly globalThis: IBrowserWindow; + readonly screen: Screen; + innerWidth: number; + innerHeight: number; + outerWidth: number; + outerHeight: number; + devicePixelRatio: number; + readonly screenLeft: number; + readonly screenTop: number; + readonly screenX: number; + readonly screenY: number; + readonly sessionStorage: Storage; + readonly localStorage: Storage; + readonly performance: Performance; + readonly pageXOffset: number; + readonly pageYOffset: number; + readonly scrollX: number; + readonly scrollY: number; + readonly crypto: typeof webcrypto; + readonly closed: boolean; + name: string; + + /** + * Returns an object containing the values of all CSS properties of an element. + * + * @param element Element. + * @returns CSS style declaration. + */ + getComputedStyle(element: IElement): CSSStyleDeclaration; + + /** + * Returns selection. + * + * @returns Selection. + */ + getSelection(): Selection; + + /** + * Scrolls to a particular set of coordinates. + * + * @param x X position or options object. + * @param y Y position. + */ + scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void; + + /** + * Scrolls to a particular set of coordinates. + * + * @param x X position or options object. + * @param y Y position. + */ + scrollTo(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void; + + /** + * Shifts focus away from the window. + */ + blur(): void; + + /** + * Gives focus to the window. + */ + focus(): void; + + /** + * Loads a specified resource into a new or existing browsing context (that is, a tab, a window, or an iframe) under a specified name. + * + * @param [url] URL. + * @param [target] Target. + * @param [windowFeatures] Window features. + */ + open( + url?: string, + target?: string, + windowFeatures?: string + ): IBrowserWindow | ICrossOriginBrowserWindow | null; + + /** + * Closes the window. + */ + close(): void; + + /** + * Returns a new MediaQueryList object that can then be used to determine if the document matches the media query string. + * + * @param mediaQueryString A string specifying the media query to parse into a MediaQueryList. + * @returns A new MediaQueryList. + */ + matchMedia(mediaQueryString: string): MediaQueryList; + + /** + * Sets a timer which executes a function once the timer expires. + * + * @param callback Function to be executed. + * @param [delay=0] Delay in ms. + * @param args Arguments passed to the callback function. + * @returns Timeout ID. + */ + setTimeout(callback: Function, delay?: number, ...args: unknown[]): NodeJS.Timeout; + + /** + * Cancels a timeout previously established by calling setTimeout(). + * + * @param id ID of the timeout. + */ + clearTimeout(id: NodeJS.Timeout): void; + + /** + * Calls a function with a fixed time delay between each call. + * + * @param callback Function to be executed. + * @param [delay=0] Delay in ms. + * @param args Arguments passed to the callback function. + * @returns Interval ID. + */ + setInterval(callback: Function, delay?: number, ...args: unknown[]): NodeJS.Timeout; + + /** + * Cancels a timed repeating action which was previously established by a call to setInterval(). + * + * @param id ID of the interval. + */ + clearInterval(id: NodeJS.Timeout): void; + + /** + * Mock animation frames with timeouts. + * + * @param {Function} callback Callback. + * @returns {NodeJS.Timeout} ID. + */ + requestAnimationFrame(callback: (timestamp: number) => void): NodeJS.Immediate; + + /** + * Mock animation frames with timeouts. + * + * @param {NodeJS.Timeout} id ID. + */ + cancelAnimationFrame(id: NodeJS.Immediate): void; + + /** + * This method provides an easy, logical way to fetch resources asynchronously across the network. + * + * @param url URL. + * @param [init] Init. + * @returns Promise. + */ + fetch(url: RequestInfo, init?: IRequestInit): Promise; + + /** + * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa + * @param data Binary data. + * @returns Base64-encoded string. + */ + btoa(data: unknown): string; + + /** + * Decodes a string of data which has been encoded using Base64 encoding. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/atob + * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. + * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. + * @param data Binary string. + * @returns An ASCII string containing decoded data from encodedData. + */ + atob(data: unknown): string; + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param listener Listener. + */ + postMessage(message: unknown, targetOrigin?: string, transfer?: unknown[]): void; + + /** + * Resizes the window. + * + * @param width Width. + * @param height Height. + */ + resizeTo(width: number, height: number): void; + + /** + * Resizes the current window by a specified amount. + */ + resizeBy(width: number, height: number): void; + + /** + * Destroys the window. + */ + [PropertySymbol.destroy](): void; +} diff --git a/packages/happy-dom/src/window/ICrossOriginBrowserWindow.ts b/packages/happy-dom/src/window/ICrossOriginBrowserWindow.ts new file mode 100644 index 000000000..7c98770b4 --- /dev/null +++ b/packages/happy-dom/src/window/ICrossOriginBrowserWindow.ts @@ -0,0 +1,40 @@ +import IBrowserWindow from './IBrowserWindow.js'; +import Location from '../location/Location.js'; +import IEventTarget from '../event/IEventTarget.js'; + +/** + * Browser window with limited access due to CORS restrictions in iframes. + */ +export default interface ICrossOriginBrowserWindow extends IEventTarget { + readonly self: ICrossOriginBrowserWindow; + readonly window: ICrossOriginBrowserWindow; + readonly parent: IBrowserWindow | ICrossOriginBrowserWindow; + readonly top: IBrowserWindow | ICrossOriginBrowserWindow; + readonly location: Location; + readonly opener: IBrowserWindow | ICrossOriginBrowserWindow | null; + readonly closed: boolean; + + /** + * Shifts focus away from the window. + */ + blur(): void; + + /** + * Gives focus to the window. + */ + focus(): void; + + /** + * Closes the window. + */ + close(): void; + + /** + * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. + * + * @param message Message. + * @param [targetOrigin=*] Target origin. + * @param transfer Transfer. Not implemented. + */ + postMessage(message: unknown, targetOrigin?: string, transfer?: unknown[]): void; +} diff --git a/packages/happy-dom/src/window/IHappyDOMOptions.ts b/packages/happy-dom/src/window/IHappyDOMOptions.ts deleted file mode 100644 index 92ebb25bd..000000000 --- a/packages/happy-dom/src/window/IHappyDOMOptions.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Happy DOM options. - */ -export default interface IHappyDOMOptions { - width?: number; - height?: number; - url?: string; - console?: Console; - settings?: { - disableJavaScriptEvaluation?: boolean; - disableJavaScriptFileLoading?: boolean; - disableCSSFileLoading?: boolean; - disableIframePageLoading?: boolean; - disableComputedStyleRendering?: boolean; - disableErrorCapturing?: boolean; - enableFileSystemHttpRequests?: boolean; - navigator?: { - userAgent?: string; - }; - device?: { - prefersColorScheme?: string; - mediaType?: string; - }; - }; - - /** - * @deprecated - */ - innerWidth?: number; - - /** - * @deprecated - */ - innerHeight?: number; -} diff --git a/packages/happy-dom/src/window/IHappyDOMSettings.ts b/packages/happy-dom/src/window/IHappyDOMSettings.ts deleted file mode 100644 index 1b2afeb00..000000000 --- a/packages/happy-dom/src/window/IHappyDOMSettings.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Happy DOM settings. - */ -export default interface IHappyDOMSettings { - disableJavaScriptEvaluation: boolean; - disableJavaScriptFileLoading: boolean; - disableCSSFileLoading: boolean; - disableIframePageLoading: boolean; - disableComputedStyleRendering: boolean; - disableErrorCapturing: boolean; - enableFileSystemHttpRequests: boolean; - navigator: { - userAgent: string; - }; - device: { - prefersColorScheme: string; - mediaType: string; - }; -} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index ce21b64d4..dacd12a90 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -1,543 +1,10 @@ -import CustomElementRegistry from '../custom-element/CustomElementRegistry.js'; -import Document from '../nodes/document/Document.js'; -import HTMLDocument from '../nodes/html-document/HTMLDocument.js'; -import XMLDocument from '../nodes/xml-document/XMLDocument.js'; -import SVGDocument from '../nodes/svg-document/SVGDocument.js'; -import Node from '../nodes/node/Node.js'; -import Text from '../nodes/text/Text.js'; -import Comment from '../nodes/comment/Comment.js'; -import ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; -import Element from '../nodes/element/Element.js'; -import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement.js'; -import HTMLFormElement from '../nodes/html-form-element/HTMLFormElement.js'; -import HTMLElement from '../nodes/html-element/HTMLElement.js'; -import HTMLUnknownElement from '../nodes/html-unknown-element/HTMLUnknownElement.js'; -import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; -import HTMLSelectElement from '../nodes/html-select-element/HTMLSelectElement.js'; -import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement.js'; -import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; -import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement.js'; -import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement.js'; -import HTMLLabelElement from '../nodes/html-label-element/HTMLLabelElement.js'; -import HTMLMetaElement from '../nodes/html-meta-element/HTMLMetaElement.js'; -import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement.js'; -import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement.js'; -import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement.js'; -import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement.js'; -import HTMLIFrameElement from '../nodes/html-iframe-element/HTMLIFrameElement.js'; -import SVGSVGElement from '../nodes/svg-element/SVGSVGElement.js'; -import SVGElement from '../nodes/svg-element/SVGElement.js'; -import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; -import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement.js'; -import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement.js'; -import Image from '../nodes/html-image-element/Image.js'; -import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; -import CharacterData from '../nodes/character-data/CharacterData.js'; -import NodeIterator from '../tree-walker/NodeIterator.js'; -import TreeWalker from '../tree-walker/TreeWalker.js'; -import Event from '../event/Event.js'; -import CustomEvent from '../event/events/CustomEvent.js'; -import AnimationEvent from '../event/events/AnimationEvent.js'; -import KeyboardEvent from '../event/events/KeyboardEvent.js'; -import ProgressEvent from '../event/events/ProgressEvent.js'; -import MediaQueryListEvent from '../event/events/MediaQueryListEvent.js'; -import EventTarget from '../event/EventTarget.js'; -import { URLSearchParams } from 'url'; -import URL from '../url/URL.js'; -import Location from '../location/Location.js'; -import MutationObserver from '../mutation-observer/MutationObserver.js'; -import MutationRecord from '../mutation-observer/MutationRecord.js'; -import DOMParser from '../dom-parser/DOMParser.js'; -import XMLSerializer from '../xml-serializer/XMLSerializer.js'; -import ResizeObserver from '../resize-observer/ResizeObserver.js'; -import Blob from '../file/Blob.js'; -import File from '../file/File.js'; -import DOMException from '../exception/DOMException.js'; -import FileReader from '../file/FileReader.js'; -import History from '../history/History.js'; -import CSSStyleSheet from '../css/CSSStyleSheet.js'; -import CSSStyleDeclaration from '../css/declaration/CSSStyleDeclaration.js'; -import CSS from '../css/CSS.js'; -import CSSUnitValue from '../css/CSSUnitValue.js'; -import CSSRule from '../css/CSSRule.js'; -import CSSContainerRule from '../css/rules/CSSContainerRule.js'; -import CSSFontFaceRule from '../css/rules/CSSFontFaceRule.js'; -import CSSKeyframeRule from '../css/rules/CSSKeyframeRule.js'; -import CSSKeyframesRule from '../css/rules/CSSKeyframesRule.js'; -import CSSMediaRule from '../css/rules/CSSMediaRule.js'; -import CSSStyleRule from '../css/rules/CSSStyleRule.js'; -import CSSSupportsRule from '../css/rules/CSSSupportsRule.js'; -import PointerEvent from '../event/events/PointerEvent.js'; -import MouseEvent from '../event/events/MouseEvent.js'; -import FocusEvent from '../event/events/FocusEvent.js'; -import WheelEvent from '../event/events/WheelEvent.js'; -import DataTransfer from '../event/DataTransfer.js'; -import DataTransferItem from '../event/DataTransferItem.js'; -import DataTransferItemList from '../event/DataTransferItemList.js'; -import InputEvent from '../event/events/InputEvent.js'; -import UIEvent from '../event/UIEvent.js'; -import ErrorEvent from '../event/events/ErrorEvent.js'; -import StorageEvent from '../event/events/StorageEvent.js'; -import SubmitEvent from '../event/events/SubmitEvent.js'; -import MessageEvent from '../event/events/MessageEvent.js'; -import MessagePort from '../event/MessagePort.js'; -import Screen from '../screen/Screen.js'; -import AsyncTaskManager from '../async-task-manager/AsyncTaskManager.js'; -import Storage from '../storage/Storage.js'; -import NodeFilter from '../tree-walker/NodeFilter.js'; -import HTMLCollection from '../nodes/element/HTMLCollection.js'; -import HTMLFormControlsCollection from '../nodes/html-form-element/HTMLFormControlsCollection.js'; -import NodeList from '../nodes/node/NodeList.js'; -import Selection from '../selection/Selection.js'; -import IEventTarget from '../event/IEventTarget.js'; -import Navigator from '../navigator/Navigator.js'; -import MimeType from '../navigator/MimeType.js'; -import MimeTypeArray from '../navigator/MimeTypeArray.js'; -import Plugin from '../navigator/Plugin.js'; -import PluginArray from '../navigator/PluginArray.js'; -import IResponseInit from '../fetch/types/IResponseInit.js'; -import IRequest from '../fetch/types/IRequest.js'; -import IHeaders from '../fetch/types/IHeaders.js'; -import IRequestInit from '../fetch/types/IRequestInit.js'; -import IResponse from '../fetch/types/IResponse.js'; -import Range from '../range/Range.js'; -import MediaQueryList from '../match-media/MediaQueryList.js'; -import XMLHttpRequest from '../xml-http-request/XMLHttpRequest.js'; -import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload.js'; -import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget.js'; -import DOMRect from '../nodes/element/DOMRect.js'; -import Window from './Window.js'; -import Attr from '../nodes/attr/Attr.js'; -import NamedNodeMap from '../named-node-map/NamedNodeMap.js'; -import { Performance } from 'perf_hooks'; -import IElement from '../nodes/element/IElement.js'; -import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction.js'; -import IHappyDOMSettings from './IHappyDOMSettings.js'; -import RequestInfo from '../fetch/types/IRequestInfo.js'; -import FileList from '../nodes/html-input-element/FileList.js'; -import Stream from 'stream'; -import { webcrypto } from 'crypto'; -import FormData from '../form-data/FormData.js'; -import AbortController from '../fetch/AbortController.js'; -import AbortSignal from '../fetch/AbortSignal.js'; -import IResponseBody from '../fetch/types/IResponseBody.js'; -import IRequestInfo from '../fetch/types/IRequestInfo.js'; -import IHeadersInit from '../fetch/types/IHeadersInit.js'; -import RadioNodeList from '../nodes/html-form-element/RadioNodeList.js'; -import ValidityState from '../validity-state/ValidityState.js'; -import INodeJSGlobal from './INodeJSGlobal.js'; -import VirtualConsolePrinter from '../console/VirtualConsolePrinter.js'; -import Permissions from '../permissions/Permissions.js'; -import PermissionStatus from '../permissions/PermissionStatus.js'; -import Clipboard from '../clipboard/Clipboard.js'; -import ClipboardItem from '../clipboard/ClipboardItem.js'; -import ClipboardEvent from '../event/events/ClipboardEvent.js'; +import DetachedWindowAPI from './DetachedWindowAPI.js'; +import IBrowserWindow from './IBrowserWindow.js'; /** - * Browser window. + * Window. */ -export default interface IWindow extends IEventTarget, INodeJSGlobal { - // Happy DOM property. - readonly happyDOM: { - whenAsyncComplete: () => Promise; - cancelAsync: () => void; - asyncTaskManager: AsyncTaskManager; - setWindowSize: (options: { width?: number; height?: number }) => void; - setURL: (url: string) => void; - virtualConsolePrinter: VirtualConsolePrinter | null; - settings: IHappyDOMSettings; - - /** - * @deprecated - */ - setInnerWidth: (width: number) => void; - - /** - * @deprecated - */ - setInnerHeight: (height: number) => void; - }; - - // Nodes - readonly Node: typeof Node; - readonly Attr: typeof Attr; - readonly SVGSVGElement: typeof SVGSVGElement; - readonly SVGElement: typeof SVGElement; - readonly Text: typeof Text; - readonly Comment: typeof Comment; - readonly ShadowRoot: typeof ShadowRoot; - readonly Element: typeof Element; - readonly DocumentFragment: typeof DocumentFragment; - readonly CharacterData: typeof CharacterData; - readonly ProcessingInstruction: typeof ProcessingInstruction; - readonly Document: typeof Document; - readonly HTMLDocument: typeof HTMLDocument; - readonly XMLDocument: typeof XMLDocument; - readonly SVGDocument: typeof SVGDocument; - - // Element classes - readonly HTMLElement: typeof HTMLElement; - readonly HTMLUnknownElement: typeof HTMLUnknownElement; - readonly HTMLTemplateElement: typeof HTMLTemplateElement; - readonly HTMLFormElement: typeof HTMLFormElement; - readonly HTMLInputElement: typeof HTMLInputElement; - readonly HTMLSelectElement: typeof HTMLSelectElement; - readonly HTMLTextAreaElement: typeof HTMLTextAreaElement; - readonly HTMLImageElement: typeof HTMLImageElement; - readonly HTMLScriptElement: typeof HTMLScriptElement; - readonly HTMLLinkElement: typeof HTMLLinkElement; - readonly HTMLStyleElement: typeof HTMLStyleElement; - readonly HTMLSlotElement: typeof HTMLSlotElement; - readonly HTMLLabelElement: typeof HTMLLabelElement; - readonly HTMLMetaElement: typeof HTMLMetaElement; - readonly HTMLMediaElement: typeof HTMLMediaElement; - readonly HTMLAudioElement: typeof HTMLAudioElement; - readonly HTMLVideoElement: typeof HTMLVideoElement; - readonly HTMLBaseElement: typeof HTMLBaseElement; - readonly HTMLIFrameElement: typeof HTMLIFrameElement; - readonly HTMLDialogElement: typeof HTMLDialogElement; - - /** - * Non-implemented element classes - */ - readonly HTMLHeadElement: typeof HTMLElement; - readonly HTMLTitleElement: typeof HTMLElement; - readonly HTMLBodyElement: typeof HTMLElement; - readonly HTMLHeadingElement: typeof HTMLElement; - readonly HTMLParagraphElement: typeof HTMLElement; - readonly HTMLHRElement: typeof HTMLElement; - readonly HTMLPreElement: typeof HTMLElement; - readonly HTMLUListElement: typeof HTMLElement; - readonly HTMLOListElement: typeof HTMLElement; - readonly HTMLLIElement: typeof HTMLElement; - readonly HTMLMenuElement: typeof HTMLElement; - readonly HTMLDListElement: typeof HTMLElement; - readonly HTMLDivElement: typeof HTMLElement; - readonly HTMLAnchorElement: typeof HTMLElement; - readonly HTMLAreaElement: typeof HTMLElement; - readonly HTMLBRElement: typeof HTMLElement; - readonly HTMLButtonElement: typeof HTMLElement; - readonly HTMLCanvasElement: typeof HTMLElement; - readonly HTMLDataElement: typeof HTMLElement; - readonly HTMLDataListElement: typeof HTMLElement; - readonly HTMLDetailsElement: typeof HTMLElement; - readonly HTMLDirectoryElement: typeof HTMLElement; - readonly HTMLFieldSetElement: typeof HTMLElement; - readonly HTMLFontElement: typeof HTMLElement; - readonly HTMLHtmlElement: typeof HTMLElement; - readonly HTMLLegendElement: typeof HTMLElement; - readonly HTMLMapElement: typeof HTMLElement; - readonly HTMLMarqueeElement: typeof HTMLElement; - readonly HTMLMeterElement: typeof HTMLElement; - readonly HTMLModElement: typeof HTMLElement; - readonly HTMLOutputElement: typeof HTMLElement; - readonly HTMLPictureElement: typeof HTMLElement; - readonly HTMLProgressElement: typeof HTMLElement; - readonly HTMLQuoteElement: typeof HTMLElement; - readonly HTMLSourceElement: typeof HTMLElement; - readonly HTMLSpanElement: typeof HTMLElement; - readonly HTMLTableCaptionElement: typeof HTMLElement; - readonly HTMLTableCellElement: typeof HTMLElement; - readonly HTMLTableColElement: typeof HTMLElement; - readonly HTMLTableElement: typeof HTMLElement; - readonly HTMLTimeElement: typeof HTMLElement; - readonly HTMLTableRowElement: typeof HTMLElement; - readonly HTMLTableSectionElement: typeof HTMLElement; - readonly HTMLFrameElement: typeof HTMLElement; - readonly HTMLFrameSetElement: typeof HTMLElement; - readonly HTMLEmbedElement: typeof HTMLElement; - readonly HTMLObjectElement: typeof HTMLElement; - readonly HTMLParamElement: typeof HTMLElement; - readonly HTMLTrackElement: typeof HTMLElement; - - // Event classes - readonly Event: typeof Event; - readonly UIEvent: typeof UIEvent; - readonly CustomEvent: typeof CustomEvent; - readonly AnimationEvent: typeof AnimationEvent; - readonly KeyboardEvent: typeof KeyboardEvent; - readonly PointerEvent: typeof PointerEvent; - readonly MouseEvent: typeof MouseEvent; - readonly FocusEvent: typeof FocusEvent; - readonly WheelEvent: typeof WheelEvent; - readonly InputEvent: typeof InputEvent; - readonly ErrorEvent: typeof ErrorEvent; - readonly StorageEvent: typeof StorageEvent; - readonly SubmitEvent: typeof SubmitEvent; - readonly MessageEvent: typeof MessageEvent; - readonly MessagePort: typeof MessagePort; - readonly ProgressEvent: typeof ProgressEvent; - readonly MediaQueryListEvent: typeof MediaQueryListEvent; - readonly ClipboardEvent: typeof ClipboardEvent; - - /** - * Non-implemented event classes - */ - readonly AudioProcessingEvent: typeof Event; - readonly BeforeInputEvent: typeof Event; - readonly BeforeUnloadEvent: typeof Event; - readonly BlobEvent: typeof Event; - readonly CloseEvent: typeof Event; - readonly CompositionEvent: typeof Event; - readonly CSSFontFaceLoadEvent: typeof Event; - readonly DeviceLightEvent: typeof Event; - readonly DeviceMotionEvent: typeof Event; - readonly DeviceOrientationEvent: typeof Event; - readonly DeviceProximityEvent: typeof Event; - readonly DOMTransactionEvent: typeof Event; - readonly DragEvent: typeof Event; - readonly EditingBeforeInputEvent: typeof Event; - readonly FetchEvent: typeof Event; - readonly GamepadEvent: typeof Event; - readonly HashChangeEvent: typeof Event; - readonly IDBVersionChangeEvent: typeof Event; - readonly MediaStreamEvent: typeof Event; - readonly MutationEvent: typeof Event; - readonly OfflineAudioCompletionEvent: typeof Event; - readonly OverconstrainedError: typeof Event; - readonly PageTransitionEvent: typeof Event; - readonly PaymentRequestUpdateEvent: typeof Event; - readonly PopStateEvent: typeof Event; - readonly RelatedEvent: typeof Event; - readonly RTCDataChannelEvent: typeof Event; - readonly RTCIdentityErrorEvent: typeof Event; - readonly RTCIdentityEvent: typeof Event; - readonly RTCPeerConnectionIceEvent: typeof Event; - readonly SensorEvent: typeof Event; - readonly SVGEvent: typeof Event; - readonly SVGZoomEvent: typeof Event; - readonly TimeEvent: typeof Event; - readonly TouchEvent: typeof Event; - readonly TrackEvent: typeof Event; - readonly TransitionEvent: typeof Event; - readonly UserProximityEvent: typeof Event; - readonly WebGLContextEvent: typeof Event; - readonly TextEvent: typeof Event; - - // Other classes - readonly Image: typeof Image; - readonly NamedNodeMap: typeof NamedNodeMap; - readonly EventTarget: typeof EventTarget; - readonly DataTransfer: typeof DataTransfer; - readonly DataTransferItem: typeof DataTransferItem; - readonly DataTransferItemList: typeof DataTransferItemList; - readonly URL: typeof URL; - readonly URLSearchParams: typeof URLSearchParams; - readonly Location: typeof Location; - readonly CustomElementRegistry: typeof CustomElementRegistry; - readonly Window: typeof Window; - readonly XMLSerializer: typeof XMLSerializer; - readonly ResizeObserver: typeof ResizeObserver; - readonly CSSStyleSheet: typeof CSSStyleSheet; - readonly Blob: typeof Blob; - readonly File: typeof File; - readonly FileReader: typeof FileReader; - readonly DOMException: typeof DOMException; - readonly History: typeof History; - readonly Screen: typeof Screen; - readonly Storage: typeof Storage; - readonly HTMLCollection: typeof HTMLCollection; - readonly HTMLFormControlsCollection: typeof HTMLFormControlsCollection; - readonly NodeList: typeof NodeList; - readonly CSSUnitValue: typeof CSSUnitValue; - readonly CSS: CSS; - readonly CSSRule: typeof CSSRule; - readonly CSSContainerRule: typeof CSSContainerRule; - readonly CSSFontFaceRule: typeof CSSFontFaceRule; - readonly CSSKeyframeRule: typeof CSSKeyframeRule; - readonly CSSKeyframesRule: typeof CSSKeyframesRule; - readonly CSSMediaRule: typeof CSSMediaRule; - readonly CSSStyleRule: typeof CSSStyleRule; - readonly CSSSupportsRule: typeof CSSSupportsRule; - readonly Selection: typeof Selection; - readonly Navigator: typeof Navigator; - readonly MimeType: typeof MimeType; - readonly MimeTypeArray: typeof MimeTypeArray; - readonly Plugin: typeof Plugin; - readonly PluginArray: typeof PluginArray; - readonly Headers: { new (init?: IHeadersInit): IHeaders }; - readonly Request: { - new (input: IRequestInfo, init?: IRequestInit): IRequest; - }; - readonly Response: { new (body?: IResponseBody | null, init?: IResponseInit): IResponse }; - readonly Range: typeof Range; - readonly DOMRect: typeof DOMRect; - readonly XMLHttpRequest: typeof XMLHttpRequest; - readonly XMLHttpRequestUpload: typeof XMLHttpRequestUpload; - readonly XMLHttpRequestEventTarget: typeof XMLHttpRequestEventTarget; - readonly FileList: typeof FileList; - readonly ReadableStream: typeof Stream.Readable; - readonly WritableStream: typeof Stream.Writable; - readonly FormData: typeof FormData; - readonly AbortController: typeof AbortController; - readonly AbortSignal: typeof AbortSignal; - readonly RadioNodeList: typeof RadioNodeList; - readonly ValidityState: typeof ValidityState; - readonly Permissions: typeof Permissions; - readonly PermissionStatus: typeof PermissionStatus; - readonly Clipboard: typeof Clipboard; - readonly ClipboardItem: typeof ClipboardItem; - - readonly NodeFilter: typeof NodeFilter; - readonly NodeIterator: typeof NodeIterator; - readonly TreeWalker: typeof TreeWalker; - readonly DOMParser: typeof DOMParser; - readonly MutationObserver: typeof MutationObserver; - readonly MutationRecord: typeof MutationRecord; - readonly CSSStyleDeclaration: typeof CSSStyleDeclaration; - - // Events - onload: (event: Event) => void; - onerror: (event: ErrorEvent) => void; - - // Public Properties - readonly document: Document; - readonly customElements: CustomElementRegistry; - readonly location: Location; - readonly history: History; - readonly navigator: Navigator; - readonly console: Console; - readonly self: IWindow; - readonly top: IWindow; - readonly parent: IWindow; - readonly window: IWindow; - readonly globalThis: IWindow; - readonly screen: Screen; - readonly innerWidth: number; - readonly innerHeight: number; - readonly outerWidth: number; - readonly outerHeight: number; - readonly sessionStorage: Storage; - readonly localStorage: Storage; - readonly performance: Performance; - readonly pageXOffset: number; - readonly pageYOffset: number; - readonly scrollX: number; - readonly scrollY: number; - readonly crypto: typeof webcrypto; - - /** - * Returns an object containing the values of all CSS properties of an element. - * - * @param element Element. - * @returns CSS style declaration. - */ - getComputedStyle(element: IElement): CSSStyleDeclaration; - - /** - * Returns selection. - * - * @returns Selection. - */ - getSelection(): Selection; - - /** - * Scrolls to a particular set of coordinates. - * - * @param x X position or options object. - * @param y Y position. - */ - scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void; - - /** - * Scrolls to a particular set of coordinates. - * - * @param x X position or options object. - * @param y Y position. - */ - scrollTo(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void; - - /** - * Returns a new MediaQueryList object that can then be used to determine if the document matches the media query string. - * - * @param mediaQueryString A string specifying the media query to parse into a MediaQueryList. - * @returns A new MediaQueryList. - */ - matchMedia(mediaQueryString: string): MediaQueryList; - - /** - * Sets a timer which executes a function once the timer expires. - * - * @param callback Function to be executed. - * @param [delay=0] Delay in ms. - * @param args Arguments passed to the callback function. - * @returns Timeout ID. - */ - setTimeout(callback: Function, delay?: number, ...args: unknown[]): NodeJS.Timeout; - - /** - * Cancels a timeout previously established by calling setTimeout(). - * - * @param id ID of the timeout. - */ - clearTimeout(id: NodeJS.Timeout): void; - - /** - * Calls a function with a fixed time delay between each call. - * - * @param callback Function to be executed. - * @param [delay=0] Delay in ms. - * @param args Arguments passed to the callback function. - * @returns Interval ID. - */ - setInterval(callback: Function, delay?: number, ...args: unknown[]): NodeJS.Timeout; - - /** - * Cancels a timed repeating action which was previously established by a call to setInterval(). - * - * @param id ID of the interval. - */ - clearInterval(id: NodeJS.Timeout): void; - - /** - * Mock animation frames with timeouts. - * - * @param {Function} callback Callback. - * @returns {NodeJS.Timeout} ID. - */ - requestAnimationFrame(callback: (timestamp: number) => void): NodeJS.Immediate; - - /** - * Mock animation frames with timeouts. - * - * @param {NodeJS.Timeout} id ID. - */ - cancelAnimationFrame(id: NodeJS.Immediate): void; - - /** - * This method provides an easy, logical way to fetch resources asynchronously across the network. - * - * @param url URL. - * @param [init] Init. - * @returns Promise. - */ - fetch(url: RequestInfo, init?: IRequestInit): Promise; - - /** - * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa - * @param data Binary data. - * @returns Base64-encoded string. - */ - btoa(data: unknown): string; - - /** - * Decodes a string of data which has been encoded using Base64 encoding. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/atob - * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. - * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. - * @param data Binary string. - * @returns An ASCII string containing decoded data from encodedData. - */ - atob(data: unknown): string; - - /** - * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. - * - * @param message Message. - * @param listener Listener. - */ - postMessage(message: unknown, targetOrigin?: string, transfer?: unknown[]): void; +export default interface IWindow extends IBrowserWindow { + // Detached Window API. + readonly happyDOM: DetachedWindowAPI; } diff --git a/packages/happy-dom/src/window/VMGlobalPropertyScript.ts b/packages/happy-dom/src/window/VMGlobalPropertyScript.ts index e0a037b12..774dc85e6 100644 --- a/packages/happy-dom/src/window/VMGlobalPropertyScript.ts +++ b/packages/happy-dom/src/window/VMGlobalPropertyScript.ts @@ -48,7 +48,7 @@ this.isFinite = globalThis.isFinite; this.isNaN = globalThis.isNaN; this.parseFloat = globalThis.parseFloat; this.parseInt = globalThis.parseInt; -this.process = globalThis.process; +this.process = null; this.root = globalThis.root; this.undefined = globalThis.undefined; this.unescape = globalThis.unescape; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index d94bd337f..bbfdeb065 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -1,563 +1,18 @@ -import CustomElementRegistry from '../custom-element/CustomElementRegistry.js'; -import Document from '../nodes/document/Document.js'; -import HTMLDocument from '../nodes/html-document/HTMLDocument.js'; -import XMLDocument from '../nodes/xml-document/XMLDocument.js'; -import SVGDocument from '../nodes/svg-document/SVGDocument.js'; -import Node from '../nodes/node/Node.js'; -import NodeFilter from '../tree-walker/NodeFilter.js'; -import Text from '../nodes/text/Text.js'; -import Comment from '../nodes/comment/Comment.js'; -import ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; -import Element from '../nodes/element/Element.js'; -import HTMLTemplateElement from '../nodes/html-template-element/HTMLTemplateElement.js'; -import HTMLFormElement from '../nodes/html-form-element/HTMLFormElement.js'; -import HTMLElement from '../nodes/html-element/HTMLElement.js'; -import HTMLUnknownElement from '../nodes/html-unknown-element/HTMLUnknownElement.js'; -import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement.js'; -import HTMLSelectElement from '../nodes/html-select-element/HTMLSelectElement.js'; -import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement.js'; -import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; -import HTMLStyleElement from '../nodes/html-style-element/HTMLStyleElement.js'; -import HTMLSlotElement from '../nodes/html-slot-element/HTMLSlotElement.js'; -import HTMLLabelElement from '../nodes/html-label-element/HTMLLabelElement.js'; -import HTMLMetaElement from '../nodes/html-meta-element/HTMLMetaElement.js'; -import HTMLMediaElement from '../nodes/html-media-element/HTMLMediaElement.js'; -import HTMLAudioElement from '../nodes/html-audio-element/HTMLAudioElement.js'; -import AudioImplementation from '../nodes/html-audio-element/Audio.js'; -import HTMLVideoElement from '../nodes/html-video-element/HTMLVideoElement.js'; -import HTMLBaseElement from '../nodes/html-base-element/HTMLBaseElement.js'; -import HTMLIFrameElement from '../nodes/html-iframe-element/HTMLIFrameElement.js'; -import HTMLDialogElement from '../nodes/html-dialog-element/HTMLDialogElement.js'; -import SVGSVGElement from '../nodes/svg-element/SVGSVGElement.js'; -import SVGElement from '../nodes/svg-element/SVGElement.js'; -import SVGGraphicsElement from '../nodes/svg-element/SVGGraphicsElement.js'; -import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; -import HTMLImageElement from '../nodes/html-image-element/HTMLImageElement.js'; -import ImageImplementation from '../nodes/html-image-element/Image.js'; -import DocumentFragmentImplementation from '../nodes/document-fragment/DocumentFragment.js'; -import CharacterData from '../nodes/character-data/CharacterData.js'; -import NodeIterator from '../tree-walker/NodeIterator.js'; -import TreeWalker from '../tree-walker/TreeWalker.js'; -import Event from '../event/Event.js'; -import CustomEvent from '../event/events/CustomEvent.js'; -import AnimationEvent from '../event/events/AnimationEvent.js'; -import KeyboardEvent from '../event/events/KeyboardEvent.js'; -import MessageEvent from '../event/events/MessageEvent.js'; -import ProgressEvent from '../event/events/ProgressEvent.js'; -import MediaQueryListEvent from '../event/events/MediaQueryListEvent.js'; -import EventTarget from '../event/EventTarget.js'; -import MessagePort from '../event/MessagePort.js'; -import { URLSearchParams } from 'url'; -import URL from '../url/URL.js'; -import Location from '../location/Location.js'; -import MutationObserver from '../mutation-observer/MutationObserver.js'; -import MutationRecord from '../mutation-observer/MutationRecord.js'; -import DOMParserImplementation from '../dom-parser/DOMParser.js'; -import XMLSerializer from '../xml-serializer/XMLSerializer.js'; -import ResizeObserver from '../resize-observer/ResizeObserver.js'; -import Blob from '../file/Blob.js'; -import File from '../file/File.js'; -import DOMException from '../exception/DOMException.js'; -import FileReaderImplementation from '../file/FileReader.js'; -import History from '../history/History.js'; -import CSSStyleSheet from '../css/CSSStyleSheet.js'; -import CSSStyleDeclaration from '../css/declaration/CSSStyleDeclaration.js'; -import CSS from '../css/CSS.js'; -import CSSUnitValue from '../css/CSSUnitValue.js'; -import CSSRule from '../css/CSSRule.js'; -import CSSContainerRule from '../css/rules/CSSContainerRule.js'; -import CSSFontFaceRule from '../css/rules/CSSFontFaceRule.js'; -import CSSKeyframeRule from '../css/rules/CSSKeyframeRule.js'; -import CSSKeyframesRule from '../css/rules/CSSKeyframesRule.js'; -import CSSMediaRule from '../css/rules/CSSMediaRule.js'; -import CSSStyleRule from '../css/rules/CSSStyleRule.js'; -import CSSSupportsRule from '../css/rules/CSSSupportsRule.js'; -import MouseEvent from '../event/events/MouseEvent.js'; -import PointerEvent from '../event/events/PointerEvent.js'; -import FocusEvent from '../event/events/FocusEvent.js'; -import WheelEvent from '../event/events/WheelEvent.js'; -import DataTransfer from '../event/DataTransfer.js'; -import DataTransferItem from '../event/DataTransferItem.js'; -import DataTransferItemList from '../event/DataTransferItemList.js'; -import InputEvent from '../event/events/InputEvent.js'; -import UIEvent from '../event/UIEvent.js'; -import ErrorEvent from '../event/events/ErrorEvent.js'; -import StorageEvent from '../event/events/StorageEvent.js'; -import SubmitEvent from '../event/events/SubmitEvent.js'; -import Screen from '../screen/Screen.js'; -import AsyncTaskManager from '../async-task-manager/AsyncTaskManager.js'; -import IResponse from '../fetch/types/IResponse.js'; -import IResponseInit from '../fetch/types/IResponseInit.js'; -import IRequest from '../fetch/types/IRequest.js'; -import IRequestInit from '../fetch/types/IRequestInit.js'; -import IHeaders from '../fetch/types/IHeaders.js'; -import IHeadersInit from '../fetch/types/IHeadersInit.js'; -import Headers from '../fetch/Headers.js'; -import RequestImplementation from '../fetch/Request.js'; -import ResponseImplementation from '../fetch/Response.js'; -import Storage from '../storage/Storage.js'; import IWindow from './IWindow.js'; -import HTMLCollection from '../nodes/element/HTMLCollection.js'; -import HTMLFormControlsCollection from '../nodes/html-form-element/HTMLFormControlsCollection.js'; -import NodeList from '../nodes/node/NodeList.js'; -import MediaQueryList from '../match-media/MediaQueryList.js'; -import Selection from '../selection/Selection.js'; -import Navigator from '../navigator/Navigator.js'; -import MimeType from '../navigator/MimeType.js'; -import MimeTypeArray from '../navigator/MimeTypeArray.js'; -import Plugin from '../navigator/Plugin.js'; -import PluginArray from '../navigator/PluginArray.js'; -import Fetch from '../fetch/Fetch.js'; -import RangeImplementation from '../range/Range.js'; -import DOMRect from '../nodes/element/DOMRect.js'; -import VMGlobalPropertyScript from './VMGlobalPropertyScript.js'; -import * as PerfHooks from 'perf_hooks'; -import VM from 'vm'; -import { Buffer } from 'buffer'; -import { webcrypto } from 'crypto'; -import XMLHttpRequestImplementation from '../xml-http-request/XMLHttpRequest.js'; -import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload.js'; -import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget.js'; -import Base64 from '../base64/Base64.js'; -import IDocument from '../nodes/document/IDocument.js'; -import Attr from '../nodes/attr/Attr.js'; -import NamedNodeMap from '../named-node-map/NamedNodeMap.js'; -import IElement from '../nodes/element/IElement.js'; -import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction.js'; -import RequestInfo from '../fetch/types/IRequestInfo.js'; -import FileList from '../nodes/html-input-element/FileList.js'; -import Stream from 'stream'; -import FormData from '../form-data/FormData.js'; -import AbortController from '../fetch/AbortController.js'; -import AbortSignal from '../fetch/AbortSignal.js'; -import IResponseBody from '../fetch/types/IResponseBody.js'; -import IRequestInfo from '../fetch/types/IRequestInfo.js'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; -import IHappyDOMOptions from './IHappyDOMOptions.js'; -import RadioNodeList from '../nodes/html-form-element/RadioNodeList.js'; -import ValidityState from '../validity-state/ValidityState.js'; -import WindowErrorUtility from './WindowErrorUtility.js'; -import VirtualConsole from '../console/VirtualConsole.js'; -import VirtualConsolePrinter from '../console/VirtualConsolePrinter.js'; -import IHappyDOMSettings from './IHappyDOMSettings.js'; -import PackageVersion from '../version.js'; -import Permissions from '../permissions/Permissions.js'; -import PermissionStatus from '../permissions/PermissionStatus.js'; -import Clipboard from '../clipboard/Clipboard.js'; -import ClipboardItem from '../clipboard/ClipboardItem.js'; -import ClipboardEvent from '../event/events/ClipboardEvent.js'; - -const ORIGINAL_SET_TIMEOUT = setTimeout; -const ORIGINAL_CLEAR_TIMEOUT = clearTimeout; -const ORIGINAL_SET_INTERVAL = setInterval; -const ORIGINAL_CLEAR_INTERVAL = clearInterval; -const ORIGINAL_QUEUE_MICROTASK = queueMicrotask; +import DetachedWindowAPI from './DetachedWindowAPI.js'; +import IOptionalBrowserSettings from '../browser/types/IOptionalBrowserSettings.js'; +import BrowserWindow from './BrowserWindow.js'; +import DetachedBrowser from '../browser/detached-browser/DetachedBrowser.js'; /** - * Browser window. + * Window. * * Reference: * https://developer.mozilla.org/en-US/docs/Web/API/Window. */ -export default class Window extends EventTarget implements IWindow { - // Happy DOM property. - public readonly happyDOM: { - whenAsyncComplete: () => Promise; - cancelAsync: () => void; - asyncTaskManager: AsyncTaskManager; - setWindowSize: (options: { width?: number; height?: number }) => void; - setURL: (url: string) => void; - virtualConsolePrinter: VirtualConsolePrinter | null; - settings: IHappyDOMSettings; - - /** - * @deprecated - */ - setInnerWidth: (width: number) => void; - - /** - * @deprecated - */ - setInnerHeight: (height: number) => void; - } = { - whenAsyncComplete: async (): Promise => { - return await this.happyDOM.asyncTaskManager.whenComplete(); - }, - cancelAsync: (): void => { - this.happyDOM.asyncTaskManager.cancelAll(); - }, - asyncTaskManager: new AsyncTaskManager(), - setWindowSize: (options: { width?: number; height?: number }): void => { - if ( - (options.width !== undefined && this.innerWidth !== options.width) || - (options.height !== undefined && this.innerHeight !== options.height) - ) { - if (options.width !== undefined && this.innerWidth !== options.width) { - (this.innerWidth) = options.width; - (this.outerWidth) = options.width; - } - - if (options.height !== undefined && this.innerHeight !== options.height) { - (this.innerHeight) = options.height; - (this.outerHeight) = options.height; - } - - this.dispatchEvent(new Event('resize')); - } - }, - virtualConsolePrinter: null, - setURL: (url: string) => { - this.location.href = url; - }, - settings: { - disableJavaScriptEvaluation: false, - disableJavaScriptFileLoading: false, - disableCSSFileLoading: false, - disableIframePageLoading: false, - disableComputedStyleRendering: false, - disableErrorCapturing: false, - enableFileSystemHttpRequests: false, - navigator: { - userAgent: `Mozilla/5.0 (X11; ${ - process.platform.charAt(0).toUpperCase() + process.platform.slice(1) + ' ' + process.arch - }) AppleWebKit/537.36 (KHTML, like Gecko) HappyDOM/${PackageVersion.version}` - }, - device: { - prefersColorScheme: 'light', - mediaType: 'screen' - } - }, - setInnerWidth: (width: number): void => this.happyDOM.setWindowSize({ width }), - setInnerHeight: (height: number): void => this.happyDOM.setWindowSize({ height }) - }; - - // Nodes - public readonly Node = Node; - public readonly Attr = Attr; - public readonly SVGSVGElement = SVGSVGElement; - public readonly SVGElement = SVGElement; - public readonly SVGGraphicsElement = SVGGraphicsElement; - public readonly Text = Text; - public readonly Comment = Comment; - public readonly ShadowRoot = ShadowRoot; - public readonly ProcessingInstruction = ProcessingInstruction; - public readonly Element = Element; - public readonly CharacterData = CharacterData; - public readonly Document = Document; - public readonly HTMLDocument = HTMLDocument; - public readonly XMLDocument = XMLDocument; - public readonly SVGDocument = SVGDocument; - - // Element classes - public readonly HTMLElement = HTMLElement; - public readonly HTMLUnknownElement = HTMLUnknownElement; - public readonly HTMLTemplateElement = HTMLTemplateElement; - public readonly HTMLFormElement = HTMLFormElement; - public readonly HTMLInputElement = HTMLInputElement; - public readonly HTMLSelectElement = HTMLSelectElement; - public readonly HTMLTextAreaElement = HTMLTextAreaElement; - public readonly HTMLImageElement = HTMLImageElement; - public readonly HTMLScriptElement = HTMLScriptElement; - public readonly HTMLLinkElement = HTMLLinkElement; - public readonly HTMLStyleElement = HTMLStyleElement; - public readonly HTMLLabelElement = HTMLLabelElement; - public readonly HTMLSlotElement = HTMLSlotElement; - public readonly HTMLMetaElement = HTMLMetaElement; - public readonly HTMLMediaElement = HTMLMediaElement; - public readonly HTMLAudioElement = HTMLAudioElement; - public readonly HTMLVideoElement = HTMLVideoElement; - public readonly HTMLBaseElement = HTMLBaseElement; - public readonly HTMLIFrameElement = HTMLIFrameElement; - public readonly HTMLDialogElement = HTMLDialogElement; - - // Non-implemented element classes - public readonly HTMLHeadElement = HTMLElement; - public readonly HTMLTitleElement = HTMLElement; - public readonly HTMLBodyElement = HTMLElement; - public readonly HTMLHeadingElement = HTMLElement; - public readonly HTMLParagraphElement = HTMLElement; - public readonly HTMLHRElement = HTMLElement; - public readonly HTMLPreElement = HTMLElement; - public readonly HTMLUListElement = HTMLElement; - public readonly HTMLOListElement = HTMLElement; - public readonly HTMLLIElement = HTMLElement; - public readonly HTMLMenuElement = HTMLElement; - public readonly HTMLDListElement = HTMLElement; - public readonly HTMLDivElement = HTMLElement; - public readonly HTMLAnchorElement = HTMLElement; - public readonly HTMLAreaElement = HTMLElement; - public readonly HTMLBRElement = HTMLElement; - public readonly HTMLButtonElement = HTMLElement; - public readonly HTMLCanvasElement = HTMLElement; - public readonly HTMLDataElement = HTMLElement; - public readonly HTMLDataListElement = HTMLElement; - public readonly HTMLDetailsElement = HTMLElement; - public readonly HTMLDirectoryElement = HTMLElement; - public readonly HTMLFieldSetElement = HTMLElement; - public readonly HTMLFontElement = HTMLElement; - public readonly HTMLHtmlElement = HTMLElement; - public readonly HTMLLegendElement = HTMLElement; - public readonly HTMLMapElement = HTMLElement; - public readonly HTMLMarqueeElement = HTMLElement; - public readonly HTMLMeterElement = HTMLElement; - public readonly HTMLModElement = HTMLElement; - public readonly HTMLOutputElement = HTMLElement; - public readonly HTMLPictureElement = HTMLElement; - public readonly HTMLProgressElement = HTMLElement; - public readonly HTMLQuoteElement = HTMLElement; - public readonly HTMLSourceElement = HTMLElement; - public readonly HTMLSpanElement = HTMLElement; - public readonly HTMLTableCaptionElement = HTMLElement; - public readonly HTMLTableCellElement = HTMLElement; - public readonly HTMLTableColElement = HTMLElement; - public readonly HTMLTableElement = HTMLElement; - public readonly HTMLTimeElement = HTMLElement; - public readonly HTMLTableRowElement = HTMLElement; - public readonly HTMLTableSectionElement = HTMLElement; - public readonly HTMLFrameElement = HTMLElement; - public readonly HTMLFrameSetElement = HTMLElement; - public readonly HTMLEmbedElement = HTMLElement; - public readonly HTMLObjectElement = HTMLElement; - public readonly HTMLParamElement = HTMLElement; - public readonly HTMLTrackElement = HTMLElement; - - // Events classes - public readonly Event = Event; - public readonly UIEvent = UIEvent; - public readonly CustomEvent = CustomEvent; - public readonly AnimationEvent = AnimationEvent; - public readonly KeyboardEvent = KeyboardEvent; - public readonly MessageEvent = MessageEvent; - public readonly MouseEvent = MouseEvent; - public readonly PointerEvent = PointerEvent; - public readonly FocusEvent = FocusEvent; - public readonly WheelEvent = WheelEvent; - public readonly InputEvent = InputEvent; - public readonly ErrorEvent = ErrorEvent; - public readonly StorageEvent = StorageEvent; - public readonly SubmitEvent = SubmitEvent; - public readonly ProgressEvent = ProgressEvent; - public readonly MediaQueryListEvent = MediaQueryListEvent; - public readonly ClipboardEvent = ClipboardEvent; - - // Non-implemented event classes - public readonly AudioProcessingEvent = Event; - public readonly BeforeInputEvent = Event; - public readonly BeforeUnloadEvent = Event; - public readonly BlobEvent = Event; - public readonly CloseEvent = Event; - public readonly CompositionEvent = Event; - public readonly CSSFontFaceLoadEvent = Event; - public readonly DeviceLightEvent = Event; - public readonly DeviceMotionEvent = Event; - public readonly DeviceOrientationEvent = Event; - public readonly DeviceProximityEvent = Event; - public readonly DOMTransactionEvent = Event; - public readonly DragEvent = Event; - public readonly EditingBeforeInputEvent = Event; - public readonly FetchEvent = Event; - public readonly GamepadEvent = Event; - public readonly HashChangeEvent = Event; - public readonly IDBVersionChangeEvent = Event; - public readonly MediaStreamEvent = Event; - public readonly MutationEvent = Event; - public readonly OfflineAudioCompletionEvent = Event; - public readonly OverconstrainedError = Event; - public readonly PageTransitionEvent = Event; - public readonly PaymentRequestUpdateEvent = Event; - public readonly PopStateEvent = Event; - public readonly RelatedEvent = Event; - public readonly RTCDataChannelEvent = Event; - public readonly RTCIdentityErrorEvent = Event; - public readonly RTCIdentityEvent = Event; - public readonly RTCPeerConnectionIceEvent = Event; - public readonly SensorEvent = Event; - public readonly SVGEvent = Event; - public readonly SVGZoomEvent = Event; - public readonly TimeEvent = Event; - public readonly TouchEvent = Event; - public readonly TrackEvent = Event; - public readonly TransitionEvent = Event; - public readonly UserProximityEvent = Event; - public readonly WebGLContextEvent = Event; - public readonly TextEvent = Event; - - // Other classes - public readonly NamedNodeMap = NamedNodeMap; - public readonly NodeFilter = NodeFilter; - public readonly NodeIterator = NodeIterator; - public readonly TreeWalker = TreeWalker; - public readonly MutationObserver = MutationObserver; - public readonly MutationRecord = MutationRecord; - public readonly EventTarget = EventTarget; - public readonly MessagePort = MessagePort; - public readonly DataTransfer = DataTransfer; - public readonly DataTransferItem = DataTransferItem; - public readonly DataTransferItemList = DataTransferItemList; - public readonly URL = URL; - public readonly Location = Location; - public readonly CustomElementRegistry = CustomElementRegistry; - public readonly Window = this.constructor; - public readonly XMLSerializer = XMLSerializer; - public readonly ResizeObserver = ResizeObserver; - public readonly CSSStyleSheet = CSSStyleSheet; - public readonly Blob = Blob; - public readonly File = File; - public readonly DOMException = DOMException; - public readonly History = History; - public readonly Screen = Screen; - public readonly Storage = Storage; - public readonly URLSearchParams = URLSearchParams; - public readonly HTMLCollection = HTMLCollection; - public readonly HTMLFormControlsCollection = HTMLFormControlsCollection; - public readonly NodeList = NodeList; - public readonly CSSUnitValue = CSSUnitValue; - public readonly CSSRule = CSSRule; - public readonly CSSContainerRule = CSSContainerRule; - public readonly CSSFontFaceRule = CSSFontFaceRule; - public readonly CSSKeyframeRule = CSSKeyframeRule; - public readonly CSSKeyframesRule = CSSKeyframesRule; - public readonly CSSMediaRule = CSSMediaRule; - public readonly CSSStyleRule = CSSStyleRule; - public readonly CSSSupportsRule = CSSSupportsRule; - public readonly Selection = Selection; - public readonly Navigator = Navigator; - public readonly MimeType = MimeType; - public readonly MimeTypeArray = MimeTypeArray; - public readonly Plugin = Plugin; - public readonly PluginArray = PluginArray; - public readonly FileList = FileList; - public readonly Headers: { new (init?: IHeadersInit): IHeaders } = Headers; - public readonly DOMRect: typeof DOMRect; - public readonly RadioNodeList: typeof RadioNodeList; - public readonly ValidityState: typeof ValidityState; - public readonly Request: { - new (input: IRequestInfo, init?: IRequestInit): IRequest; - }; - public readonly Response: { - new (body?: IResponseBody, init?: IResponseInit): IResponse; - }; - public readonly XMLHttpRequestUpload = XMLHttpRequestUpload; - public readonly XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; - public readonly ReadableStream = Stream.Readable; - public readonly WritableStream = Stream.Writable; - public readonly TransformStream = Stream.Transform; - public readonly AbortController = AbortController; - public readonly AbortSignal = AbortSignal; - public readonly FormData = FormData; - public readonly Permissions = Permissions; - public readonly PermissionStatus = PermissionStatus; - public readonly Clipboard = Clipboard; - public readonly ClipboardItem = ClipboardItem; - public readonly XMLHttpRequest; - public readonly DOMParser: typeof DOMParserImplementation; - public readonly Range; - public readonly FileReader; - public readonly Image; - public readonly DocumentFragment; - public readonly Audio; - public readonly CSSStyleDeclaration = CSSStyleDeclaration; - - // Events - public onload: (event: Event) => void = null; - public onerror: (event: ErrorEvent) => void = null; - - // Public properties. - public readonly document: Document; - public readonly customElements: CustomElementRegistry; - public readonly location: Location; - public readonly history: History; - public readonly navigator: Navigator; - public readonly console: Console; - public readonly self = this; - public readonly top = this; - public readonly parent = this; - public readonly window = this; - public readonly globalThis = this; - public readonly screen: Screen; - public readonly devicePixelRatio = 1; - public readonly sessionStorage: Storage; - public readonly localStorage: Storage; - public readonly performance = PerfHooks.performance; - public readonly innerWidth: number = 1024; - public readonly innerHeight: number = 768; - public readonly outerWidth: number = 1024; - public readonly outerHeight: number = 768; - public readonly crypto = webcrypto; - - // Node.js Globals - public Array: typeof Array; - public ArrayBuffer: typeof ArrayBuffer; - public Boolean: typeof Boolean; - public Buffer = Buffer; - public DataView: typeof DataView; - public Date: typeof Date; - public Error: typeof Error; - public EvalError: typeof EvalError; - public Float32Array: typeof Float32Array; - public Float64Array: typeof Float64Array; - public Function: typeof Function; - public Infinity: typeof Infinity; - public Int16Array: typeof Int16Array; - public Int32Array: typeof Int32Array; - public Int8Array: typeof Int8Array; - public Intl: typeof Intl; - public JSON: typeof JSON; - public Map: MapConstructor; - public Math: typeof Math; - public NaN: typeof NaN; - public Number: typeof Number; - public Object: typeof Object; - public Promise: typeof Promise; - public RangeError: typeof RangeError; - public ReferenceError: typeof ReferenceError; - public RegExp: typeof RegExp; - public Set: SetConstructor; - public String: typeof String; - public Symbol: Function; - public SyntaxError: typeof SyntaxError; - public TypeError: typeof TypeError; - public URIError: typeof URIError; - public Uint16Array: typeof Uint16Array; - public Uint32Array: typeof Uint32Array; - public Uint8Array: typeof Uint8Array; - public Uint8ClampedArray: typeof Uint8ClampedArray; - public WeakMap: WeakMapConstructor; - public WeakSet: WeakSetConstructor; - public decodeURI: typeof decodeURI; - public decodeURIComponent: typeof decodeURIComponent; - public encodeURI: typeof encodeURI; - public encodeURIComponent: typeof encodeURIComponent; - public eval: typeof eval; - /** - * @deprecated - */ - public escape: (str: string) => string; - public global: typeof globalThis; - public isFinite: typeof isFinite; - public isNaN: typeof isNaN; - public parseFloat: typeof parseFloat; - public parseInt: typeof parseInt; - public undefined: typeof undefined; - /** - * @deprecated - */ - public unescape: (str: string) => string; - public gc: () => void; - public v8debug?: unknown; - - // Public internal properties - - // Used for tracking capture event listeners to improve performance when they are not used. - // See EventTarget class. - public _captureEventListenerCount: { [eventType: string]: number } = {}; - - // Private properties - private _setTimeout: (callback: Function, delay?: number, ...args: unknown[]) => NodeJS.Timeout; - private _clearTimeout: (id: NodeJS.Timeout) => void; - private _setInterval: (callback: Function, delay?: number, ...args: unknown[]) => NodeJS.Timeout; - private _clearInterval: (id: NodeJS.Timeout) => void; - private _queueMicrotask: (callback: Function) => void; +export default class Window extends BrowserWindow implements IWindow { + // Detached Window API. + public readonly happyDOM: DetachedWindowAPI; /** * Constructor. @@ -568,463 +23,40 @@ export default class Window extends EventTarget implements IWindow { * @param [options.innerWidth] Inner width. Deprecated. Defaults to "1024". * @param [options.innerHeight] Inner height. Deprecated. Defaults to "768". * @param [options.url] URL. + * @param [options.console] Console. * @param [options.settings] Settings. */ - constructor(options?: IHappyDOMOptions) { - super(); - - this.customElements = new CustomElementRegistry(); - this.location = new Location(); - this.navigator = new Navigator(this); - this.history = new History(); - this.screen = new Screen(); - this.sessionStorage = new Storage(); - this.localStorage = new Storage(); - - if (options) { - if (options.width !== undefined) { - this.innerWidth = options.width; - this.outerWidth = options.width; - } else if (options.innerWidth !== undefined) { - this.innerWidth = options.innerWidth; - this.outerWidth = options.innerWidth; - } - - if (options.height !== undefined) { - this.innerHeight = options.height; - this.outerHeight = options.height; - } else if (options.innerHeight !== undefined) { - this.innerHeight = options.innerHeight; - this.outerHeight = options.innerHeight; - } - - if (options.url !== undefined) { - this.location.href = options.url; - } - - if (options.settings) { - this.happyDOM.settings = { - ...this.happyDOM.settings, - ...options.settings, - navigator: { - ...this.happyDOM.settings.navigator, - ...options.settings.navigator - }, - device: { - ...this.happyDOM.settings.device, - ...options.settings.device - } - }; - } - } - - if (options && options.console) { - this.console = options.console; - } else { - this.happyDOM.virtualConsolePrinter = new VirtualConsolePrinter(); - this.console = new VirtualConsole(this.happyDOM.virtualConsolePrinter); - } - - this._setTimeout = ORIGINAL_SET_TIMEOUT; - this._clearTimeout = ORIGINAL_CLEAR_TIMEOUT; - this._setInterval = ORIGINAL_SET_INTERVAL; - this._clearInterval = ORIGINAL_CLEAR_INTERVAL; - this._queueMicrotask = ORIGINAL_QUEUE_MICROTASK; - - // Binds all methods to "this", so that it will use the correct context when called globally. - for (const key of Object.getOwnPropertyNames(Window.prototype).concat( - Object.getOwnPropertyNames(EventTarget.prototype) - )) { - if ( - key !== 'constructor' && - key[0] !== '_' && - key[0] === key[0].toLowerCase() && - typeof this[key] === 'function' - ) { - this[key] = this[key].bind(this); - } - } - - HTMLDocument._defaultView = this; - HTMLDocument._windowClass = Window; - - const document = new HTMLDocument(); - - this.document = document; - - // We need to set the correct owner document when the class is constructed. - // To achieve this we will extend the original implementation with a class that sets the owner document. - - ResponseImplementation._ownerDocument = document; - RequestImplementation._ownerDocument = document; - ImageImplementation._ownerDocument = document; - DocumentFragmentImplementation._ownerDocument = document; - FileReaderImplementation._ownerDocument = document; - DOMParserImplementation._ownerDocument = document; - RangeImplementation._ownerDocument = document; - XMLHttpRequestImplementation._ownerDocument = document; - - /* eslint-disable jsdoc/require-jsdoc */ - class Response extends ResponseImplementation { - public static _ownerDocument: IDocument = document; - } - class Request extends RequestImplementation { - public static _ownerDocument: IDocument = document; - } - class Image extends ImageImplementation { - public static _ownerDocument: IDocument = document; - } - class DocumentFragment extends DocumentFragmentImplementation { - public static _ownerDocument: IDocument = document; - } - class FileReader extends FileReaderImplementation { - public static _ownerDocument: IDocument = document; - } - class DOMParser extends DOMParserImplementation { - public static _ownerDocument: IDocument = document; - } - class XMLHttpRequest extends XMLHttpRequestImplementation { - public static _ownerDocument: IDocument = document; - } - class Range extends RangeImplementation { - public static _ownerDocument: IDocument = document; - } - class Audio extends AudioImplementation { - public static _ownerDocument: IDocument = document; - } - /* eslint-enable jsdoc/require-jsdoc */ - - this.Response = Response; - this.Request = Request; - this.Image = Image; - this.DocumentFragment = DocumentFragment; - this.FileReader = FileReader; - this.DOMParser = DOMParser; - this.XMLHttpRequest = XMLHttpRequest; - this.Range = Range; - this.Audio = Audio; - - this._setupVMContext(); - - this.document._onWindowReady(); - } - - /** - * The number of pixels that the document is currently scrolled horizontally - * - * @returns number - */ - public get scrollX(): number { - return this.document?.documentElement?.scrollLeft ?? 0; - } - - /** - * The read-only Window property pageXOffset is an alias for scrollX. - * - * @returns number - */ - public get pageXOffset(): number { - return this.scrollX; - } - - /** - * The number of pixels that the document is currently scrolled vertically - * - * @returns number - */ - public get scrollY(): number { - return this.document?.documentElement?.scrollTop ?? 0; - } - - /** - * The read-only Window property pageYOffset is an alias for scrollY. - * - * @returns number - */ - public get pageYOffset(): number { - return this.scrollY; - } - - /** - * The CSS interface holds useful CSS-related methods. - * - * @returns CSS interface. - */ - public get CSS(): CSS { - return new CSS(); - } - - /** - * Returns an object containing the values of all CSS properties of an element. - * - * @param element Element. - * @returns CSS style declaration. - */ - public getComputedStyle(element: IElement): CSSStyleDeclaration { - element['_computedStyle'] = element['_computedStyle'] || new CSSStyleDeclaration(element, true); - return element['_computedStyle']; - } - - /** - * Returns selection. - * - * @returns Selection. - */ - public getSelection(): Selection { - return this.document.getSelection(); - } - - /** - * Scrolls to a particular set of coordinates. - * - * @param x X position or options object. - * @param y Y position. - */ - public scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void { - if (typeof x === 'object') { - if (x.behavior === 'smooth') { - this.setTimeout(() => { - if (x.top !== undefined) { - (this.document.documentElement.scrollTop) = x.top; - } - if (x.left !== undefined) { - (this.document.documentElement.scrollLeft) = x.left; - } - }); - } else { - if (x.top !== undefined) { - (this.document.documentElement.scrollTop) = x.top; - } - if (x.left !== undefined) { - (this.document.documentElement.scrollLeft) = x.left; - } - } - } else if (x !== undefined && y !== undefined) { - (this.document.documentElement.scrollLeft) = x; - (this.document.documentElement.scrollTop) = y; - } - } - - /** - * Scrolls to a particular set of coordinates. - * - * @param x X position or options object. - * @param y Y position. - */ - public scrollTo( - x: { top?: number; left?: number; behavior?: string } | number, - y?: number - ): void { - this.scroll(x, y); - } - - /** - * Returns a new MediaQueryList object that can then be used to determine if the document matches the media query string. - * - * @param mediaQueryString A string specifying the media query to parse into a MediaQueryList. - * @returns A new MediaQueryList. - */ - public matchMedia(mediaQueryString: string): MediaQueryList { - return new MediaQueryList({ ownerWindow: this, media: mediaQueryString }); - } - - /** - * Sets a timer which executes a function once the timer expires. - * - * @param callback Function to be executed. - * @param [delay=0] Delay in ms. - * @param args Arguments passed to the callback function. - * @returns Timeout ID. - */ - public setTimeout(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { - const id = this._setTimeout(() => { - if (this.happyDOM.settings.disableErrorCapturing) { - callback(...args); - } else { - WindowErrorUtility.captureError(this, () => callback(...args)); - } - this.happyDOM.asyncTaskManager.endTimer(id); - }, delay); - this.happyDOM.asyncTaskManager.startTimer(id); - return id; - } - - /** - * Cancels a timeout previously established by calling setTimeout(). - * - * @param id ID of the timeout. - */ - public clearTimeout(id: NodeJS.Timeout): void { - this._clearTimeout(id); - this.happyDOM.asyncTaskManager.endTimer(id); - } - - /** - * Calls a function with a fixed time delay between each call. - * - * @param callback Function to be executed. - * @param [delay=0] Delay in ms. - * @param args Arguments passed to the callback function. - * @returns Interval ID. - */ - public setInterval(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { - const id = this._setInterval(() => { - if (this.happyDOM.settings.disableErrorCapturing) { - callback(...args); - } else { - WindowErrorUtility.captureError( - this, - () => callback(...args), - () => this.clearInterval(id) - ); - } - }, delay); - this.happyDOM.asyncTaskManager.startTimer(id); - return id; - } - - /** - * Cancels a timed repeating action which was previously established by a call to setInterval(). - * - * @param id ID of the interval. - */ - public clearInterval(id: NodeJS.Timeout): void { - this._clearInterval(id); - this.happyDOM.asyncTaskManager.endTimer(id); - } - - /** - * Mock animation frames with timeouts. - * - * @param callback Callback. - * @returns ID. - */ - public requestAnimationFrame(callback: (timestamp: number) => void): NodeJS.Immediate { - const id = global.setImmediate(() => { - if (this.happyDOM.settings.disableErrorCapturing) { - callback(this.performance.now()); - } else { - WindowErrorUtility.captureError(this, () => callback(this.performance.now())); - } - this.happyDOM.asyncTaskManager.endImmediate(id); + constructor(options?: { + width?: number; + height?: number; + /** @deprecated Replaced by the "width" property. */ + innerWidth?: number; + /** @deprecated Replaced by the "height" property. */ + innerHeight?: number; + url?: string; + console?: Console; + settings?: IOptionalBrowserSettings; + }) { + const browser = new DetachedBrowser(BrowserWindow, { + console: options?.console, + settings: options?.settings }); - this.happyDOM.asyncTaskManager.startImmediate(id); - return id; - } + const browserPage = browser.defaultContext.pages[0]; + const browserFrame = browserPage.mainFrame; - /** - * Mock animation frames with timeouts. - * - * @param id ID. - */ - public cancelAnimationFrame(id: NodeJS.Immediate): void { - global.clearImmediate(id); - this.happyDOM.asyncTaskManager.endImmediate(id); - } - - /** - * Queues a microtask to be executed at a safe time prior to control returning to the browser's event loop. - * - * @param callback Function to be executed. - */ - public queueMicrotask(callback: Function): void { - let isAborted = false; - const taskId = this.happyDOM.asyncTaskManager.startTask(() => (isAborted = true)); - this._queueMicrotask(() => { - if (!isAborted) { - if (this.happyDOM.settings.disableErrorCapturing) { - callback(); - } else { - WindowErrorUtility.captureError(this, <() => unknown>callback); - } - this.happyDOM.asyncTaskManager.endTask(taskId); - } - }); - } - - /** - * This method provides an easy, logical way to fetch resources asynchronously across the network. - * - * @param url URL. - * @param [init] Init. - * @returns Promise. - */ - public async fetch(url: RequestInfo, init?: IRequestInit): Promise { - return await new Fetch({ ownerDocument: this.document, url, init }).send(); - } - - /** - * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa - * @param data Binay data. - * @returns Base64-encoded string. - */ - public btoa(data: unknown): string { - return Base64.btoa(data); - } - - /** - * Decodes a string of data which has been encoded using Base64 encoding. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/atob - * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. - * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. - * @param data Binay string. - * @returns An ASCII string containing decoded data from encodedData. - */ - public atob(data: unknown): string { - return Base64.atob(data); - } - - /** - * Safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. - * - * @param message Message. - * @param [targetOrigin=*] Target origin. - * @param _transfer Transfer. Not implemented. - */ - public postMessage(message: unknown, targetOrigin = '*', _transfer?: unknown[]): void { - // TODO: Implement transfer. - - if (targetOrigin && targetOrigin !== '*' && this.location.origin !== targetOrigin) { - throw new DOMException( - `Failed to execute 'postMessage' on 'Window': The target origin provided ('${targetOrigin}') does not match the recipient window\'s origin ('${this.location.origin}').`, - DOMExceptionNameEnum.securityError - ); - } - - try { - JSON.stringify(message); - } catch (error) { - throw new DOMException( - `Failed to execute 'postMessage' on 'Window': The provided message cannot be serialized.`, - DOMExceptionNameEnum.invalidStateError - ); + if (options && (options.width || options.height || options.innerWidth || options.innerHeight)) { + Object.assign(browserPage.viewport, { + width: options.width || options.innerWidth || browserPage.viewport.width, + height: options.height || options.innerHeight || browserPage.viewport.height + }); } - this.window.setTimeout(() => - this.dispatchEvent( - new MessageEvent('message', { - data: message, - origin: this.parent.location.origin, - source: this.parent, - lastEventId: '' - }) - ) - ); - } + super(browserFrame, { + url: options?.url + }); - /** - * Setup of VM context. - */ - protected _setupVMContext(): void { - if (!VM.isContext(this)) { - VM.createContext(this); + browserFrame.window = this; - // Sets global properties from the VM to the Window object. - // Otherwise "this.Array" will be undefined for example. - VMGlobalPropertyScript.runInContext(this); - } + this.happyDOM = new DetachedWindowAPI(browserFrame); } } diff --git a/packages/happy-dom/src/window/WindowBrowserSettingsReader.ts b/packages/happy-dom/src/window/WindowBrowserSettingsReader.ts new file mode 100644 index 000000000..a8b51b8a7 --- /dev/null +++ b/packages/happy-dom/src/window/WindowBrowserSettingsReader.ts @@ -0,0 +1,55 @@ +import IBrowserSettings from '../browser/types/IBrowserSettings.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import IBrowserWindow from './IBrowserWindow.js'; + +/** + * Browser settings reader that will allow to read settings more securely as it is not possible to override a settings object to make DOM functionality act on it. + */ +export default class WindowBrowserSettingsReader { + static #settings: IBrowserSettings[] = []; + + /** + * Returns browser settings. + * + * @param window Window. + * @returns Settings. + */ + public static getSettings(window: IBrowserWindow): IBrowserSettings | null { + const id = window[PropertySymbol.happyDOMSettingsID]; + + if (id === undefined || !this.#settings[id]) { + return null; + } + + return this.#settings[id]; + } + + /** + * Sets browser settings. + * + * @param window Window. + * @param settings Settings. + */ + public static setSettings(window: IBrowserWindow, settings: IBrowserSettings): void { + if (window[PropertySymbol.happyDOMSettingsID] !== undefined) { + return; + } + window[PropertySymbol.happyDOMSettingsID] = this.#settings.length; + this.#settings.push(settings); + } + + /** + * Removes browser settings. + * + * @param window Window. + */ + public static removeSettings(window: IBrowserWindow): void { + const id = window[PropertySymbol.happyDOMSettingsID]; + + if (id !== undefined && this.#settings[id]) { + delete this.#settings[id]; + } + + delete window[PropertySymbol.happyDOMSettingsID]; + } +} diff --git a/packages/happy-dom/src/window/WindowErrorUtility.ts b/packages/happy-dom/src/window/WindowErrorUtility.ts index 7c76b5482..ae5fdf68c 100644 --- a/packages/happy-dom/src/window/WindowErrorUtility.ts +++ b/packages/happy-dom/src/window/WindowErrorUtility.ts @@ -1,6 +1,7 @@ -import IWindow from './IWindow.js'; +import IBrowserWindow from './IBrowserWindow.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import ErrorEvent from '../event/events/ErrorEvent.js'; -import { IElement } from '../index.js'; +import IElement from '../nodes/element/IElement.js'; /** * Error utility. @@ -18,7 +19,7 @@ export default class WindowErrorUtility { * @returns Result. */ public static captureError( - elementOrWindow: IWindow | IElement, + elementOrWindow: IBrowserWindow | IElement, callback: () => T, cleanup?: () => void ): T | null { @@ -51,18 +52,17 @@ export default class WindowErrorUtility { * @param elementOrWindow Element or Window. * @param error Error. */ - public static dispatchError(elementOrWindow: IWindow | IElement, error: Error): void { - if ((elementOrWindow).console) { - (elementOrWindow).console.error(error); + public static dispatchError(elementOrWindow: IBrowserWindow | IElement, error: Error): void { + if ((elementOrWindow).console) { + (elementOrWindow).console.error(error); elementOrWindow.dispatchEvent(new ErrorEvent('error', { message: error.message, error })); } else { - (elementOrWindow).ownerDocument.defaultView.console.error(error); + (elementOrWindow)[PropertySymbol.ownerDocument][ + PropertySymbol.defaultView + ].console.error(error); (elementOrWindow).dispatchEvent( new ErrorEvent('error', { message: error.message, error }) ); - (elementOrWindow).ownerDocument.defaultView.dispatchEvent( - new ErrorEvent('error', { message: error.message, error }) - ); } } } diff --git a/packages/happy-dom/src/window/WindowPageOpenUtility.ts b/packages/happy-dom/src/window/WindowPageOpenUtility.ts new file mode 100644 index 000000000..d55c361ff --- /dev/null +++ b/packages/happy-dom/src/window/WindowPageOpenUtility.ts @@ -0,0 +1,193 @@ +import IBrowserWindow from './IBrowserWindow.js'; +import CrossOriginBrowserWindow from './CrossOriginBrowserWindow.js'; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import FetchCORSUtility from '../fetch/utilities/FetchCORSUtility.js'; +import ICrossOriginBrowserWindow from './ICrossOriginBrowserWindow.js'; +import BrowserFrameURL from '../browser/utilities/BrowserFrameURL.js'; +import * as PropertySymbol from '../PropertySymbol.js'; + +/** + * Window page open handler. + */ +export default class WindowPageOpenUtility { + /** + * Opens a page. + * + * @param browserFrame Browser frame. + * @param [options] Options. + * @param [options.url] URL. + * @param [options.target] Target. + * @param [options.features] Window features. + */ + public static openPage( + browserFrame: IBrowserFrame, + options?: { + url?: string; + target?: string; + features?: string; + } + ): IBrowserWindow | ICrossOriginBrowserWindow | null { + const features = this.getWindowFeatures(options?.features || ''); + const target = options?.target !== undefined ? String(options.target) : null; + const originURL = browserFrame.window.location; + const targetURL = BrowserFrameURL.getRelativeURL(browserFrame, options.url); + const oldWindow = browserFrame.window; + let targetFrame: IBrowserFrame; + + switch (target) { + case '_self': + targetFrame = browserFrame; + break; + case '_top': + targetFrame = browserFrame.page.mainFrame; + break; + case '_parent': + targetFrame = browserFrame.parentFrame ?? browserFrame; + break; + case '_blank': + default: + const newPage = browserFrame.page.context.newPage(); + targetFrame = newPage.mainFrame; + targetFrame[PropertySymbol.openerFrame] = browserFrame; + break; + } + + targetFrame + .goto(targetURL.href, { + referrer: features.noreferrer ? 'no-referrer' : undefined + }) + .catch((error) => targetFrame.page.console.error(error)); + + if (targetURL.protocol === 'javascript:') { + return targetFrame.window; + } + + // When using a detached Window instance directly and not via the Browser API we will not navigate and the window for the frame will not have changed. + if (targetFrame === browserFrame && browserFrame.window === oldWindow) { + return null; + } + + if (features.popup && target !== '_self' && target !== '_top' && target !== '_parent') { + targetFrame[PropertySymbol.popup] = true; + + if (features?.width || features?.height) { + targetFrame.page.setViewport({ + width: features?.width, + height: features?.height + }); + } + + if (features?.left) { + (targetFrame.window.screenLeft) = features.left; + (targetFrame.window.screenX) = features.left; + } + + if (features?.top) { + (targetFrame.window.screenTop) = features.top; + (targetFrame.window.screenY) = features.top; + } + } + + if ( + target && + target !== '_self' && + target !== '_top' && + target !== '_parent' && + target !== '_blank' + ) { + (targetFrame.window.name) = target; + } + + const isCORS = FetchCORSUtility.isCORS(originURL, targetFrame.url); + + if ( + !features.noopener && + !features.noreferrer && + browserFrame.window && + targetFrame[PropertySymbol.openerFrame] && + targetFrame.window !== browserFrame.window + ) { + targetFrame[PropertySymbol.openerWindow] = isCORS + ? new CrossOriginBrowserWindow(browserFrame.window) + : browserFrame.window; + } + + if (features.noopener || features.noreferrer) { + return null; + } + + if (isCORS) { + return new CrossOriginBrowserWindow(targetFrame.window, browserFrame.window); + } + + return targetFrame.window; + } + + /** + * Returns window features. + * + * @param features Window features string. + * @returns Window features. + */ + private static getWindowFeatures(features: string): { + popup: boolean; + width: number; + height: number; + left: number; + top: number; + noopener: boolean; + noreferrer: boolean; + } { + const parts = features.split(','); + const result: { + popup: boolean; + width: number; + height: number; + left: number; + top: number; + noopener: boolean; + noreferrer: boolean; + } = { + popup: false, + width: 0, + height: 0, + left: 0, + top: 0, + noopener: false, + noreferrer: false + }; + + for (const part of parts) { + const [key, value] = part.split('='); + switch (key) { + case 'popup': + result.popup = !value || value === 'yes' || value === '1' || value === 'true'; + break; + case 'width': + case 'innerWidth': + result.width = parseInt(value, 10); + break; + case 'height': + case 'innerHeight': + result.height = parseInt(value, 10); + break; + case 'left': + case 'screenX': + result.left = parseInt(value, 10); + break; + case 'top': + case 'screenY': + result.top = parseInt(value, 10); + break; + case 'noopener': + result.noopener = true; + break; + case 'noreferrer': + result.noreferrer = true; + break; + } + } + + return result; + } +} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index c3685e7f0..12066da3c 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -1,8 +1,5 @@ -import FS from 'fs'; -import ChildProcess from 'child_process'; -import HTTP from 'http'; -import HTTPS from 'https'; import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import XMLHttpRequestReadyStateEnum from './XMLHttpRequestReadyStateEnum.js'; import Event from '../event/Event.js'; import IDocument from '../nodes/document/IDocument.js'; @@ -10,46 +7,22 @@ import Blob from '../file/Blob.js'; import XMLHttpRequestUpload from './XMLHttpRequestUpload.js'; import DOMException from '../exception/DOMException.js'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; -import { UrlObject } from 'url'; -import URL from '../url/URL.js'; -import XMLHttpRequestURLUtility from './utilities/XMLHttpRequestURLUtility.js'; -import ProgressEvent from '../event/events/ProgressEvent.js'; import XMLHttpResponseTypeEnum from './XMLHttpResponseTypeEnum.js'; -import XMLHttpRequestCertificate from './XMLHttpRequestCertificate.js'; -import XMLHttpRequestSyncRequestScriptBuilder from './utilities/XMLHttpRequestSyncRequestScriptBuilder.js'; -import IconvLite from 'iconv-lite'; import ErrorEvent from '../event/events/ErrorEvent.js'; - -// These headers are not user setable. -// The following are allowed but banned in the spec: -// * User-agent -const FORBIDDEN_REQUEST_HEADERS = [ - 'accept-charset', - 'accept-encoding', - 'access-control-request-headers', - 'access-control-request-method', - 'connection', - 'content-length', - 'content-transfer-encoding', - 'cookie', - 'cookie2', - 'date', - 'expect', - 'host', - 'keep-alive', - 'origin', - 'referer', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'via' -]; - -// These request methods are not allowed -const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; -// Match Content-Type header charset -const CONTENT_TYPE_ENCODING_REGEXP = /charset=([^;]*)/i; +import IBrowserFrame from '../browser/types/IBrowserFrame.js'; +import IBrowserWindow from '../window/IBrowserWindow.js'; +import Headers from '../fetch/Headers.js'; +import Fetch from '../fetch/Fetch.js'; +import SyncFetch from '../fetch/SyncFetch.js'; +import Request from '../fetch/Request.js'; +import IResponse from '../fetch/types/IResponse.js'; +import ISyncResponse from '../fetch/types/ISyncResponse.js'; +import AbortController from '../fetch/AbortController.js'; +import ProgressEvent from '../event/events/ProgressEvent.js'; +import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; +import IRequestBody from '../fetch/types/IRequestBody.js'; +import XMLHttpRequestResponseDataParser from './utilities/XMLHttpRequestResponseDataParser.js'; +import FetchRequestHeaderUtility from '../fetch/utilities/FetchRequestHeaderUtility.js'; /** * XMLHttpRequest. @@ -58,9 +31,6 @@ const CONTENT_TYPE_ENCODING_REGEXP = /charset=([^;]*)/i; * https://github.com/mjwwit/node-XMLHttpRequest/blob/master/lib/XMLHttpRequest.js */ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { - // Owner document is set by a sub-class in the Window constructor - public static _ownerDocument: IDocument = null; - // Constants public static UNSENT = XMLHttpRequestReadyStateEnum.unsent; public static OPENED = XMLHttpRequestReadyStateEnum.opened; @@ -70,65 +40,31 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Public properties public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); + public withCredentials = false; // Private properties - private readonly _ownerDocument: IDocument = null; - private _state: { - incommingMessage: - | HTTP.IncomingMessage - | { headers: { [name: string]: string | string[] }; statusCode: number }; - response: ArrayBuffer | Blob | IDocument | object | string; - responseType: XMLHttpResponseTypeEnum | ''; - responseText: string; - responseXML: IDocument; - responseURL: string; - readyState: XMLHttpRequestReadyStateEnum; - asyncRequest: HTTP.ClientRequest; - asyncTaskID: number; - requestHeaders: object; - status: number; - statusText: string; - send: boolean; - error: boolean; - aborted: boolean; - } = { - incommingMessage: null, - response: null, - responseType: '', - responseText: '', - responseXML: null, - responseURL: '', - readyState: XMLHttpRequestReadyStateEnum.unsent, - asyncRequest: null, - asyncTaskID: null, - requestHeaders: {}, - status: null, - statusText: null, - send: false, - error: false, - aborted: false - }; - - private _settings: { - method: string; - url: string; - async: boolean; - user: string; - password: string; - } = { - method: null, - url: null, - async: true, - user: null, - password: null - }; + #browserFrame: IBrowserFrame; + #window: IBrowserWindow; + #async = true; + #abortController: AbortController | null = null; + #aborted = false; + #request: Request | null = null; + #response: IResponse | ISyncResponse | null = null; + #responseType: XMLHttpResponseTypeEnum | '' = ''; + #responseBody: ArrayBuffer | Blob | IDocument | object | string | null = null; + #readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent; /** * Constructor. + * + * @param injected Injected properties. + * @param injected.browserFrame Browser frame. + * @param injected.window Window. */ - constructor() { + constructor(injected: { browserFrame: IBrowserFrame; window: IBrowserWindow }) { super(); - this._ownerDocument = XMLHttpRequest._ownerDocument; + this.#browserFrame = injected.browserFrame; + this.#window = injected.window; } /** @@ -137,7 +73,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Status. */ public get status(): number { - return this._state.status; + return this.#response?.status || 0; } /** @@ -146,7 +82,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Status text. */ public get statusText(): string { - return this._state.statusText; + return this.#response?.statusText || ''; } /** @@ -154,26 +90,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * * @returns Response. */ - public get response(): ArrayBuffer | Blob | IDocument | object | string { - return this._state.response; - } - - /** - * Returns the response URL. - * - * @returns Response URL. - */ - public get responseURL(): string { - return this._state.responseURL; - } + public get response(): ArrayBuffer | Blob | IDocument | object | string | null { + if (!this.#response) { + return ''; + } - /** - * Returns the ready state. - * - * @returns Ready state. - */ - public get readyState(): XMLHttpRequestReadyStateEnum { - return this._state.readyState; + return this.#responseBody; } /** @@ -183,13 +105,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns The response text. */ public get responseText(): string { - if (this.responseType === XMLHttpResponseTypeEnum.text || this.responseType === '') { - return this._state.responseText; + if (this.responseType !== XMLHttpResponseTypeEnum.text && this.responseType !== '') { + throw new DOMException( + `Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.responseType}').`, + DOMExceptionNameEnum.invalidStateError + ); } - throw new DOMException( - `Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.responseType}').`, - DOMExceptionNameEnum.invalidStateError - ); + return this.#responseBody ?? ''; } /** @@ -199,13 +121,31 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Response XML. */ public get responseXML(): IDocument { - if (this.responseType === XMLHttpResponseTypeEnum.document || this.responseType === '') { - return this._state.responseXML; + if (this.responseType !== XMLHttpResponseTypeEnum.document && this.responseType !== '') { + throw new DOMException( + `Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.responseType}').`, + DOMExceptionNameEnum.invalidStateError + ); } - throw new DOMException( - `Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.responseType}').`, - DOMExceptionNameEnum.invalidStateError - ); + return this.responseType === '' ? null : this.#responseBody; + } + + /** + * Returns the response URL. + * + * @returns Response URL. + */ + public get responseURL(): string { + return this.#response?.url || ''; + } + + /** + * Returns the ready state. + * + * @returns Ready state. + */ + public get readyState(): XMLHttpRequestReadyStateEnum { + return this.#readyState; } /** @@ -227,13 +167,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } // Sync requests can only have empty string or 'text' as response type. - if (!this._settings.async) { + if (!this.#async) { throw new DOMException( `Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document.`, DOMExceptionNameEnum.invalidStateError ); } - this._state.responseType = type; + this.#responseType = type; } /** @@ -242,7 +182,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Response type. */ public get responseType(): XMLHttpResponseTypeEnum | '' { - return this._state.responseType; + return this.#responseType; } /** @@ -255,19 +195,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param [password] Password for basic authentication (optional). */ public open(method: string, url: string, async = true, user?: string, password?: string): void { - this.abort(); - - this._state.aborted = false; - this._state.error = false; - - const upperMethod = method.toUpperCase(); - - // Check for valid request method - if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { - throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); - } - - // Check responseType. if (!async && !!this.responseType && this.responseType !== XMLHttpResponseTypeEnum.text) { throw new DOMException( `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`, @@ -275,25 +202,35 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - this._settings = { - method: upperMethod, - url: url, - async: async, - user: user || null, - password: password || null - }; + const headers = new Headers(); + if (user) { + const authBuffer = Buffer.from(`${user}:${password || ''}`); + headers.set('Authorization', 'Basic ' + authBuffer.toString('base64')); + } - this._setState(XMLHttpRequestReadyStateEnum.opened); + this.#async = async; + this.#aborted = false; + this.#response = null; + this.#responseBody = null; + this.#abortController = new AbortController(); + this.#request = new this.#window.Request(url, { + method, + headers, + signal: this.#abortController.signal, + credentials: this.withCredentials ? 'include' : 'omit' + }); + + this.#readyState = XMLHttpRequestReadyStateEnum.opened; } /** * Sets a header for the request. * - * @param header Header name - * @param value Header value + * @param name Header name. + * @param value Header value. * @returns Header added. */ - public setRequestHeader(header: string, value: string): boolean { + public setRequestHeader(name: string, value: string): boolean { if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.`, @@ -301,20 +238,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - const lowerHeader = header.toLowerCase(); - - if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { + // TODO: Use FetchRequestHeaderUtility.removeForbiddenHeaders() instead. + if (FetchRequestHeaderUtility.isHeaderForbidden(name)) { return false; } - if (this._state.send) { - throw new DOMException( - `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': Request is in progress.`, - DOMExceptionNameEnum.invalidStateError - ); - } - - this._state.requestHeaders[lowerHeader] = value; + this.#request.headers.set(name, value); return true; } @@ -325,24 +254,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param header header Name of header to get. * @returns string Text of the header or null if it doesn't exist. */ - public getResponseHeader(header: string): string { - const lowerHeader = header.toLowerCase(); - - // Cookie headers are excluded for security reasons as per spec. - if ( - typeof header === 'string' && - header !== 'set-cookie' && - header !== 'set-cookie2' && - this.readyState > XMLHttpRequestReadyStateEnum.opened && - this._state.incommingMessage.headers[lowerHeader] && - !this._state.error - ) { - return Array.isArray(this._state.incommingMessage.headers[lowerHeader]) - ? (this._state.incommingMessage.headers[lowerHeader]).join(', ') - : this._state.incommingMessage.headers[lowerHeader]; - } - - return null; + public getResponseHeader(header: string): string | null { + return this.#response?.headers.get(header) ?? null; } /** @@ -351,16 +264,16 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns A string with all response headers separated by CR+LF. */ public getAllResponseHeaders(): string { - if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._state.error) { + if (!this.#response) { return ''; } const result = []; - for (const name of Object.keys(this._state.incommingMessage.headers)) { - // Cookie headers are excluded for security reasons as per spec. - if (name !== 'set-cookie' && name !== 'set-cookie2') { - result.push(`${name}: ${this._state.incommingMessage.headers[name]}`); + for (const [name, value] of this.#response?.headers) { + const lowerName = name.toLowerCase(); + if (lowerName !== 'set-cookie' && lowerName !== 'set-cookie2') { + result.push(`${name}: ${value}`); } } @@ -370,9 +283,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * Sends the request to the server. * - * @param data Optional data to send as request body. + * @param body Optional data to send as request body. */ - public send(data?: string): void { + public send(body?: IDocument | IRequestBody): void { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( `Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.`, @@ -380,116 +293,21 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - if (this._state.send) { - throw new DOMException( - `Failed to execute 'send' on 'XMLHttpRequest': Send has already been called.`, - DOMExceptionNameEnum.invalidStateError - ); - } - - const { location } = this._ownerDocument.defaultView; - - const url = new URL(this._settings.url, location); - - // Security check. - if (url.protocol === 'http:' && location.protocol === 'https:') { - throw new DOMException( - `Mixed Content: The page at '${location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${url.href}'. This request has been blocked; the content must be served over HTTPS.`, - DOMExceptionNameEnum.securityError - ); - } - - // Load files off the local filesystem (file://) - if (XMLHttpRequestURLUtility.isLocal(url)) { - if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { - throw new DOMException( - 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', - DOMExceptionNameEnum.securityError - ); - } - - if (this._settings.method !== 'GET') { - throw new DOMException( - 'Failed to send local file system request. Only "GET" method is supported for local file system requests.', - DOMExceptionNameEnum.notSupportedError - ); - } - - if (this._settings.async) { - this._sendLocalAsyncRequest(url).catch((error) => this._onError(error)); - } else { - this._sendLocalSyncRequest(url); - } - return; - } - - // TODO: CORS check. - - const host = XMLHttpRequestURLUtility.getHost(url); - const ssl = XMLHttpRequestURLUtility.isSSL(url); - - // Default to port 80. If accessing localhost on another port be sure - // To use http://localhost:port/path - const port = Number(url.port) || (ssl ? 443 : 80); - // Add query string if one is used - const uri = url.pathname + (url.search ? url.search : ''); - - // Set the Host header or the server may reject the request - this._state.requestHeaders['host'] = host; - if (!((ssl && port === 443) || port === 80)) { - this._state.requestHeaders['host'] += ':' + url.port; - } - - // Set Basic Auth if necessary - if (this._settings.user) { - this._settings.password ??= ''; - const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); - this._state.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); - } - // Set the Content-Length header if method is POST - switch (this._settings.method) { - case 'GET': - case 'HEAD': - data = null; - break; - case 'POST': - this._state.requestHeaders['content-type'] ??= 'text/plain;charset=UTF-8'; - if (data) { - this._state.requestHeaders['content-length'] = Buffer.isBuffer(data) - ? data.length - : Buffer.byteLength(data); - } else { - this._state.requestHeaders['content-length'] = 0; - } - break; - - default: - break; - } - - const options: HTTPS.RequestOptions = { - host: host, - port: port, - path: uri, - method: this._settings.method, - headers: { ...this._getDefaultRequestHeaders(), ...this._state.requestHeaders }, - agent: false, - rejectUnauthorized: true - }; - - if (ssl) { - options.key = XMLHttpRequestCertificate.key; - options.cert = XMLHttpRequestCertificate.cert; + // When body is a Document, serialize it to a string. + if ( + typeof body === 'object' && + body !== null && + (body)[PropertySymbol.nodeType] === NodeTypeEnum.documentNode + ) { + body = (body).documentElement.outerHTML; } - // Reset error flag - this._state.error = false; - - // Handle async requests - if (this._settings.async) { - this._sendAsyncRequest(options, ssl, data).catch((error) => this._onError(error)); + if (this.#async) { + this.#sendAsync(body).catch((error) => { + throw error; + }); } else { - this._sendSyncRequest(options, ssl, data); + this.#sendSync(body); } } @@ -497,581 +315,181 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * Aborts a request. */ public abort(): void { - if (this._state.asyncRequest) { - this._state.asyncRequest.destroy(); - this._state.asyncRequest = null; - } - - this._state.status = null; - this._state.statusText = null; - this._state.requestHeaders = {}; - this._state.responseText = ''; - this._state.responseXML = null; - this._state.aborted = true; - this._state.error = true; - - if ( - this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._state.send) && - this.readyState !== XMLHttpRequestReadyStateEnum.done - ) { - this._state.send = false; - this._setState(XMLHttpRequestReadyStateEnum.done); - } - this._state.readyState = XMLHttpRequestReadyStateEnum.unsent; - - if (this._state.asyncTaskID !== null) { - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); + if (this.#aborted) { + return; } + this.#aborted = true; + this.#abortController.abort(); } /** - * Changes readyState and calls onreadystatechange. + * Sends the request to the server asynchronously. * - * @param state + * @param body Optional data to send as request body. */ - private _setState(state: XMLHttpRequestReadyStateEnum): void { - if ( - this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._state.aborted) - ) { - return; - } + async #sendAsync(body?: IRequestBody): Promise { + const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(() => + this.abort() + ); - this._state.readyState = state; + this.#readyState = XMLHttpRequestReadyStateEnum.loading; - if ( - this._settings.async || - this.readyState < XMLHttpRequestReadyStateEnum.opened || - this.readyState === XMLHttpRequestReadyStateEnum.done - ) { - this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('loadstart')); + + if (body) { + this.#request = new this.#window.Request(this.#request.url, { + method: this.#request.method, + headers: this.#request.headers, + signal: this.#abortController.signal, + credentials: this.#request.credentials, + body + }); } - if (this.readyState === XMLHttpRequestReadyStateEnum.done) { - let fire: Event; + this.#abortController.signal.addEventListener('abort', () => { + this.#aborted = true; + this.#readyState = XMLHttpRequestReadyStateEnum.unsent; + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + this.dispatchEvent(new Event('readystatechange')); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); + }); - if (this._state.aborted) { - fire = new Event('abort'); - } else if (this._state.error) { - fire = new Event('error'); + const onError = (error: Error): void => { + if (error instanceof DOMException && error.name === DOMExceptionNameEnum.abortError) { + if (this.#aborted) { + return; + } + this.#readyState = XMLHttpRequestReadyStateEnum.unsent; + this.dispatchEvent(new Event('abort')); } else { - fire = new Event('load'); + this.#readyState = XMLHttpRequestReadyStateEnum.done; + this.dispatchEvent(new ErrorEvent('error', { error, message: error.message })); } - - this.dispatchEvent(fire); this.dispatchEvent(new Event('loadend')); - } - } - - /** - * Default request headers. - * - * @returns Default request headers. - */ - private _getDefaultRequestHeaders(): { [key: string]: string } { - const { location, navigator, document } = this._ownerDocument.defaultView; - - return { - accept: '*/*', - referer: location.href, - 'user-agent': navigator.userAgent, - cookie: document._cookie.getCookieString(location, false) + this.dispatchEvent(new Event('readystatechange')); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); }; - } - - /** - * Sends a synchronous request. - * - * @param options - * @param ssl - * @param data - */ - private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { - const scriptString = XMLHttpRequestSyncRequestScriptBuilder.getScript(options, ssl, data); - // Start the other Node Process, executing this string - const content = ChildProcess.execFileSync(process.argv[0], ['-e', scriptString], { - encoding: 'buffer', - maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB. + const fetch = new Fetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.#request.url, + init: this.#request }); - // If content length is 0, then there was an error - if (!content.length) { - throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); - } - - const { error, data: response } = JSON.parse(content.toString()); - - if (error) { - this._onError(error); + try { + this.#response = await fetch.send(); + } catch (error) { + onError(error); return; } - if (response) { - this._state.incommingMessage = { - statusCode: response.statusCode, - headers: response.headers - }; - this._state.status = response.statusCode; - this._state.statusText = response.statusMessage; - // Although it will immediately be set to loading, - // According to the spec, the state should be headersRecieved first. - this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - this._setState(XMLHttpRequestReadyStateEnum.loading); - this._state.response = this._decodeResponseText(Buffer.from(response.data, 'base64')); - this._state.responseText = this._state.response; - this._state.responseXML = null; - this._state.responseURL = new URL( - this._settings.url, - this._ownerDocument.defaultView.location - ).href; - // Set Cookies. - this._setCookies(this._state.incommingMessage.headers); - // Redirect. - if ( - this._state.incommingMessage.statusCode === 301 || - this._state.incommingMessage.statusCode === 302 || - this._state.incommingMessage.statusCode === 303 || - this._state.incommingMessage.statusCode === 307 - ) { - const redirectUrl = new URL( - this._state.incommingMessage.headers['location'], - this._ownerDocument.defaultView.location - ); - ssl = redirectUrl.protocol === 'https:'; - this._settings.url = redirectUrl.href; - // Recursive call. - this._sendSyncRequest( - Object.assign(options, { - host: redirectUrl.host, - path: redirectUrl.pathname + (redirectUrl.search ?? ''), - port: redirectUrl.port || (ssl ? 443 : 80), - method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, - headers: Object.assign(options.headers, { - referer: redirectUrl.origin, - host: redirectUrl.host - }) - }), - ssl, - data - ); - } - - this._setState(XMLHttpRequestReadyStateEnum.done); - } - } - - /** - * Sends an async request. - * - * @param options - * @param ssl - * @param data - */ - private _sendAsyncRequest( - options: HTTPS.RequestOptions, - ssl: boolean, - data?: string - ): Promise { - return new Promise((resolve) => { - // Starts async task in Happy DOM - this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( - this.abort.bind(this) - ); - - // Use the proper protocol - const sendRequest = ssl ? HTTPS.request : HTTP.request; - - // Request is being sent, set send flag - this._state.send = true; - - // As per spec, this is called here for historical reasons. - this.dispatchEvent(new Event('readystatechange')); - - // Create the request - this._state.asyncRequest = sendRequest( - options, - async (response: HTTP.IncomingMessage) => { - await this._onAsyncResponse(response, options, ssl, data); - - resolve(); - - // Ends async task in Happy DOM - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask( - this._state.asyncTaskID - ); + this.#readyState = XMLHttpRequestReadyStateEnum.headersRecieved; + + this.dispatchEvent(new Event('readystatechange')); + + const contentLength = this.#response.headers.get('Content-Length'); + const contentLengthNumber = + contentLength !== null && !isNaN(Number(contentLength)) ? Number(contentLength) : null; + let loaded = 0; + let data = Buffer.from([]); + + if (this.#response.body) { + let eventError: Error; + try { + for await (const chunk of this.#response.body) { + data = Buffer.concat([data, typeof chunk === 'string' ? Buffer.from(chunk) : chunk]); + loaded += chunk.length; + // We need to re-throw the error as we don't want it to be caught by the try/catch. + try { + this.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: contentLengthNumber !== null, + loaded, + total: contentLengthNumber !== null ? contentLengthNumber : 0 + }) + ); + } catch (error) { + eventError = error; + throw error; + } } - ); - this._state.asyncRequest.on('error', (error: Error) => { - this._onError(error); - resolve(); - - // Ends async task in Happy DOM - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); - }); - - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - this._state.asyncRequest.write(data); - } - - this._state.asyncRequest.end(); - - this.dispatchEvent(new Event('loadstart')); - }); - } - - /** - * Handles an async response. - * - * @param options Options. - * @param ssl SSL. - * @param data Data. - * @param response Response. - * @returns Promise. - */ - private _onAsyncResponse( - response: HTTP.IncomingMessage, - options: HTTPS.RequestOptions, - ssl: boolean, - data?: string - ): Promise { - return new Promise((resolve) => { - // Set response var to the response we got back - // This is so it remains accessable outside this scope - this._state.incommingMessage = response; - - // Set Cookies - this._setCookies(this._state.incommingMessage.headers); - - // Check for redirect - // @TODO Prevent looped redirects - if ( - this._state.incommingMessage.statusCode === 301 || - this._state.incommingMessage.statusCode === 302 || - this._state.incommingMessage.statusCode === 303 || - this._state.incommingMessage.statusCode === 307 - ) { - // TODO: redirect url protocol change. - // Change URL to the redirect location - this._settings.url = this._state.incommingMessage.headers.location; - // Parse the new URL. - const redirectUrl = new URL(this._settings.url, this._ownerDocument.defaultView.location); - this._settings.url = redirectUrl.href; - ssl = redirectUrl.protocol === 'https:'; - // Issue the new request - this._sendAsyncRequest( - { - ...options, - host: redirectUrl.hostname, - port: redirectUrl.port, - path: redirectUrl.pathname + (redirectUrl.search ?? ''), - method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, - headers: { ...options.headers, referer: redirectUrl.origin, host: redirectUrl.host } - }, - ssl, - data - ); - // @TODO Check if an XHR event needs to be fired here + } catch (error) { + if (error === eventError) { + throw error; + } + onError(error); return; } + } - this._state.status = this._state.incommingMessage.statusCode; - this._state.statusText = this._state.incommingMessage.statusMessage; - this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - - // Initialize response. - let tempResponse = Buffer.from(new Uint8Array(0)); - - this._state.incommingMessage.on('data', (chunk: Uint8Array) => { - // Make sure there's some data - if (chunk) { - tempResponse = Buffer.concat([tempResponse, Buffer.from(chunk)]); - } - // Don't emit state changes if the connection has been aborted. - if (this._state.send) { - this._setState(XMLHttpRequestReadyStateEnum.loading); - } - - const contentLength = Number(this._state.incommingMessage.headers['content-length']); - this.dispatchEvent( - new ProgressEvent('progress', { - lengthComputable: !isNaN(contentLength), - loaded: tempResponse.length, - total: isNaN(contentLength) ? 0 : contentLength - }) - ); - }); - - this._state.incommingMessage.on('end', () => { - if (this._state.send) { - // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks - // There can be a timing issue (the callback is called and a new call is made before the flag is reset). - this._state.send = false; - - // Set response according to responseType. - const { response, responseXML, responseText } = this._parseResponseData(tempResponse); - this._state.response = response; - this._state.responseXML = responseXML; - this._state.responseText = responseText; - this._state.responseURL = new URL( - this._settings.url, - this._ownerDocument.defaultView.location - ).href; - // Discard the 'end' event if the connection has been aborted - this._setState(XMLHttpRequestReadyStateEnum.done); - } + this.#responseBody = XMLHttpRequestResponseDataParser.parse({ + window: this.#window, + responseType: this.#responseType, + data, + contentType: + this.#response.headers.get('Content-Type') || this.#request.headers.get('Content-Type') + }); + this.#readyState = XMLHttpRequestReadyStateEnum.done; - resolve(); - }); + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); - this._state.incommingMessage.on('error', (error) => { - this._onError(error); - resolve(); - }); - }); + this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); } /** - * Sends a local file system async request. + * Sends the request to the server synchronously. * - * @param url URL. - * @returns Promise. + * @param body Optional data to send as request body. */ - private async _sendLocalAsyncRequest(url: UrlObject): Promise { - this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( - this.abort.bind(this) - ); - - let data: Buffer; - - try { - data = await FS.promises.readFile(decodeURI(url.pathname.slice(1))); - } catch (error) { - this._onError(error); - // Release async task. - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); - return; + #sendSync(body?: IRequestBody): void { + if (body) { + this.#request = new this.#window.Request(this.#request.url, { + method: this.#request.method, + headers: this.#request.headers, + signal: this.#abortController.signal, + credentials: this.#request.credentials, + body + }); } - const dataLength = data.length; - - // @TODO: set state headersRecieved first. - this._setState(XMLHttpRequestReadyStateEnum.loading); - this.dispatchEvent( - new ProgressEvent('progress', { - lengthComputable: true, - loaded: dataLength, - total: dataLength - }) - ); - - if (data) { - this._parseLocalRequestData(url, data); - } + this.#readyState = XMLHttpRequestReadyStateEnum.loading; - this._setState(XMLHttpRequestReadyStateEnum.done); - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); - } + const fetch = new SyncFetch({ + browserFrame: this.#browserFrame, + window: this.#window, + url: this.#request.url, + init: this.#request + }); - /** - * Sends a local file system synchronous request. - * - * @param url URL. - */ - private _sendLocalSyncRequest(url: UrlObject): void { - let data: Buffer; try { - data = FS.readFileSync(decodeURI(url.pathname.slice(1))); + this.#response = fetch.send(); } catch (error) { - this._onError(error); + this.#readyState = XMLHttpRequestReadyStateEnum.done; + this.dispatchEvent(new ErrorEvent('error', { error, message: error.message })); + this.dispatchEvent(new Event('loadend')); + this.dispatchEvent(new Event('readystatechange')); return; } - // @TODO: set state headersRecieved first. - this._setState(XMLHttpRequestReadyStateEnum.loading); - - if (data) { - this._parseLocalRequestData(url, data); - } - - this._setState(XMLHttpRequestReadyStateEnum.done); - } - - /** - * Parses local request data. - * - * @param url URL. - * @param data Data. - */ - private _parseLocalRequestData(url: UrlObject, data: Buffer): void { - // Manually set the response headers. - this._state.incommingMessage = { - statusCode: 200, - headers: { - 'content-length': String(data.length), - 'content-type': XMLHttpRequestURLUtility.getMimeTypeFromExt(url) - // @TODO: 'last-modified': - } - }; - - this._state.status = this._state.incommingMessage.statusCode; - this._state.statusText = 'OK'; - - const { response, responseXML, responseText } = this._parseResponseData(data); - this._state.response = response; - this._state.responseXML = responseXML; - this._state.responseText = responseText; - this._state.responseURL = new URL( - this._settings.url, - this._ownerDocument.defaultView.location - ).href; + this.#readyState = XMLHttpRequestReadyStateEnum.headersRecieved; - this._setState(XMLHttpRequestReadyStateEnum.done); - } - - /** - * Returns response based to the "responseType" property. - * - * @param data Data. - * @returns Parsed response. - */ - private _parseResponseData(data: Buffer): { - response: ArrayBuffer | Blob | IDocument | object | string; - responseText: string; - responseXML: IDocument; - } { - switch (this.responseType) { - case XMLHttpResponseTypeEnum.arraybuffer: - // See: https://github.com/jsdom/jsdom/blob/c3c421c364510e053478520500bccafd97f5fa39/lib/jsdom/living/helpers/binary-data.js - const newAB = new ArrayBuffer(data.length); - const view = new Uint8Array(newAB); - view.set(data); - return { - response: view, - responseText: null, - responseXML: null - }; - case XMLHttpResponseTypeEnum.blob: - try { - return { - response: new this._ownerDocument.defaultView.Blob([new Uint8Array(data)], { - type: this.getResponseHeader('content-type') || '' - }), - responseText: null, - responseXML: null - }; - } catch (e) { - return { response: null, responseText: null, responseXML: null }; - } - case XMLHttpResponseTypeEnum.document: - const window = this._ownerDocument.defaultView; - const happyDOMSettings = window.happyDOM.settings; - let response: IDocument; - - // Temporary disables unsecure features. - window.happyDOM.settings = { - ...happyDOMSettings, - enableFileSystemHttpRequests: false, - disableJavaScriptEvaluation: true, - disableCSSFileLoading: true, - disableJavaScriptFileLoading: true - }; - - const domParser = new window.DOMParser(); - - try { - response = domParser.parseFromString(this._decodeResponseText(data), 'text/xml'); - } catch (e) { - return { response: null, responseText: null, responseXML: null }; - } - - // Restores unsecure features. - window.happyDOM.settings = happyDOMSettings; - - return { response, responseText: null, responseXML: response }; - case XMLHttpResponseTypeEnum.json: - try { - return { - response: JSON.parse(this._decodeResponseText(data)), - responseText: null, - responseXML: null - }; - } catch (e) { - return { response: null, responseText: null, responseXML: null }; - } - case XMLHttpResponseTypeEnum.text: - case '': - default: - const responseText = this._decodeResponseText(data); - return { - response: responseText, - responseText: responseText, - responseXML: null - }; - } - } - - /** - * Set Cookies from response headers. - * - * @param headers Headers. - */ - private _setCookies( - headers: { [name: string]: string | string[] } | HTTP.IncomingHttpHeaders - ): void { - const originURL = new URL(this._settings.url, this._ownerDocument.defaultView.location); - for (const header of ['set-cookie', 'set-cookie2']) { - if (Array.isArray(headers[header])) { - for (const cookie of headers[header]) { - this._ownerDocument.defaultView.document._cookie.addCookieString(originURL, cookie); - } - } else if (headers[header]) { - this._ownerDocument.defaultView.document._cookie.addCookieString( - originURL, - headers[header] - ); - } - } - } - - /** - * Called when an error is encountered to deal with it. - * - * @param error Error. - */ - private _onError(error: Error | string): void { - this._state.status = 0; - this._state.statusText = error.toString(); - this._state.responseText = error instanceof Error ? error.stack : ''; - this._state.error = true; - this._setState(XMLHttpRequestReadyStateEnum.done); - - const errorObject = error instanceof Error ? error : new Error(error); - const event = new ErrorEvent('error', { - message: errorObject.message, - error: errorObject + this.#responseBody = XMLHttpRequestResponseDataParser.parse({ + window: this.#window, + responseType: this.#responseType, + data: this.#response.body, + contentType: + this.#response.headers.get('Content-Type') || this.#request.headers.get('Content-Type') }); - this._ownerDocument.defaultView.console.error(errorObject); - this.dispatchEvent(event); - this._ownerDocument.defaultView.dispatchEvent(event); - } + this.#readyState = XMLHttpRequestReadyStateEnum.done; - /** - * Decodes response text. - * - * @param data Data. - * @returns Decoded text. - **/ - private _decodeResponseText(data: Buffer): string { - const contextTypeEncodingRegexp = new RegExp(CONTENT_TYPE_ENCODING_REGEXP, 'gi'); - // Compatibility with file:// protocol or unpredictable http request. - const contentType = - this.getResponseHeader('content-type') ?? this._state.requestHeaders['content-type']; - const charset = contextTypeEncodingRegexp.exec(contentType); - // Default encoding: utf-8. - return IconvLite.decode(data, charset ? charset[1] : 'utf-8'); + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); } } diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseDataParser.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseDataParser.ts new file mode 100644 index 000000000..0f780049d --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseDataParser.ts @@ -0,0 +1,75 @@ +import XMLHttpResponseTypeEnum from '../XMLHttpResponseTypeEnum.js'; +import XMLHttpRequestResponseTextDecoder from './XMLHttpRequestResponseTextDecoder.js'; +import IBrowserWindow from '../../window/IBrowserWindow.js'; +import Blob from '../../file/Blob.js'; +import IDocument from '../../nodes/document/IDocument.js'; + +/** + * + */ +export default class XMLHttpRequestResponseDataParser { + /** + * Parses response. + * + * @param options Options. + * @param options.window Window. + * @param [options.responseType] Response type. + * @param [options.data] Data. + * @param [options.contentType] Content type. + * @returns Parsed response. + **/ + public static parse(options: { + window: IBrowserWindow; + responseType: string; + data?: Buffer; + contentType?: string; + }): ArrayBuffer | Blob | IDocument | object | string | null { + if (!options.data) { + return ''; + } + switch (options.responseType) { + case XMLHttpResponseTypeEnum.arraybuffer: + // See: https://github.com/jsdom/jsdom/blob/c3c421c364510e053478520500bccafd97f5fa39/lib/jsdom/living/helpers/binary-data.js + const newAB = new ArrayBuffer(options.data.length); + const view = new Uint8Array(newAB); + view.set(options.data); + return view; + case XMLHttpResponseTypeEnum.blob: + try { + return new options.window.Blob([new Uint8Array(options.data)], { + type: options.contentType || '' + }); + } catch (e) { + // Ignore error. + } + return null; + case XMLHttpResponseTypeEnum.document: + const window = options.window; + const domParser = new window.DOMParser(); + + try { + return domParser.parseFromString( + XMLHttpRequestResponseTextDecoder.decode(options.data, options.contentType), + 'text/xml' + ); + } catch (e) { + // Ignore error. + } + + return null; + case XMLHttpResponseTypeEnum.json: + try { + return JSON.parse( + XMLHttpRequestResponseTextDecoder.decode(options.data, options.contentType) + ); + } catch (e) { + // Ignore error. + } + return null; + case XMLHttpResponseTypeEnum.text: + case '': + default: + return XMLHttpRequestResponseTextDecoder.decode(options.data, options.contentType); + } + } +} diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseTextDecoder.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseTextDecoder.ts new file mode 100644 index 000000000..3fec30c2d --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestResponseTextDecoder.ts @@ -0,0 +1,26 @@ +import IconvLite from 'iconv-lite'; + +const CONTENT_TYPE_ENCODING_REGEXP = /charset=([^;]*)/i; + +/** + * + */ +export default class XMLHttpRequestResponseTextDecoder { + /** + * Decodes response text. + * + * @param data Data. + * @param [contentType] Content type. + * @returns Decoded text. + **/ + public static decode(data: Buffer, contentType?: string): string { + if (!contentType) { + return IconvLite.decode(data, 'utf-8'); + } + + const contextTypeEncodingRegexp = new RegExp(CONTENT_TYPE_ENCODING_REGEXP, 'gi'); + const charset = contextTypeEncodingRegexp.exec(contentType); + + return IconvLite.decode(data, charset ? charset[1] : 'utf-8'); + } +} diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index 2d6fe68c2..d1e4cca56 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -1,4 +1,5 @@ import IDocument from '../nodes/document/IDocument.js'; +import * as PropertySymbol from '../PropertySymbol.js'; import VoidElements from '../config/VoidElements.js'; import UnnestableElements from '../config/UnnestableElements.js'; import NamespaceURI from '../config/NamespaceURI.js'; @@ -114,7 +115,7 @@ export default class XMLParser { if (unnestableTagNameIndex !== -1) { unnestableTagNames.splice(unnestableTagNameIndex, 1); while (currentNode !== root) { - if ((currentNode).tagName === tagName) { + if ((currentNode)[PropertySymbol.tagName] === tagName) { stack.pop(); currentNode = stack[stack.length - 1] || root; break; @@ -129,7 +130,7 @@ export default class XMLParser { const namespaceURI = tagName === 'SVG' ? NamespaceURI.svg - : (currentNode).namespaceURI || NamespaceURI.html; + : (currentNode)[PropertySymbol.namespaceURI] || NamespaceURI.html; const newElement = document.createElementNS(namespaceURI, tagName); currentNode.appendChild(newElement); @@ -140,11 +141,11 @@ export default class XMLParser { } else if (match[2]) { // End tag. - if (match[2].toUpperCase() === (currentNode).tagName) { + if (match[2].toUpperCase() === (currentNode)[PropertySymbol.tagName]) { // Some elements are not allowed to be nested (e.g. "" is not allowed.). // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). const unnestableTagNameIndex = unnestableTagNames.indexOf( - (currentNode).tagName + (currentNode)[PropertySymbol.tagName] ); if (unnestableTagNameIndex !== -1) { unnestableTagNames.splice(unnestableTagNameIndex, 1); @@ -156,7 +157,8 @@ export default class XMLParser { } else if ( match[3] || match[4] || - (match[6] && (currentNode).namespaceURI === NamespaceURI.html) + (match[6] && + (currentNode)[PropertySymbol.namespaceURI] === NamespaceURI.html) ) { // Comment. @@ -226,7 +228,9 @@ export default class XMLParser { attributeMatch[2] || attributeMatch[4] || attributeMatch[7] || ''; const value = rawValue ? Entities.decodeHTMLAttribute(rawValue) : ''; const namespaceURI = - (currentNode).tagName === 'SVG' && name === 'xmlns' ? value : null; + (currentNode)[PropertySymbol.tagName] === 'SVG' && name === 'xmlns' + ? value + : null; (currentNode).setAttributeNS(namespaceURI, name, value); @@ -252,24 +256,27 @@ export default class XMLParser { // Self closing tags are not allowed in the HTML namespace, but the parser should still allow it for void elements. // Self closing tags is supported in the SVG namespace. if ( - VoidElements[(currentNode).tagName] || - (match[7] && (currentNode).namespaceURI === NamespaceURI.svg) + VoidElements[(currentNode)[PropertySymbol.tagName]] || + (match[7] && + (currentNode)[PropertySymbol.namespaceURI] === NamespaceURI.svg) ) { stack.pop(); currentNode = stack[stack.length - 1] || root; readState = MarkupReadStateEnum.startOrEndTag; } else { // Plain text elements such as + `); + + await new Promise((resolve) => setTimeout(resolve, 2)); + + expect(errorEvent instanceof window.ErrorEvent).toBe(true); + expect(errorEvent.error.message).toBe('Test error'); + expect(errorEvent.message).toBe('Test error'); + + await browser.close(); + }); + + it('Observes uncaught exceptions.', async () => { + const browser = new Browser({ + settings: { errorCapture: BrowserErrorCaptureEnum.processLevel } + }); + const page = browser.newPage(); + const window = page.mainFrame.window; + const document = window.document; + let errorEvent = null; + + window.addEventListener('error', (event) => (errorEvent = event)); + window['customSetTimeout'] = setTimeout.bind(global); + + document.write(` + + `); + + await new Promise((resolve) => setTimeout(resolve, 2)); + + const consoleOutput = page.virtualConsolePrinter.readAsString(); + + expect(consoleOutput.startsWith('Error: Test error\n at Timeout.eval')).toBe(true); + expect(errorEvent instanceof window.ErrorEvent).toBe(true); + expect(errorEvent.error.message).toBe('Test error'); + expect(errorEvent.message).toBe('Test error'); + + await browser.close(); + }); + }); + + describe('disconnect()', () => { + it('Disconnects the observer.', async () => { + expect(process.listenerCount('uncaughtException')).toBe(0); + expect(process.listenerCount('unhandledRejection')).toBe(0); + }); + }); +}); diff --git a/packages/integration-test/test/tests/Fetch.test.js b/packages/integration-test/test/tests/Fetch.test.js index 49808cc89..1ed29da1e 100644 --- a/packages/integration-test/test/tests/Fetch.test.js +++ b/packages/integration-test/test/tests/Fetch.test.js @@ -4,7 +4,9 @@ import Express from 'express'; describe('Fetch', () => { it('Can perform a real fetch()', async () => { - const window = new Window(); + const window = new Window({ + url: 'http://localhost:3000' + }); const express = Express(); express.get('/get/json', (_req, res) => { @@ -31,7 +33,9 @@ describe('Fetch', () => { }); it('Can perform a real FormData post request using fetch()', async () => { - const window = new Window(); + const window = new Window({ + url: 'http://localhost:3000' + }); const express = Express(); express.post('/post/formdata', (req, res) => { diff --git a/packages/integration-test/test/tests/XMLHttpRequest.test.js b/packages/integration-test/test/tests/XMLHttpRequest.test.js index c72b4898e..1a895e581 100644 --- a/packages/integration-test/test/tests/XMLHttpRequest.test.js +++ b/packages/integration-test/test/tests/XMLHttpRequest.test.js @@ -5,7 +5,9 @@ import Express from 'express'; describe('XMLHttpRequest', () => { it('Can perform a real asynchronous XMLHttpRequest request', async () => { await new Promise((resolve) => { - const window = new Window(); + const window = new Window({ + url: 'http://localhost:3000/' + }); const express = Express(); express.get('/get/json', (_req, res) => { @@ -35,7 +37,9 @@ describe('XMLHttpRequest', () => { }); it('Can perform a real synchronous XMLHttpRequest request to Github.com', () => { - const window = new Window(); + const window = new Window({ + url: 'https://raw.githubusercontent.com/' + }); const request = new window.XMLHttpRequest(); request.open( diff --git a/packages/integration-test/test/utilities/TestFunctions.js b/packages/integration-test/test/utilities/TestFunctions.js index 945bf36fc..4cf33c519 100644 --- a/packages/integration-test/test/utilities/TestFunctions.js +++ b/packages/integration-test/test/utilities/TestFunctions.js @@ -1,3 +1,5 @@ +import Chalk from 'chalk'; + /* eslint-disable no-console*/ const tests = []; @@ -22,35 +24,60 @@ export function run(description, callback) { }); clearTimeout(timeout); timeout = setTimeout(async () => { + let hasError = false; for (const test of tests) { - console.log(test.description); - const result = test.callback(); + process.stdout.write(Chalk.blue(test.description)); + let result = null; + const startTime = performance.now(); + try { + result = test.callback(); + } catch (error) { + console.error(Chalk.red(error)); + hasError = true; + } if (result instanceof Promise) { - const testTimeout = setTimeout(() => { - console.error('Test timed out.'); - process.exit(1); - }, 2000); - try { - await result; - clearTimeout(testTimeout); - } catch (error) { - console.error(error); - process.exit(1); - } + await new Promise((resolve) => { + let hasTimedout = false; + const testTimeout = setTimeout(() => { + console.error(Chalk.red('Test timed out.')); + hasError = true; + hasTimedout = true; + resolve(); + }, 100000); + result + .then(() => { + if (!hasTimedout) { + clearTimeout(testTimeout); + resolve(); + } + }) + .catch((error) => { + console.error(Chalk.red(error)); + hasError = true; + }); + }); } + process.stdout.write( + Chalk.blue(` (${Math.round((performance.now() - startTime) * 100) / 100}ms)\n`) + ); + } + + if (hasError) { + console.log(''); + console.error(Chalk.red('❌ Some tests failed.')); + console.log(''); + process.exit(1); } console.log(''); - console.log('All tests passed.'); + console.log(Chalk.green('All tests passed.')); + console.log(''); }, 100); } export function expect(value) { return { toBe: (expected) => { - if (typeof value !== typeof expected) { - throw new Error(`Expected type "${typeof value}" to be "${typeof expected}".`); - } if (value !== expected) { throw new Error(`Expected value "${value}" to be "${expected}".`); } diff --git a/packages/jest-environment/.eslintrc.cjs b/packages/jest-environment/.eslintrc.cjs deleted file mode 100644 index f4088a566..000000000 --- a/packages/jest-environment/.eslintrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('happy-dom/.eslintrc.cjs'); diff --git a/packages/jest-environment/package.json b/packages/jest-environment/package.json index 313613b34..fe31d09ae 100644 --- a/packages/jest-environment/package.json +++ b/packages/jest-environment/package.json @@ -27,8 +27,6 @@ "scripts": { "compile": "node ./bin/build-lit-ts-config && tsc --project tmp/tsconfig.lit-reactive-element.json && tsc --project tmp/tsconfig.lit-element.json && tsc --project tmp/tsconfig.lit-html.json && tsc --project tmp/tsconfig.lit.json && node ./bin/transform-lit-require.js && node ./bin/copy-tsdef-for-lit.js && node ./bin/copy-package-json-for-lit.js && tsc", "watch": "npm run compile && tsc -w --preserveWatchOutput", - "lint": "eslint --ignore-path .gitignore --max-warnings 0 .", - "lint:fix": "eslint --ignore-path .gitignore --max-warnings 0 --fix .", "test": "jest", "test:watch": "jest --watch", "test:debug": "node --inspect-brk ../../node_modules/.bin/jest --runInBand --testTimeout 60000" @@ -65,15 +63,6 @@ "@testing-library/user-event": "^14.5.1", "@types/react-dom": "^18.2.0", "@types/jest": "29.5.2", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jest": "^26.1.2", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-turbo": "^0.0.7", "prettier": "^2.6.0", "typescript": "^5.0.4", "jest": "^29.4.0", diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 4f7b8783a..e142ee549 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -70,9 +70,9 @@ export default class HappyDOMEnvironment implements JestEnvironment { } if (projectConfig.testEnvironmentOptions['url']) { - this.window.happyDOM.setURL(String(projectConfig.testEnvironmentOptions['url'])); + this.window.happyDOM?.setURL(String(projectConfig.testEnvironmentOptions['url'])); } else { - this.window.happyDOM.setURL('http://localhost/'); + this.window.happyDOM?.setURL('http://localhost/'); } this.fakeTimers = new LegacyFakeTimers({ @@ -90,7 +90,7 @@ export default class HappyDOMEnvironment implements JestEnvironment { global: (this.window) }); - // Jest is using the setTimeout function from Happy DOM internally for detecting when a test times out, but this causes Window.happyDOM.whenAsyncComplete() and Window.happyDOM.cancelAsync() to not work as expected. + // Jest is using the setTimeout function from Happy DOM internally for detecting when a test times out, but this causes window.happyDOM?.waitUntilComplete() and window.happyDOM?.abort() to not work as expected. // Hopefully Jest can fix this in the future as this fix is not very pretty. const happyDOMSetTimeout = this.global.setTimeout; (<(...args: unknown[]) => number>this.global.setTimeout) = (...args: unknown[]): number => { @@ -117,7 +117,8 @@ export default class HappyDOMEnvironment implements JestEnvironment { this.fakeTimers.dispose(); this.fakeTimersModern.dispose(); - ((this.global)).happyDOM.cancelAsync(); + await ((this.global)).happyDOM.abort(); + ((this.global)).close(); this.global = null; this.moduleMocker = null; diff --git a/packages/jest-environment/test/javascript/JavaScript.test.ts b/packages/jest-environment/test/javascript/JavaScript.test.ts index bea3351e8..311b56ecf 100644 --- a/packages/jest-environment/test/javascript/JavaScript.test.ts +++ b/packages/jest-environment/test/javascript/JavaScript.test.ts @@ -14,6 +14,8 @@ describe('JavaScript', () => { }); it('Can perform a real fetch()', async () => { + location.href = 'http://localhost:3000/'; + const express = Express(); express.get('/get/json', (_req, res) => { @@ -22,7 +24,6 @@ describe('JavaScript', () => { }); const server = express.listen(3000); - const response = await fetch('http://localhost:3000/get/json'); server.close(); @@ -40,6 +41,8 @@ describe('JavaScript', () => { }); it('Can perform a real FormData post request using fetch()', async () => { + location.href = 'http://localhost:3000/'; + const express = Express(); express.post('/post/formdata', (req, res) => { @@ -54,7 +57,6 @@ describe('JavaScript', () => { }); const server = express.listen(3000); - const requestFormData = new FormData(); requestFormData.append('key1', 'value1'); @@ -87,6 +89,8 @@ describe('JavaScript', () => { }); it('Can perform a real asynchronous XMLHttpRequest request', (done) => { + location.href = 'http://localhost:3000/'; + const express = Express(); express.get('/get/json', (_req, res) => { @@ -95,7 +99,6 @@ describe('JavaScript', () => { }); const server = express.listen(3000); - const request = new XMLHttpRequest(); request.open('GET', 'http://localhost:3000/get/json', true); @@ -116,6 +119,8 @@ describe('JavaScript', () => { }); it('Can perform a real synchronous XMLHttpRequest request to Github.com', () => { + location.href = 'https://raw.githubusercontent.com/'; + const request = new XMLHttpRequest(); request.open( diff --git a/packages/jest-environment/test/lit-element/LitElement.test.ts b/packages/jest-environment/test/lit-element/LitElement.test.ts index b48ffe66c..b44abd061 100644 --- a/packages/jest-environment/test/lit-element/LitElement.test.ts +++ b/packages/jest-environment/test/lit-element/LitElement.test.ts @@ -21,6 +21,7 @@ describe('LitElementComponent', () => { ); expect(shadowRoot.querySelector('span').innerText).toBe(PROP1); + expect(window.getComputedStyle(shadowRoot.querySelector('span')).color).toBe('green'); expect( shadowRoot.innerHTML .replace(/[\s]/gm, '') @@ -29,11 +30,6 @@ describe('LitElementComponent', () => { ` Some text ${PROP1}! - `.replace(/[\s]/gm, '') ); }); diff --git a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx index 04e2aeeba..d179929f1 100644 --- a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx +++ b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx @@ -10,6 +10,7 @@ describe('TestingLibrary', () => { render( onChange(event.target.value)} />); await user.type(screen.getByRole('textbox'), 'hello'); + expect(onChange).toHaveBeenCalledWith('hello'); }); @@ -29,6 +30,7 @@ describe('TestingLibrary', () => { ); await user.click(screen.getByRole('button')); + expect(handleSubmit).toHaveBeenCalledTimes(1); expect(clickHandler).toHaveBeenCalledTimes(1); }); @@ -39,7 +41,9 @@ describe('TestingLibrary', () => { render(); - await user.click(screen.getByRole('checkbox')); + const checkbox = screen.getByRole('checkbox'); + + await user.click(checkbox); expect(changeHandler).toHaveBeenCalledTimes(1); }); diff --git a/packages/uncaught-exception-observer/.eslintrc.cjs b/packages/uncaught-exception-observer/.eslintrc.cjs deleted file mode 100644 index f4088a566..000000000 --- a/packages/uncaught-exception-observer/.eslintrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('happy-dom/.eslintrc.cjs'); diff --git a/packages/uncaught-exception-observer/.prettierrc.cjs b/packages/uncaught-exception-observer/.prettierrc.cjs deleted file mode 100644 index 7b9cd9691..000000000 --- a/packages/uncaught-exception-observer/.prettierrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../happy-dom/.prettierrc.cjs'); diff --git a/packages/uncaught-exception-observer/README.md b/packages/uncaught-exception-observer/README.md index 4ebb51d48..47760d78c 100644 --- a/packages/uncaught-exception-observer/README.md +++ b/packages/uncaught-exception-observer/README.md @@ -1,3 +1,5 @@ +:warning: **This package is deprecated. Happy DOM now supports built in by setting "errorCapture" to "processLevel".** :warning: + ![Happy DOM Logo](https://github.com/capricorn86/happy-dom/raw/master/docs/happy-dom-logo.jpg) # About diff --git a/packages/uncaught-exception-observer/package.json b/packages/uncaught-exception-observer/package.json index b52a99e00..2876cb785 100644 --- a/packages/uncaught-exception-observer/package.json +++ b/packages/uncaught-exception-observer/package.json @@ -6,91 +6,11 @@ "repository": "https://github.com/capricorn86/happy-dom", "author": "David Ortner", "description": "A utility for observing uncaught exceptions thrown in Happy DOM and dispatch them as events on the Happy DOM window.", - "main": "lib/index.js", - "type": "module", - "exports": { - ".": { - "import": "./lib/index.js", - "require": "./cjs/index.cjs", - "default": "./lib/index.js" - }, - "./lib/*.js": { - "import": "./lib/*.js", - "require": "./cjs/*.cjs", - "default": "./lib/*.js" - }, - "./lib/*.ts": { - "import": "./lib/*.ts", - "require": "./cjs/*.ts", - "default": "./lib/*.ts" - }, - "./lib/*.map": { - "import": "./lib/*.map", - "require": "./cjs/*.map", - "default": "./lib/*.map" - }, - "./cjs/*.cjs": { - "import": "./cjs/*.cjs", - "require": "./cjs/*.cjs", - "default": "./cjs/*.cjs" - }, - "./cjs/*.ts": { - "import": "./cjs/*.ts", - "require": "./cjs/*.ts", - "default": "./cjs/*.ts" - }, - "./cjs/*.map": { - "import": "./cjs/*.map", - "require": "./cjs/*.map", - "default": "./cjs/*.map" - }, - "./src/*.ts": "./src/*.ts", - "./package.json": "./package.json", - "./.eslintrc": "./.eslintrc.js" - }, - "keywords": [ - "jsdom", - "happy", - "dom", - "webcomponents", - "web", - "component", - "custom", - "elements", - "uncaught", - "error", - "exception", - "observer" - ], + "keywords": [], "publishConfig": { "access": "public" }, - "scripts": { - "compile": "tsc && tsc --moduleResolution Node --module CommonJS --outDir cjs && npm run change-cjs-file-extension", - "change-cjs-file-extension": "node ../happy-dom/bin/change-file-extension.cjs --dir=./cjs --fromExt=.js --toExt=.cjs", - "watch": "npm run compile && tsc -w --preserveWatchOutput", - "lint": "eslint --ignore-path .gitignore --max-warnings 0 .", - "lint:fix": "eslint --ignore-path .gitignore --max-warnings 0 --fix .", - "test": "tsc --project ./test && node ./tmp/UncaughtExceptionObserver.test.js", - "test:debug": "tsc --project ./test && node --inspect-brk ./tmp/UncaughtExceptionObserver.test.js" - }, - "peerDependencies": { - "happy-dom": ">= 2.25.2" - }, - "devDependencies": { - "@typescript-eslint/eslint-plugin": "^5.16.0", - "@typescript-eslint/parser": "^5.16.0", - "@types/node": "^16.11.7", - "eslint": "^8.11.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^38.0.6", - "eslint-plugin-json": "^3.1.0", - "eslint-plugin-turbo": "^0.0.7", - "prettier": "^2.6.0", - "typescript": "^5.0.4", - "happy-dom": "^0.0.0" - } + "scripts": {}, + "peerDependencies": {}, + "devDependencies": {} } diff --git a/packages/uncaught-exception-observer/src/index.ts b/packages/uncaught-exception-observer/src/index.ts deleted file mode 100644 index 1f39e1192..000000000 --- a/packages/uncaught-exception-observer/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import UncaughtExceptionObserver from './UncaughtExceptionObserver.js'; - -export { UncaughtExceptionObserver }; diff --git a/packages/uncaught-exception-observer/test/UncaughtExceptionObserver.test.ts b/packages/uncaught-exception-observer/test/UncaughtExceptionObserver.test.ts deleted file mode 100644 index 8d5feaf2f..000000000 --- a/packages/uncaught-exception-observer/test/UncaughtExceptionObserver.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Window, ErrorEvent, IResponse } from 'happy-dom'; -import UncaughtExceptionObserver from '../lib/UncaughtExceptionObserver.js'; - -async function itObservesUnhandledFetchRejections(): Promise { - const window = new Window(); - const document = window.document; - const observer = new UncaughtExceptionObserver(); - let errorEvent: ErrorEvent | null = null; - - observer.observe(window); - - window.addEventListener('error', (event) => (errorEvent = event)); - - window.fetch = () => { - return new Promise((resolve) => setTimeout(() => resolve({}), 0)); - }; - - document.write(` - - `); - - await new Promise((resolve) => setTimeout(resolve, 2)); - - observer.disconnect(); - - if (!(errorEvent instanceof window.ErrorEvent)) { - throw new Error('Error event not dispatched.'); - } - - if (errorEvent.error.message !== 'Test error') { - throw new Error('Error message not correct.'); - } - - if (errorEvent.message !== 'Test error') { - throw new Error('Error message not correct.'); - } -} - -async function itObservesUnhandledJavaScriptFetchRejections(): Promise { - const window = new Window(); - const document = window.document; - const observer = new UncaughtExceptionObserver(); - let errorEvent: ErrorEvent | null = null; - - window.happyDOM.settings.disableErrorCapturing = true; - - observer.observe(window); - - window.addEventListener('error', (event) => (errorEvent = event)); - - document.write(` - - `); - - for (let i = 0; i < 10; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - if (errorEvent) { - break; - } - } - - observer.disconnect(); - - if (!(errorEvent instanceof window.ErrorEvent)) { - throw new Error('Error event not dispatched.'); - } - - if (!errorEvent.error.message.startsWith('Fetch to "https://localhost:3000/404.js" failed.')) { - throw new Error('Error message not correct.'); - } - - if (!errorEvent.message.startsWith('Fetch to "https://localhost:3000/404.js" failed.')) { - throw new Error('Error message not correct.'); - } -} - -async function itObservesUncaughtExceptions(): Promise { - const window = new Window(); - const document = window.document; - const observer = new UncaughtExceptionObserver(); - let errorEvent: ErrorEvent | null = null; - - observer.observe(window); - - window.addEventListener('error', (event) => (errorEvent = event)); - - window['customSetTimeout'] = setTimeout.bind(globalThis); - - document.write(` - - `); - - await new Promise((resolve) => setTimeout(resolve, 2)); - - observer.disconnect(); - - const consoleOutput = window.happyDOM.virtualConsolePrinter.readAsString(); - - if (consoleOutput.startsWith('Error: Test error\nat Timeout.eval')) { - throw new Error(`Console output not correct.`); - } - - if (!(errorEvent instanceof window.ErrorEvent)) { - throw new Error('Error event not dispatched.'); - } - - if (errorEvent.error.message !== 'Test error') { - throw new Error('Error message not correct.'); - } - - if (errorEvent.message !== 'Test error') { - throw new Error('Error message not correct.'); - } -} - -async function main(): Promise { - try { - await itObservesUnhandledFetchRejections(); - await itObservesUnhandledJavaScriptFetchRejections(); - await itObservesUncaughtExceptions(); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - process.exit(1); - } -} - -main(); diff --git a/packages/uncaught-exception-observer/test/tsconfig.json b/packages/uncaught-exception-observer/test/tsconfig.json deleted file mode 100644 index a42397ae1..000000000 --- a/packages/uncaught-exception-observer/test/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../tmp", - "rootDir": "../test" - }, - "include": [ - "@types/node", - ".", - "../lib" - ] -} \ No newline at end of file diff --git a/packages/uncaught-exception-observer/tsconfig.json b/packages/uncaught-exception-observer/tsconfig.json deleted file mode 100644 index 25f063331..000000000 --- a/packages/uncaught-exception-observer/tsconfig.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "compilerOptions": { - "outDir": "lib", - "rootDir": "src", - "target": "ES2020", - "declaration": true, - "declarationMap": true, - "module": "Node16", - "moduleResolution": "Node16", - "esModuleInterop": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "removeComments": false, - "preserveConstEnums": true, - "sourceMap": true, - "skipLibCheck": true, - "baseUrl": ".", - "composite": false, - "incremental": false, - "lib": [ - "es2020" - ], - "types": [ - "node" - ] - }, - "include": [ - "@types/node", - "src" - ], - "exclude": [ - "@types/dom" - ] -} diff --git a/packages/uncaught-exception-observer/vitest.config.ts b/packages/uncaught-exception-observer/vitest.config.ts deleted file mode 100644 index 9ace40c13..000000000 --- a/packages/uncaught-exception-observer/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - include: ['./test/**/*.test.ts'] - } -}); diff --git a/turbo.json b/turbo.json index a085097f4..5c5dd920b 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "pipeline": { "compile": { "dependsOn": ["^compile"], - "inputs": ["src/**", "src/tsconfig.json", "src/package.json"], + "inputs": ["src/**", "tsconfig.json", "package.json"], "outputs": ["lib/**", "cjs/**", "tmp/**"] }, "global-registrator#compile": { @@ -19,28 +19,24 @@ "dependsOn": ["happy-dom#compile"] }, "happy-dom#test": { + "dependsOn": ["^compile"], + "inputs": ["vitest.config.ts", "package.json"], "outputs": ["node_modules/vitest/**"] }, "global-registrator#test": { - "dependsOn": ["happy-dom#compile", "global-registrator#compile"], + "dependsOn": ["happy-dom#compile", "^compile"], "outputs": ["tmp/**"] }, "jest-environment#test": { - "dependsOn": ["happy-dom#compile", "jest-environment#compile"] + "dependsOn": ["happy-dom#compile", "^compile"] }, "integration-test#test": { - "dependsOn": ["happy-dom#compile", "integration-test#compile"] + "dependsOn": ["happy-dom#compile", "^compile"] }, "uncaught-exception-observer#test": { "dependsOn": ["happy-dom#compile", "uncaught-exception-observer#compile"], "outputs": ["tmp/**"] }, - "lint": { - "outputs": [] - }, - "lint:fix": { - "outputs": [] - }, "test": { "inputs": ["test/**"] }