diff --git a/.editorconfig b/.editorconfig index 44ffe996f1..28ea59ea5c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ root = true [*] +max_line_length = 100 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true @@ -21,3 +22,4 @@ insert_final_newline = false [*.{diff,md}] trim_trailing_whitespace = false +max_line_length = 80 diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a6cde334c0..152a06e108 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,32 +19,32 @@ module.exports = { '**/fixtures', '!**/.eslintrc.cjs', ], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - project: [], - }, - settings: { - 'import/parsers': { - '@typescript-eslint/parser': ['.js', '.cjs', '.mjs', '.mts', '.ts', '.d.ts'], - }, - 'import/resolver': { - typescript: {}, - }, - node: { - allowModules: ['@glimmer/debug', '@glimmer/local-debug-flags'], - tryExtensions: ['.js', '.ts', '.d.ts', '.json'], - }, - }, - plugins: [ - '@typescript-eslint', - 'prettier', - 'qunit', - 'simple-import-sort', - 'unused-imports', - 'prettier', - 'n', - ], + // parser: '@typescript-eslint/parser', + // parserOptions: { + // ecmaVersion: 'latest', + // project: [], + // }, + // settings: { + // 'import/parsers': { + // '@typescript-eslint/parser': ['.js', '.cjs', '.mjs', '.mts', '.ts', '.d.ts'], + // }, + // 'import/resolver': { + // typescript: {}, + // }, + // node: { + // allowModules: ['@glimmer/debug', '@glimmer/local-debug-flags'], + // tryExtensions: ['.js', '.ts', '.d.ts', '.json'], + // }, + // }, + // plugins: [ + // '@typescript-eslint', + // 'prettier', + // 'qunit', + // 'simple-import-sort', + // 'unused-imports', + // 'prettier', + // 'n', + // ], rules: {}, overrides: [ diff --git a/.prettierrc.json b/.prettierrc.json index d440171380..5626bc6e0d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,6 @@ { "singleQuote": true, "trailingComma": "es5", - "printWidth": 100 + "printWidth": 100, + "plugins": [] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..8c164afb17 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["foxundermoon.shell-format"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index fc8e324a56..b56a38d2db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,11 +9,14 @@ "[javascript][typescript]": { "editor.formatOnSave": false, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": ["source.formatDocument", "source.fixAll.eslint"] + "editor.codeActionsOnSave": { + "source.unusedImports": "explicit", + "source.fixAll": "explicit", + "source.formatDocument": "always" + } }, "editor.formatOnSave": true, "eslint.enable": true, - "eslint.packageManager": "npm", "eslint.codeAction.showDocumentation": { "enable": true }, @@ -25,7 +28,7 @@ "mode": "auto" } ], - "explorer.excludeGitIgnore": false, + "explorer.excludeGitIgnore": true, "files.exclude": { "**/.DS_Store": true, "**/.git": true, @@ -41,15 +44,190 @@ "javascript.updateImportsOnFileMove.enabled": "always", "typescript.updateImportsOnFileMove.enabled": "always", - "typescript.preferences.importModuleSpecifier": "project-relative", "javascript.preferences.importModuleSpecifier": "project-relative", + "typescript.preferences.importModuleSpecifier": "project-relative", - "typescript.preferences.importModuleSpecifierEnding": "minimal", + "typescript.preferences.importModuleSpecifierEnding": "index", "typescript.preferences.useAliasesForRenames": false, - "typescript.tsc.autoDetect": "on", "typescript.tsdk": "node_modules/typescript/lib", - "typescript.tsserver.experimental.enableProjectDiagnostics": false, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, "typescript.workspaceSymbols.scope": "currentProject", - "eslint.problems.shortenToSingleLine": true + "eslint.problems.shortenToSingleLine": true, + "inline-bookmarks.expert.custom.words.mapping": { + "warn": ["@premerge(\\s|$)"], + "active": ["@active(\\s|$)"], + "fixme": ["@fixme(\\s|$)"] + }, + "inline-bookmarks.expert.custom.styles": { + "active": { + // teal + "gutterIconColor": "#009999", + "overviewRulerColor": "rgba(0, 150, 150, 0.7)", + "light": { + "fontWeight": "bold", + "color": "#009999" + }, + "dark": { + "fontWeight": "bold", + "color": "#00ffff" + } + }, + "fixme": { + // yellow + "gutterIconColor": "#990000", + "overviewRulerColor": "rgba(150, 0, 0, 0.7)", + "light": { + "fontWeight": "bold", + "color": "#990000" + }, + "dark": { + "fontWeight": "bold", + "color": "#ff0000" + } + }, + "warn": { + // yellow + "gutterIconColor": "#999900", + "overviewRulerColor": "rgba(150, 150, 0, 0.7)", + "light": { + "fontWeight": "bold", + "color": "#999900" + }, + "dark": { + "fontWeight": "bold", + "color": "#ffff00" + } + } + }, + "inline-bookmarks.view.showVisibleFilesOnly": true, + "commit-message-editor.staticTemplate": [ + "feat: Short description", + "", + "Message body", + "", + "Message footer" + ], + "commit-message-editor.dynamicTemplate": [ + "{type}{scope}: {description}", + "", + "{body}", + "", + "{breaking_change}{footer}" + ], + "commit-message-editor.tokens": [ + { + "label": "Type", + "name": "type", + "type": "enum", + "options": [ + { + "label": "---", + "value": "" + }, + { + "label": "build", + "description": "Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)" + }, + { + "label": "chore", + "description": "Updating grunt tasks etc; no production code change" + }, + { + "label": "ci", + "description": "Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)" + }, + { + "label": "docs", + "description": "Documentation only changes" + }, + { + "label": "feat", + "description": "A new feature" + }, + { + "label": "fix", + "description": "A bug fix" + }, + { + "label": "editor", + "description": "A change to editor/IDE support" + }, + { + "label": "infra", + "description": "A change to the build, test or CI infrastructure" + }, + { + "label": "perf", + "description": "A code change that improves performance" + }, + { + "label": "refactor", + "description": "A code change that neither fixes a bug nor adds a feature" + }, + { + "label": "revert" + }, + { + "label": "style", + "description": "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)" + }, + { + "label": "test", + "description": "Adding missing tests or correcting existing tests" + } + ], + "description": "Type of changes" + }, + { + "label": "Scope", + "name": "scope", + "description": "A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., \"feat(parser): add ability to parse arrays\".", + "type": "text", + "multiline": false, + "prefix": "(", + "suffix": ")" + }, + { + "label": "Short description", + "name": "description", + "description": "Short description in the subject line.", + "type": "text", + "multiline": false + }, + { + "label": "Body", + "name": "body", + "description": "Optional body", + "type": "text", + "multiline": true, + "lines": 5, + "maxLines": 10 + }, + { + "label": "Breaking change", + "name": "breaking_change", + "type": "boolean", + "value": "BREAKING CHANGE: ", + "default": false + }, + { + "label": "Footer", + "name": "footer", + "description": "Optional footer", + "type": "text", + "multiline": true + } + ], + "surround.custom": { + "register": { + "label": "register helper", + "snippet": "{ ${1:helper}: $TM_SELECTED_TEXT }" + } + }, + "rewrap.wrappingColumn": 100, + "rewrap.onSave": false, + "rewrap.autoWrap.enabled": true, + "rewrap.reformat": true, + "rewrap.wholeComment": false } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000..29d05d11e7 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +- [ ] Implement `#try` that takes handler + - [ ] internal `{{#if isClear}}{{!-- actual code --}}{{/if}}` + - [ ] if an error is encountered, unwind as-if isClear was false + during the render pass + - [ ] when you encounter the enter of the try, insert a marker in + every VM stack. + - [ ] every stack in the VM needs "unwind to nearest marker" + - [ ] when a render error is encountered, unwind all the stacks + - [ ] call the handler with the error + - [ ] no catch + - [ ] the handler has a way to clear the error + - [ ] deal with user destructors that should run even during render errors + - [ ] maintain the invariant that constructors and destructors are paired diff --git a/bin/.eslintrc.cjs b/bin/.eslintrc.cjs index ff785c7aa5..358453f1d1 100644 --- a/bin/.eslintrc.cjs +++ b/bin/.eslintrc.cjs @@ -7,7 +7,6 @@ module.exports = { root: false, env: { es6: true, - node: true, }, overrides: [ { diff --git a/bin/codemods/devmode.toml b/bin/codemods/devmode.toml new file mode 100644 index 0000000000..6a55621c2c --- /dev/null +++ b/bin/codemods/devmode.toml @@ -0,0 +1,7 @@ +[devmode] +match = "devmode({ :[block] })" +rewrite = "devmode(() => ({ :[block] }))" + +[devmode2] +match = "if (import.meta.env.DEV) { :[[name]].description = :[description]; }" +rewrite = "setDescription(:[name], devmode(() => :[description]));" diff --git a/bin/codemods/new-spec-suite.toml b/bin/codemods/new-spec-suite.toml new file mode 100644 index 0000000000..ef577e8b8d --- /dev/null +++ b/bin/codemods/new-spec-suite.toml @@ -0,0 +1,21 @@ +[test] +match = """ +export class :[class] extends :[extends] { + static suiteName = :[suite]; + :[block] +} +""" +rewrite = """ +import { matrix } from "@glimmer-workspace/integration-tests"; +matrix(:[suite], (spec) => { :[block] }).client(); +""" +rule = """ +where +rewrite :[block] { "this" -> "ctx" }, +rewrite :[block] { + "@render(:[type]) :[string]() { :[body] }" -> "spec({ type: :[type] }, :[string], (ctx) => { :[body] })" +}, +rewrite :[block] { + "@render :[string]() { :[body] }" -> "spec(:[string], (ctx) => { :[body] })" +} +""" diff --git a/bin/opcodes.json b/bin/opcodes.json new file mode 100644 index 0000000000..66dd0efa4a --- /dev/null +++ b/bin/opcodes.json @@ -0,0 +1,112 @@ +{ + "$schema": "./opcodes/opcodes.schema.json", + "outputs": { + "interface": "@glimmer/interfaces/lib/generated/vm-opcodes.d.ts", + "code": "@glimmer/vm/lib/generated/opcodes.ts", + "debug": "@glimmer/debug/lib/generated/op-list.ts" + }, + "machine": ["PushFrame", "PopFrame", "Jump", "ReturnTo", "UnwindTypeFrame"], + "syscall": [ + "PushBegin", + "Begin", + "Catch", + "Finally", + "InvokeVirtual", + "InvokeStatic", + "Start", + "Return", + "Helper", + "SetNamedVariables", + "SetBlocks", + "SetVariable", + "SetBlock", + "GetVariable", + "GetProperty", + "GetBlock", + "SpreadBlock", + "HasBlock", + "HasBlockParams", + "Concat", + "Constant", + "ConstantReference", + "Primitive", + "PrimitiveReference", + "ReifyU32", + "Dup", + "Pop", + "Load", + "Fetch", + "RootScope", + "VirtualRootScope", + "ChildScope", + "PopScope", + "Text", + "Comment", + "AppendHTML", + "AppendSafeHTML", + "AppendDocumentFragment", + "AppendNode", + "AppendText", + "OpenElement", + "OpenDynamicElement", + "PushRemoteElement", + "StaticAttr", + "DynamicAttr", + "ComponentAttr", + "FlushElement", + "CloseElement", + "PopRemoteElement", + "Modifier", + "BindDynamicScope", + "PushDynamicScope", + "PopDynamicScope", + "CompileBlock", + "PushBlockScope", + "PushSymbolTable", + "InvokeYield", + "JumpIf", + "JumpUnless", + "JumpEq", + "AssertSame", + "Enter", + "Exit", + "ToBoolean", + "EnterList", + "ExitList", + "Iterate", + "Main", + "ContentType", + "Curry", + "PushComponentDefinition", + "PushDynamicComponentInstance", + "ResolveDynamicComponent", + "ResolveCurriedComponent", + "PushArgs", + "PushEmptyArgs", + "PrepareArgs", + "CaptureArgs", + "CreateComponent", + "RegisterComponentDestructor", + "PutComponentOperations", + "GetComponentSelf", + "GetComponentTagName", + "GetComponentLayout", + "SetupForEval", + "PopulateLayout", + "InvokeComponentLayout", + "BeginComponentTransaction", + "CommitComponentTransaction", + "DidCreateElement", + "DidRenderLayout", + "Debugger", + "StaticComponentAttr", + "DynamicContentType", + "DynamicHelper", + "DynamicModifier", + "IfInline", + "Not", + "GetDynamicVar", + "Log", + "PushUnwindTarget" + ] +} diff --git a/bin/opcodes.mts b/bin/opcodes.mts new file mode 100644 index 0000000000..47813386a0 --- /dev/null +++ b/bin/opcodes.mts @@ -0,0 +1,133 @@ +import { dirname, resolve } from 'node:path'; +import chalk from 'chalk'; +import { execSync, spawnSync } from 'node:child_process'; +import { Emitter } from './opcodes/utils.mjs'; + +const emitter = Emitter.argv('opcodes.json', import.meta); + +const { machine, system } = emitter.opcodes; + +const ALL: (string | null)[] = [ + ...machine, + ...new Array(16 - machine.length).fill(null), + ...system, +]; + +const TYPES = ['interface', 'code', 'debug', 'all'] as const; + +if (emitter.options.help) { + throw usage(); +} + +if (emitter.type === undefined) { + throw usage('Missing type'); +} + +compute(emitter.type); + +function compute(type: string) { + switch (type) { + case 'interface': { + const members = Object.fromEntries( + ALL.flatMap((name, i) => { + if (name === null) return []; + return [[name, i]]; + }) + ); + + const INTERFACE_MEMBERS = ALL.flatMap((name, i) => { + if (name === null) return []; + return [` ${name}: ${i};`]; + }).join('\n'); + + emitter.writeTarget('interface')([ + `export interface VmOpMap {\n${INTERFACE_MEMBERS}\n}`, + '', + `export type VmMachineOp =`, + ...machine.flatMap((m) => [`// ${m}`, `| ${members[m]}`]), + '', + `export type VmSyscallOp =`, + ...system.flatMap((m) => [`// ${m}`, `| ${members[m]}`]), + '', + `export type OpSize = ${ALL.length};`, + ]); + break; + } + + case 'code': { + const OP_TYPE = `OpType`; + + const CODE_MEMBERS = ALL.flatMap((name, i) => { + if (name === null) return []; + return [` ${name}: ${i},`]; + }).join('\n'); + + emitter.writeTarget('code')([ + `export interface Op {\n${CODE_MEMBERS}\n}`, + '', + `export const Op: Op = {\n${CODE_MEMBERS}\n};`, + '', + `export const OpSize = ${ALL.length} as const;`, + ]); + break; + } + + case 'debug': { + emitter.writeTarget('debug')([ + `import type { VmOpMap } from "${emitter.imports.interface}";`, + '', + `export const DebugOpList = ${JSON.stringify( + ALL + )} as const satisfies { [P in keyof VmOpMap as VmOpMap[P]]: P };`, + '', + `export type DebugOpList = ${JSON.stringify(ALL)}`, + ]); + break; + } + + case 'all': + compute('code'); + compute('interface'); + compute('debug'); + break; + + default: + usage(`Invalid type ${chalk.cyan(type)}`); + } +} + +function usage(error?: string): never { + emitter.human(chalk.cyan.inverse('Usage:'), chalk.cyan('node opcodes.mts [target]')); + emitter.newline(); + emitter.human( + chalk.yellow(` `), + chalk.grey.inverse('one of'), + chalk.cyanBright(TYPES.join(', ')) + ); + emitter.human( + chalk.yellow(`[target] `), + chalk.grey(`Output path or '-' (defaults to target in opcodes.json).`) + ); + emitter.newline(); + emitter.human(chalk.yellowBright.inverse('NOTE')); + emitter.human( + chalk.yellow( + `targets in ${chalk.cyan(`opcodes.json`)} are ${chalk.magentaBright( + 'relative to the packages directory' + )}.` + ) + ); + emitter.human( + chalk.yellow( + `targets ${chalk.cyan('specified on the command-line')} are ${chalk.magentaBright( + 'relative to the workspace root' + )}.` + ) + ); + + if (error) { + emitter.newline(); + emitter.human(chalk.red.inverse('ERROR'), chalk.red(error)); + } + process.exit(error ? 1 : 0); +} diff --git a/bin/opcodes/opcodes.schema.json b/bin/opcodes/opcodes.schema.json new file mode 100644 index 0000000000..c9a8a78bdf --- /dev/null +++ b/bin/opcodes/opcodes.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["outputs", "machine", "syscall"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "path" + }, + "outputs": { + "type": "object", + "additionalProperties": false, + "properties": { + "interface": { "type": "string" }, + "code": { "type": "string" }, + "debug": { "type": "string" } + }, + "required": ["interface", "code", "debug"] + }, + "machine": { + "type": "array", + "items": { "type": "string" } + }, + "syscall": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/bin/opcodes/utils.mts b/bin/opcodes/utils.mts new file mode 100644 index 0000000000..d272929f1d --- /dev/null +++ b/bin/opcodes/utils.mts @@ -0,0 +1,225 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import chalk from 'chalk'; +import { spawnSync } from 'node:child_process'; + +const MISSING_INDEX = -1; +const REMOVE = 1; + +interface OpcodesJson { + outputs: { + code: string; + interface: string; + debug: string; + }; + machine: string[]; + syscall: string[]; +} + +interface Paths { + config: string; + root: string; + packages: string; +} + +interface Options { + verbose: boolean; + dryRun: boolean; + help: boolean; +} + +type Target = keyof OpcodesJson['outputs']; + +export class Emitter { + static argv(config: string, here: ImportMeta) { + const __dirname = new URL('.', here.url).pathname; + const configPath = resolve(__dirname, config); + const json = JSON.parse(readFileSync(configPath, 'utf-8')); + + const root = dirname(__dirname); + const packages = resolve(root, 'packages'); + const ARGV = [...process.argv.slice(2)]; + + const verbose = extractFlag(ARGV, '--verbose') || extractFlag(ARGV, '-v'); + const dryRun = extractFlag(ARGV, '--dry-run') || extractFlag(ARGV, '-d'); + const help = extractFlag(ARGV, '--help') || extractFlag(ARGV, '-h'); + + return new Emitter(json, { root, packages, config: configPath }, ARGV, { + verbose, + dryRun, + help, + }); + } + + readonly #json: OpcodesJson; + readonly #paths: Paths; + readonly #args: string[]; + readonly #options: Options; + + constructor(json: OpcodesJson, paths: Paths, args: string[], options: Options) { + this.#json = json; + this.#paths = paths; + this.#args = args; + this.#options = options; + } + + get type(): string | undefined { + return this.#args[0]; + } + + get target(): string | undefined { + return this.#args[1]; + } + + get options(): Options { + return this.#options; + } + + get opcodes(): { machine: string[]; system: string[] } { + return { + machine: this.#json.machine, + system: this.#json.syscall, + }; + } + + writeTarget(name: Target): (code: string[]) => void { + return (code) => { + this.write(code, this.#resolvePath(name)); + }; + } + + newline(): void { + this.human(''); + } + + #resolvePath(name: Target) { + switch (this.target) { + case '-': + return '-'; + case undefined: + return resolve(this.#paths.packages, this.#json.outputs[name]); + default: + return resolve(this.#paths.root, this.target); + } + } + + write(code: string[], path: string) { + const outputString = [ + `// This code was generated by $root/bin/opcodes.mts.`, + `// Do not change it manually.`, + '', + ...code, + ].join('\n'); + + const dir = dirname(path); + + if (!existsSync(dir)) { + this.human(`Directory ${chalk.cyan(dir)} does not exist.`); + process.exit(1); + } + + const result = spawnSync(`pnpm`, ['prettier', '--parser', 'typescript'], { + cwd: this.#paths.root, + input: outputString, + stdio: ['pipe', 'pipe', 'inherit'], + encoding: 'utf-8', + }); + + const { stdout: formatted } = result; + + if (formatted === null) { + this.human( + chalk.red.inverse('ERROR'), + 'running', + `pnpm prettier --parser typescript`, + 'at', + chalk.cyan(this.#paths.root) + ); + this.newline(); + this.human(chalk.yellow.inverse('STDIN')); + this.logOutput(outputString); + this.newline(); + process.exit(1); + } + + if (this.#options.dryRun) { + this.human( + chalk.cyan.inverse('[DRY RUN]'), + chalk.grey(`Writing ${chalk.cyan(relative(this.#paths.root, path))}...`) + ); + } + + if (path === '-') { + this.emit(formatted); + return; + } else if (this.#options.dryRun) { + this.logOutput(formatted); + this.newline(); + return; + } + + this.human(chalk.grey(`Writing ${chalk.cyan(path)}...`)); + writeFileSync(path, formatted); + + if (this.#options.verbose) { + const written = readFileSync(path, 'utf-8'); + this.human(''); + this.human(chalk.bgGrey('Output:')); + this.logOutput(written); + this.newline(); + } + } + + logOutput(output: string) { + this.human(''); + for (const line of output.split('\n')) { + this.human(`${chalk.grey('|')} ${chalk.green.dim(line)}`); + } + } + + get imports(): { code: string; interface: string; debug: string } { + return { + code: this.#extractImport('code'), + interface: this.#extractImport('interface'), + debug: this.#extractImport('debug'), + }; + } + + #extractImport(type: Target): string { + const match = /^@glimmer\/[^/]*/.exec(this.#json.outputs[type])?.[0]; + + if (!match) { + this.human(`Invalid output.${type} in opcodes.json: ${chalk.cyan(this.#paths.config)}`); + this.human(chalk.yellow(`Output files must be nested in '@glimmer/*' packages`)); + process.exit(1); + } + + return match; + } + + /** + * Anything intended to be piped should be sent to `stdout`. + */ + emit(...args: unknown[]) { + console.log(...args); + } + + /** + * Anything that isn't intended to be piped should be sent to `stderr` (really + * should be called `stdlog` imo). + */ + human(...args: unknown[]) { + console.error(...args); + } +} + +function extractFlag(argv: string[], name: string): boolean { + const index = argv.indexOf(name); + + if (index === MISSING_INDEX) { + return false; + } + + argv.splice(index, REMOVE); + return true; +} diff --git a/bin/tsconfig.json b/bin/tsconfig.json index cba49b2922..bb4f82cb03 100644 --- a/bin/tsconfig.json +++ b/bin/tsconfig.json @@ -22,5 +22,5 @@ "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true }, - "include": ["*.mjs", "*.mts", "*.ts"] + "include": ["*.mjs", "*.mts", "*.ts", "*/*.mjs", "*/*.mts", "*/*.ts"] } diff --git a/glimmer-vm.code-workspace b/glimmer-vm.code-workspace new file mode 100644 index 0000000000..cf76460b62 --- /dev/null +++ b/glimmer-vm.code-workspace @@ -0,0 +1,189 @@ +{ + "folders": [ + { + "name": "✨ glimmer-engine", + "path": "." + }, + { + "name": "glimmer-benchmark", + "path": "benchmark" + }, + { + "name": "@glimmer-workspace/krausest", + "path": "benchmark/benchmarks/krausest" + }, + { + "name": "@glimmer-workspace/bin", + "path": "bin" + }, + { + "name": "πŸ“¦ local-linker", + "path": "lib/local-linker" + }, + { + "name": "πŸ“¦ /home/wycats/Code/Ember/glimmer-vm/packages", + "path": "packages" + }, + { + "name": "πŸ“¦ @glimmer-workspace/benchmark-env", + "path": "packages/@glimmer-workspace/benchmark-env" + }, + { + "name": "πŸ“¦ @glimmer-workspace/build-support", + "path": "packages/@glimmer-workspace/build" + }, + { + "name": "πŸ“¦ @glimmer-workspace/eslint-plugin", + "path": "packages/@glimmer-workspace/eslint-plugin" + }, + { + "name": "πŸ“¦ @glimmer-workspace/integration-tests", + "path": "packages/@glimmer-workspace/integration-tests" + }, + { + "name": "πŸ“¦ @glimmer-workspace/test-utils", + "path": "packages/@glimmer-workspace/test-utils" + }, + { + "name": "πŸ“¦ @glimmer/compiler", + "path": "packages/@glimmer/compiler" + }, + { + "name": "πŸ“¦ @glimmer-test/compiler", + "path": "packages/@glimmer/compiler/test" + }, + { + "name": "πŸ“¦ @glimmer/debug", + "path": "packages/@glimmer/debug" + }, + { + "name": "πŸ“¦ @glimmer/destroyable", + "path": "packages/@glimmer/destroyable" + }, + { + "name": "πŸ“¦ @glimmer-test/destroyable", + "path": "packages/@glimmer/destroyable/test" + }, + { + "name": "πŸ“¦ @glimmer/dom-change-list", + "path": "packages/@glimmer/dom-change-list" + }, + { + "name": "πŸ“¦ @glimmer-test/dom-change-list", + "path": "packages/@glimmer/dom-change-list/test" + }, + { + "name": "πŸ“¦ @glimmer/encoder", + "path": "packages/@glimmer/encoder" + }, + { + "name": "πŸ“¦ @glimmer/global-context", + "path": "packages/@glimmer/global-context" + }, + { + "name": "πŸ“¦ @glimmer/interfaces", + "path": "packages/@glimmer/interfaces" + }, + { + "name": "πŸ“¦ @glimmer/local-debug-flags", + "path": "packages/@glimmer/local-debug-flags" + }, + { + "name": "πŸ“¦ @glimmer/manager", + "path": "packages/@glimmer/manager" + }, + { + "name": "πŸ“¦ @glimmer-test/manager", + "path": "packages/@glimmer/manager/test" + }, + { + "name": "πŸ“¦ @glimmer/node", + "path": "packages/@glimmer/node" + }, + { + "name": "πŸ“¦ @glimmer/opcode-compiler", + "path": "packages/@glimmer/opcode-compiler" + }, + { + "name": "πŸ“¦ @glimmer/owner", + "path": "packages/@glimmer/owner" + }, + { + "name": "πŸ“¦ @glimmer-test/owner", + "path": "packages/@glimmer/owner/test" + }, + { + "name": "πŸ“¦ @glimmer/program", + "path": "packages/@glimmer/program" + }, + { + "name": "πŸ“¦ @glimmer-test/program", + "path": "packages/@glimmer/program/test" + }, + { + "name": "πŸ“¦ @glimmer/reference", + "path": "packages/@glimmer/reference" + }, + { + "name": "πŸ“¦ @glimmer-test/reference", + "path": "packages/@glimmer/reference/test" + }, + { + "name": "πŸ“¦ @glimmer/runtime", + "path": "packages/@glimmer/runtime" + }, + { + "name": "πŸ“¦ @glimmer/syntax", + "path": "packages/@glimmer/syntax" + }, + { + "name": "πŸ“¦ @glimmer-test/syntax", + "path": "packages/@glimmer/syntax/test" + }, + { + "name": "πŸ“¦ @glimmer/util", + "path": "packages/@glimmer/util" + }, + { + "name": "πŸ“¦ @glimmer-test/util", + "path": "packages/@glimmer/util/test" + }, + { + "name": "πŸ“¦ @glimmer/validator", + "path": "packages/@glimmer/validator" + }, + { + "name": "πŸ“¦ @glimmer-test/validator", + "path": "packages/@glimmer/validator/test" + }, + { + "name": "πŸ“¦ @glimmer/vm", + "path": "packages/@glimmer/vm" + }, + { + "name": "πŸ“¦ @glimmer/vm-babel-plugins", + "path": "packages/@glimmer/vm-babel-plugins" + }, + { + "name": "πŸ“¦ @glimmer/wire-format", + "path": "packages/@glimmer/wire-format" + }, + { + "name": "πŸ“¦ @types/js-reporters", + "path": "packages/@types/js-reporters" + }, + { + "name": "πŸ“¦ @types/puppeteer-chromium-resolver", + "path": "packages/@types/puppeteer-chromium-resolver" + }, + { + "name": "πŸ“¦ @types/qunit", + "path": "packages/@types/qunit" + } + ], + "settings": { + "typescript.tsc.autoDetect": "on", + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.tsserver.experimental.enableProjectDiagnostics": true + } +} diff --git a/guides/building-glimmer/01-introduction.md b/guides/building-glimmer/01-introduction.md new file mode 100644 index 0000000000..4eabb85084 --- /dev/null +++ b/guides/building-glimmer/01-introduction.md @@ -0,0 +1,5 @@ +### Table of Contents + +1. [Introduction](./01-introduction.md) +2. [Minification Assumptions](./02-minification.md) +3. [Dev Mode Patterns](./03-devmode-patterns.md) diff --git a/guides/building-glimmer/02-minification.md b/guides/building-glimmer/02-minification.md new file mode 100644 index 0000000000..f2b979b13f --- /dev/null +++ b/guides/building-glimmer/02-minification.md @@ -0,0 +1,14 @@ +# Minification Assumptions + +> 🚧 https://terser.org/docs/options/ + +- `import.meta.env.DEV` +- `keep_fargs: false` + +## Issues + +### Works better in terser than swc + +- https://tiny.katz.zone/OfydPB +- https://tiny.katz.zone/XlJrqp +- https://tiny.katz.zone/6R5VdC (weird behavior involving new) diff --git a/guides/building-glimmer/03-devmode-patterns.md b/guides/building-glimmer/03-devmode-patterns.md new file mode 100644 index 0000000000..e93df9a578 --- /dev/null +++ b/guides/building-glimmer/03-devmode-patterns.md @@ -0,0 +1,3 @@ +# Dev Mode Patterns + +## Case Study: `DevMode` diff --git a/guides/compiler-internals/01-introduction.md b/guides/internals/01-introduction.md similarity index 60% rename from guides/compiler-internals/01-introduction.md rename to guides/internals/01-introduction.md index 9f676e0528..bcc4f17662 100644 --- a/guides/compiler-internals/01-introduction.md +++ b/guides/internals/01-introduction.md @@ -6,3 +6,7 @@ 4. [Resolver Delegate](./04-compile-time-resolver-delegate.md) 5. [Handles](./05-handles.md) 6. [Template Compilation](./06-templates.md) +7. [Frames and Blocks](./07-frames-and-blocks.md) +8. [References](./08-references.md) +9. [Error Recovery](./09-error-recovery/index.md) +10. [Trace Logging](./10-trace-logging.md) diff --git a/guides/compiler-internals/02-program-compilation-context.md b/guides/internals/02-program-compilation-context.md similarity index 100% rename from guides/compiler-internals/02-program-compilation-context.md rename to guides/internals/02-program-compilation-context.md diff --git a/guides/internals/07-frames-and-blocks.md b/guides/internals/07-frames-and-blocks.md new file mode 100644 index 0000000000..4b5330150a --- /dev/null +++ b/guides/internals/07-frames-and-blocks.md @@ -0,0 +1,55 @@ +# Frames and Blocks + +Initial: + +```clj +[ + (PushFrame) + [ + (ReturnTo END) + (Push ...Args) + (Enter) + [ + (Assertion) ; (JumpUnless) | (AssertSame) + ...body + ] + (Exit) + (Return) + ; END + ] + (PopFrame) +] +``` + +Update: + +```clj +[ + ; restore state + (ReturnTo -1) + (PushArgs ...Captured) + (ReEnter) + ; start evaluation here + [ + (Assertion) + ; (JumpUnless) | (AssertSame) + ...body + ] + (Exit) + (Return) +] +``` + +1. Initial + 1. PushFrame + 2. ReturnTo + 3. Push captured args + 4. Enter (optionally try frame) + 5. Assertion + a. `JumpUnless` -> target + b. `AssertSame` + 6. (body) + 7. Exit + 8. Return + 9. PopFrame +2. Update (from 1.5) diff --git a/guides/internals/07-references.md b/guides/internals/07-references.md new file mode 100644 index 0000000000..ae6cc34dab --- /dev/null +++ b/guides/internals/07-references.md @@ -0,0 +1,139 @@ +# Reactivity APIs + +## Generic APIs + +## `readReactive` + +| Takes | Returns | +| ------------------ | ----------------------------- | +| 🟒🟑 Fallible Read | 🟑 Result _(throws βšͺ Never)_ | + +The `readReactive` function takes a reactive value and returns a `ReactiveResult`. + +## `unwrapReactive` + +The `unwrapReactive` function takes a `ReactiveResult` and returns its value, throwing an exception +if the reactive produces an error (or was previously an error). + +| Takes | Returns | +| ------------------ | ------------------------------------ | +| 🟒🟑 Fallible Read | 🟒 Value _(throws πŸ”΄ UserException)_ | + +## `updateReactive` + +The `updateReactive` function takes a _mutable_ reactive and a new value and updates it. You cannot +pass an immutable reactive to this function. + +| Takes | Updates | +| ---------- | ------------------------ | +| πŸ“ Mutable | 🟒 Value _(or πŸ”΄ Error)_ | + +## Cell APIs + +### Constructors + +```ts +function Cell(value: T): MutableCell; +function ReadonlyCell(value: T): ReadonlyCell; + +type Cell = MutableCell | ReadonlyCell; +``` + +| Type | Read | Write | +| ----------------- | -------- | ------------ | +| `Cell` | 🟒 Value | πŸ“ Mutable | +| `ReadonlyCell` | 🟒 Value | 🚫 Immutable | + +### `readCell` + +```ts +export function readCell(cell: Cell): T; +``` + +The `readCell` function takes a cell and returns its value. Since cells are infallible, you can use +this function to read from a cell without risking an exception (as with `unwrapReactive`). + +### `writeCell` + +```ts +export function writeCell(cell: MutableCell, value: T): void; +``` + +| Takes | Updates | +| ---------------- | -------- | +| πŸ“ Mutable Write | 🟒 Value | + +The `writeCell` function writes a new value to a mutable cell. You can't write to a readonly cell. + +## Formula APIs + +| Type | Read | Write | +| ------------- | --------- | ------------ | +| `Accessor` | 🟑 Result | πŸ“ Mutable | +| `Formula` | 🟑 Result | 🚫 Immutable | + +### Constructors + +```ts +export function Formula(compute: () => T): Formula; +export function Accessor(options: { get: () => T; set: (value: T) => void }): Accessor; +``` + +If an accessor's `set` throws an error, the reactive value will become an error. + +## External Markers + +External markers are not reactive values themselves. Instead, they _stand in_ for external storage. + +Here's an example using a `SimpleMap` class that uses a `Map` as its backing storage: + +```ts +class SimpleMap { + #markers = new Map(); + #values = new Map(); + + get(key: K): V { + this.#initialized(key).consumed(); + return this.#values.get(key); + } + + has(key: K) { + this.#initialized(key).consumed(); + return this.#values.has(key); + } + + set(key: K, value: V) { + this.#initialized(key).updated(); + this.#values.set(key, value); + } + + #initialized(key: K) { + let marker = this.#markers.get(key); + + if (!marker) { + marker = ExternalMarker(); + this.#markers.set(key, marker); + } + + return marker; + } +} +``` + +Now, reads from `has(key)` or `get(key)` will be invalidated whenever `set(key, value)` is called on +the same key. + +The crux of the situation is that we don't want to store every value in a Cell and turn every +computation into a `Formula`. Instead, we want to store our data in normal JavaScript data +structures and notify the reactivity system whenever the data is accessed or modified. + +## Internal Reactives + +### `ComputedCell` + +A `ComputedCell` behaves like a cell, but it uses a `compute` function to compute a new value. The +`compute` function must be infallible, and there is no error recovery if it fails. + +| Type | Read | Write | +| ----------------- | -------- | ------------ | +| `ComputedCell` | 🟒 Value | 🚫 Immutable | diff --git a/guides/internals/09-error-recovery/A-error-recovery-references.md b/guides/internals/09-error-recovery/A-error-recovery-references.md new file mode 100644 index 0000000000..39e6caddf1 --- /dev/null +++ b/guides/internals/09-error-recovery/A-error-recovery-references.md @@ -0,0 +1,55 @@ +# Error Recovery: Reactive Values + +Reactive values are _fallible_: they can be in an error state. + +## `ReactiveResult` + +A `ReactiveResult` can be in one of two states: + +```ts +interface Ok { + type: 'ok'; + value: T; +} +``` + +```ts +interface Error { + type: 'error'; + value: UserException; +} +``` + +## General Reactive APIs + +### `readReactive` + +The `readReactive` function takes a reactive value and returns a `ReactiveResult`. + +### `unwrapReactive` + +The `unwrapReactive` function takes a `ReactiveResult` and returns its value, throwing an exception +if the reactive produces an error (or was previously an error). + +## Cell APIs + +### `readCell` + +### `writeCell` + +## Error Recovery + +Once a formula has entered the error state, it can be recovered in one of two ways: + +- The formula invalidates +- The `clearError` function is called + +Examples: + +```ts +class isError = createCell(false); + +const formula = FallibleFormula(() => { + if () +}); +``` diff --git a/guides/internals/09-error-recovery/B-error-recovery-stack.md b/guides/internals/09-error-recovery/B-error-recovery-stack.md new file mode 100644 index 0000000000..c9dbe92a59 --- /dev/null +++ b/guides/internals/09-error-recovery/B-error-recovery-stack.md @@ -0,0 +1,76 @@ +# Error Recovery + +Error recovery is a feature of the Glimmer VM that allows the VM to recover from errors in user code. + +## Table of Contents + +1. [Conceptually](#conceptually) +1. [Internal VM Pattern](#internal-vm-pattern) +1. [The Unwind Pointer](#the-unwind-pointer) +1. [The ErrorBoundary Internal Construct](#the-errorboundary-internal-construct) +1. [Modelled as a Conditional](#modelled-as-a-conditional) + +## Conceptually + +Conceptually, this feature makes it possible to create an unwind boundary in the VM. When an error occurs inside of an unwind boundary, the VM will clear its internal stack back to the unwind pointer. + +## Internal VM Pattern + +Internally, all objects that represent aspects of the runtime stack have the following methods: + +```ts +interface VmStackAspect { + begin(): this; + catch(): this; + finally(): this; + + onCatch?(callback: () => void): void; + onFinally?(callback: () => void): void; + + readonly debug?: { frames: Array }; +} + +type DebugStackAspectFrame = + | { aspects: Record } + | { values: unknown[] }; +``` + +Each of these methods returns a _snapshot_ of the current state of the stack. We call these +snapshots "transactions". + +### Begin + +When entering a block of code with error recovery, call `begin()` on each stack aspect and replace +the current instance of that aspect with the result of calling `begin()`. + +### Catch + +When an error occurs inside of a block of code with error recovery, call `catch()` on each stack aspect +and replace the current instance of that aspect with the result of calling `catch()`. + +You can call `catch()` without balancing pops with pushes (that's the entire point). When you call +`catch()`, the stack aspect will return the state at the last `begin()` point. + +### Finally + +When you reach the end of a block of code with error recovery, call `finally()` on the stack aspect. +You must only call `finally()` after balancing pushes and pops. + +### `onCatch` (optional) + +Some stack aspects also implement `onCatch`. Any functions registered with `onCatch` will +automatically be invoked when `catch` occurs. + +### `onFinally` (optional) + +Some stack aspects also implement `onFinally`. Any functions registered with `onFinally` will +automatically be invoked when either `catch` or `finally` occurs. + +### `debug` (in dev mode) + +The `debug` property is a list of conceptual "frames". Each frame is a record of all of the stack aspects contained within the current stack +aspect. It is always present in dev mode (`import.meta.env.DEV`), and generally not present in prod. + +The "leaf" aspects are stacks, and each frame in a stack is a list of the values for that stack. +The "parent" aspects are collections of aspects, and the frames _zip together_ the frames of +the children. diff --git a/guides/internals/09-error-recovery/index.md b/guides/internals/09-error-recovery/index.md new file mode 100644 index 0000000000..295b208352 --- /dev/null +++ b/guides/internals/09-error-recovery/index.md @@ -0,0 +1,4 @@ +# Error Recovery + +1. [References](./error-recovery/A-error-recovery-references.md) +2. [Stack](./error-recovery/B-error-recovery-stack.md) diff --git a/guides/internals/10-trace-logging.md b/guides/internals/10-trace-logging.md new file mode 100644 index 0000000000..b8af58fb37 --- /dev/null +++ b/guides/internals/10-trace-logging.md @@ -0,0 +1,11 @@ +# Trace Logging + +This section describes the trace logging used in the Glimmer compiler. + +## Enabling Trace Logging + +### Enabling Trace Logging Via the Test Harness + +### Advanced Trace Options + +## Trace Options diff --git a/package.json b/package.json index 0611fd0391..2fd4f68e3c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "license": "MIT", "author": "Tilde, Inc.", "scripts": { + "generate:opcodes": "esyes bin/opcodes.mts all", "benchmark:build": "node benchmark/bin/build.js", "benchmark:control": "node benchmark/bin/control.js", "benchmark:experiment": "node benchmark/bin/experiment.js", @@ -23,7 +24,7 @@ "lint:files": "dotenv -- turbo lint", "force:lint:files": "eslint .", "lint:types": "tsc -b", - "start": "ember serve --port=7357", + "start": "vite", "test": "node bin/run-tests.mjs", "test:browserstack": "ember test --test-port=7774 --host 127.0.0.1 --config-file=testem-browserstack.js", "test:node": "node bin/run-node-tests.mjs", @@ -50,71 +51,74 @@ } }, "devDependencies": { + "@babel/plugin-proposal-decorators": "^7.23.2", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-modules-commonjs": "^7.21.5", - "@babel/plugin-transform-runtime": "^7.21.4", - "@babel/preset-env": "^7.21.5", - "@babel/preset-typescript": "^7.21.5", - "@babel/runtime": "^7.21.5", - "@babel/traverse": "^7.21.5", - "@babel/types": "^7.21.5", + "@babel/plugin-transform-modules-commonjs": "^7.23.0", + "@babel/plugin-transform-runtime": "^7.23.2", + "@babel/preset-env": "^7.23.2", + "@babel/preset-typescript": "^7.23.2", + "@babel/runtime": "^7.23.2", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", "@glimmer-workspace/build-support": "workspace:^", "@glimmer-workspace/eslint-plugin": "workspace:^", "@glimmer-workspace/integration-tests": "workspace:^", "@glimmer/env": "0.1.7", "@release-it-plugins/lerna-changelog": "^6.0.0", "@release-it-plugins/workspaces": "^4.0.0", - "@rollup/plugin-terser": "^0.4.1", - "@types/babel-plugin-macros": "^3.1.0", - "@types/babel__core": "^7.20.0", - "@types/babel__traverse": "^7.18.5", - "@types/eslint": "^8.37.0", - "@types/node": "^18.16.6", - "@types/preval.macro": "^3.0.0", - "@types/qunit": "^2.19.7", - "@typescript-eslint/eslint-plugin": "^5.59.5", - "@typescript-eslint/parser": "^5.59.5", + "@rollup/plugin-terser": "^0.4.4", + "@types/babel-plugin-macros": "^3.1.2", + "@types/babel__core": "^7.20.3", + "@types/babel__traverse": "^7.20.3", + "@types/eslint": "^8.44.6", + "@types/node": "^20.8.10", + "@types/qunit": "workspace:^", + "@typescript-eslint/eslint-plugin": "^6.9.1", + "@typescript-eslint/parser": "^6.9.1", + "@vitejs/plugin-react": "^4.1.1", + "@vitejs/plugin-react-swc": "^3.4.1", "amd-name-resolver": "^1.3.1", "auto-dist-tag": "^2.1.1", "babel-plugin-macros": "^3.1.0", "babel-plugin-strip-glimmer-utils": "^0.1.1", - "chalk": "^5.2.0", + "chalk": "^5.3.0", "dag-map": "^2.0.2", - "dotenv-cli": "^7.2.1", - "ember-cli": "~4.12.1", + "dotenv-cli": "^7.3.0", "ember-cli-browserstack": "^2.0.1", "ensure-posix-path": "^1.1.1", - "eslint": "^8.40.0", - "eslint-config-prettier": "^8.8.0", - "eslint-import-resolver-typescript": "^3.5.5", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-n": "^15.7.0", - "eslint-plugin-qunit": "^7.3.4", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "npm:eslint-plugin-i@^2.28.1", + "eslint-plugin-n": "^16.2.0", + "eslint-plugin-qunit": "^8.0.1", "eslint-plugin-simple-import-sort": "^10.0.0", - "eslint-plugin-unused-imports": "^2.0.0", - "execa": "^7.1.1", - "fast-glob": "^3.2.12", - "glob": "^10.2.3", + "eslint-plugin-unused-imports": "^3.0.0", + "esyes": "^1.0.1", + "execa": "^8.0.1", + "fast-glob": "^3.3.1", + "glob": "^10.3.10", "js-yaml": "^4.1.0", "loader.js": "^4.7.0", "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", - "prettier": "^2.8.8", - "preval.macro": "^5.0.0", - "puppeteer": "^20.1.2", - "puppeteer-chromium-resolver": "^20.0.0", - "qunit": "^2.19.4", + "prettier": "^3.0.3", + "prettier-plugin-organize-imports": "^3.2.3", + "puppeteer": "^21.5.0", + "puppeteer-chromium-resolver": "^21.0.0", + "qunit": "^2.20.0", "release-it": "^16.2.1", - "rimraf": "^5.0.0", - "rollup": "^3.21.6", - "semver": "^7.5.2", + "rimraf": "^5.0.5", + "rollup": "^4.3.0", + "semver": "^7.5.4", "testem-failure-only-reporter": "^1.0.0", "toml": "^3.0.0", "ts-node": "^10.9.1", - "turbo": "^1.9.3", - "typescript": "^5.0.4", - "vite": "^4.3.9", - "xo": "^0.54.2" + "turbo": "^1.10.16", + "typescript": "^5.2.2", + "vite": "^4.5.0", + "vite-plugin-babel": "^1.1.3", + "xo": "^0.56.0" }, "publishConfig": { "registry": "https://registry.npmjs.org" @@ -156,7 +160,7 @@ "node": ">=16.0.0" }, "volta": { - "node": "20.1.0", - "pnpm": "8.5.0" + "node": "21.1.0", + "pnpm": "8.6.12" } } diff --git a/packages/@glimmer-workspace/.eslintrc.cjs b/packages/@glimmer-workspace/.eslintrc.cjs index 77b2a142fd..9ec3e64b29 100644 --- a/packages/@glimmer-workspace/.eslintrc.cjs +++ b/packages/@glimmer-workspace/.eslintrc.cjs @@ -1,30 +1,141 @@ -const { resolve } = require('path'); +const { existsSync, readFileSync } = require('fs'); +const { resolve, relative, dirname } = require('path'); -const tsconfig = resolve(__dirname, 'tsconfig.json'); +const glob = require('fast-glob'); + +const libs = glob.sync(['tsconfig.json', '*/tsconfig.json'], { + cwd: __dirname, + absolute: true, +}); + +const tests = glob.sync(['tsconfig.json', '*/test/tsconfig.json'], { + cwd: __dirname, + absolute: true, +}); + +const testDirs = glob.sync(['*/test'], { + cwd: __dirname, + absolute: true, + onlyDirectories: true, + ignore: ['**/node_modules/**'], +}); + +/** + * @type {Record>} + */ +const RESTRICTIONS = {}; + +for (const dir of testDirs) { + const pkgRoot = dirname(dir); + + if (!existsSync(resolve(dir, 'package.json'))) { + // don't create rules if the tests dir doesn't have a package.json + continue; + } + + const packageName = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')).name; + + const files = glob.sync(['**/*.{js,ts,d.ts}'], { + cwd: dir, + absolute: true, + onlyFiles: true, + ignore: ['**/node_modules/**'], + }); + + for (const file of files) { + const relativeFile = `./${relative(__dirname, file)}`; + const relativeToLib = relative(dirname(file), resolve(dirname(dir), 'lib')); + + const pkg = (RESTRICTIONS[packageName] ??= {}); + + if (pkg[relativeToLib]) { + pkg[relativeToLib].push(relativeFile); + } else { + pkg[relativeToLib] = [relativeFile]; + } + } +} + +/** + * @type {import("eslint").Linter.Config["overrides"]} + */ +const overrides = Object.entries(RESTRICTIONS).flatMap(([pkgName, overrides]) => + Object.entries(overrides).map(([lib, files]) => ({ + files, + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: lib, + message: `Import from ${pkgName} instead`, + }, + ], + }, + ], + }, + })) +); + +// console.dir({ overrides }, { depth: null }); /** @type {import("eslint").Linter.Config} */ module.exports = { root: false, overrides: [ { - files: ['*/index.{js,ts,d.ts}', '*/lib/**/*.{js,ts,d.ts}', '*/test/**/*.{js,ts,d.ts}'], + files: ['*/lib/**/*.{js,cjs,mjs,ts,d.ts}'], + excludedFiles: ['node_modules', '*/node_modules'], + parserOptions: { + project: libs, + }, + plugins: ['@glimmer-workspace'], + extends: ['plugin:@glimmer-workspace/recommended'], + }, + { + files: ['*/lib/**/*.cjs'], + env: { + node: true, + }, excludedFiles: ['node_modules', '*/node_modules'], parserOptions: { - ecmaVersion: 'latest', - project: [tsconfig], + project: libs, }, plugins: ['@glimmer-workspace'], extends: ['plugin:@glimmer-workspace/recommended'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-var-requires': 'off', + 'no-undef': 'off', + }, + }, + { + files: ['./integration-tests/{lib,test}/**/*.ts'], + rules: { + // off for now + '@typescript-eslint/no-explicit-any': 'off', + }, }, // QUnit is a weird package, and there are some issues open about fixing it // - https://github.com/qunitjs/qunit/issues/1729 // - https://github.com/qunitjs/qunit/issues/1727 // - https://github.com/qunitjs/qunit/issues/1724 { - files: ['**/*-test.ts', '**/{test,integration-tests}/**/*.ts'], + files: ['*/test/**/*.{js,ts,d.ts}'], + parserOptions: { + project: tests, + }, + plugins: ['@glimmer-workspace'], + extends: ['plugin:@glimmer-workspace/recommended'], rules: { '@typescript-eslint/unbound-method': 'off', + // off for now + '@typescript-eslint/no-explicit-any': 'off', + 'import/no-relative-packages': 'error', + 'no-restricted-paths': 'off', }, }, + ...overrides, ], }; diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/args-proxy.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/args-proxy.ts index d8183833fe..fb338e2a6c 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/args-proxy.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/args-proxy.ts @@ -1,5 +1,5 @@ -import type { CapturedArguments, CapturedNamedArguments, Reference } from '@glimmer/interfaces'; -import { valueForRef } from '@glimmer/reference'; +import type { CapturedArguments, CapturedNamedArguments, Reactive } from '@glimmer/interfaces'; +import { unwrapReactive } from '@glimmer/reference'; import type { ComponentArgs } from '../interfaces'; @@ -18,7 +18,7 @@ class ArgsProxy implements ProxyHandler { ): PropertyDescriptor | undefined { let desc: PropertyDescriptor | undefined; if (typeof p === 'string' && p in target) { - const value = valueForRef(target[p] as Reference); + const value = unwrapReactive(target[p] as Reactive); desc = { enumerable: true, configurable: false, @@ -35,7 +35,7 @@ class ArgsProxy implements ProxyHandler { get(target: CapturedNamedArguments, p: PropertyKey): unknown { if (typeof p === 'string' && p in target) { - return valueForRef(target[p] as Reference); + return unwrapReactive(target[p] as Reactive); } } diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/basic-component-manager.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/basic-component-manager.ts index 2e3e4d2612..3609ae1a8f 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/basic-component-manager.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/basic-component-manager.ts @@ -1,15 +1,11 @@ -import type { - Dict, - Owner, - Template, - VMArguments, - WithCreateInstance, -} from "@glimmer/interfaces"; +import type { Dict, Owner, Template, VMArguments, WithCreateInstance } from '@glimmer/interfaces'; +import type {Reactive} from '@glimmer/reference'; import { getComponentTemplate } from '@glimmer/manager'; -import { createConstRef, type Reference } from '@glimmer/reference'; +import { ReadonlyCell } from '@glimmer/reference'; import { EMPTY_ARGS } from '@glimmer/runtime'; import type { ComponentArgs } from '../interfaces'; + import argsProxy from './args-proxy'; const BASIC_COMPONENT_CAPABILITIES = { @@ -29,7 +25,7 @@ const BASIC_COMPONENT_CAPABILITIES = { }; interface BasicState { - self: Reference; + self: Reactive; instance: object; } @@ -42,7 +38,7 @@ class BasicComponentManager args: VMArguments | null ) { const instance = new Component(argsProxy(args === null ? EMPTY_ARGS : args.capture())); - const self = createConstRef(instance, 'this'); + const self = ReadonlyCell(instance, 'this'); return { instance, self }; } diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-env-delegate.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-env-delegate.ts index 6f53f90c2a..9c3ed731f8 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-env-delegate.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-env-delegate.ts @@ -1,11 +1,13 @@ /* eslint-disable no-console */ -import setGlobalContext from '@glimmer/global-context'; import type { Destroyable, Destructor, RenderResult } from '@glimmer/interfaces'; import type { EnvironmentDelegate } from '@glimmer/runtime'; +import setGlobalContext from '@glimmer/global-context'; type Queue = (() => void)[]; +type Dict = object & Record; + const scheduledDestructors: Queue = []; const scheduledFinalizers: Queue = []; @@ -51,6 +53,14 @@ setGlobalContext({ (obj as Record)[prop] = value; }, + setProperty(parent: T, key: K, value: T[K]) { + Reflect.set(parent, key, value); + }, + + getProperty(parent: T, key: K) { + return Reflect.get(parent, key); + }, + getPath(obj: unknown, path: string) { let parts = path.split('.'); diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-registry.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-registry.ts index 4429f376fd..f4d86c52fa 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-registry.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/create-registry.ts @@ -17,6 +17,7 @@ import { programCompilationContext } from '@glimmer/opcode-compiler'; import { artifacts, RuntimeOpImpl } from '@glimmer/program'; import type { UpdateBenchmark } from '../interfaces'; + import renderBenchmark from './render-benchmark'; export interface Registry { diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/on-modifier.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/on-modifier.ts index 33597adf88..a95c7877e2 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/on-modifier.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/on-modifier.ts @@ -1,30 +1,45 @@ import type { CapturedArguments, + DevMode, InternalModifierManager, Owner, SimpleElement, -} from "@glimmer/interfaces"; -import { type Reference, valueForRef } from '@glimmer/reference'; -import { castToBrowser } from '@glimmer/util'; + TagDescription, +} from '@glimmer/interfaces'; +import type { Reactive } from '@glimmer/reference'; +import { unwrapReactive } from '@glimmer/reference'; +import { castToBrowser, devmode, getDescription, setDescription } from '@glimmer/util'; import { createUpdatableTag } from '@glimmer/validator'; interface OnModifierState { element: SimpleElement; - nameRef: Reference; - listenerRef: Reference; + nameRef: Reactive; + listenerRef: Reactive; name: string | null; listener: EventListener | null; + description: DevMode; } class OnModifierManager implements InternalModifierManager { create(_owner: Owner, element: SimpleElement, _: {}, args: CapturedArguments) { - return { + const state = { element, - nameRef: args.positional[0] as Reference, - listenerRef: args.positional[1] as Reference, + nameRef: args.positional[0] as Reactive, + listenerRef: args.positional[1] as Reactive, name: null, listener: null, }; + setDescription( + state, + devmode( + () => + ({ + reason: 'modifier', + label: ['on'], + }) satisfies TagDescription + ) + ); + return state; } getDebugName() { @@ -32,8 +47,8 @@ class OnModifierManager implements InternalModifierManager=16" }, "exports": { - "default": "./index.js" + "default": "./lib/index.js" }, "starbeam": { "source": "js:typed", @@ -20,25 +20,25 @@ "test:types": "tsc --noEmit -p ../tsconfig.json" }, "dependencies": { - "@rollup/plugin-commonjs": "^24.1.0", - "@rollup/plugin-node-resolve": "^15.0.2", "eslint": "^8.40.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-json": "^3.1.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-simple-import-sort": "^10.0.0", - "eslint-plugin-unused-imports": "^2.0.0", - "magic-string": "^0.30.0", + "eslint-plugin-unused-imports": "^3.0.0", "postcss": "^8.4.31", - "rollup": "^3.21.6", + "@rollup/plugin-commonjs": "^25.0.4", + "@rollup/plugin-node-resolve": "^15.2.0", + "magic-string": "^0.30.3", + "rollup": "^3.28.0", "rollup-plugin-polyfill-node": "^0.12.0", "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-ts": "^3.2.0", + "rollup-plugin-ts": "^3.4.4", "unplugin-fonts": "^1.0.3", - "vite": "4.3.9" + "vite": "4.4.9" }, "devDependencies": { - "@types/node": "^18.16.6", + "@types/node": "*", "typescript": "*" } } diff --git a/packages/@glimmer-workspace/build/tsconfig.json b/packages/@glimmer-workspace/build/tsconfig.json new file mode 100644 index 0000000000..0cea37921d --- /dev/null +++ b/packages/@glimmer-workspace/build/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "composite": true, + "outDir": "dist", + + "allowJs": true, + "checkJs": true, + + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + + "strict": true, + "suppressImplicitAnyIndexErrors": false, + "useDefineForClassFields": false, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["qunit", "node", "vite/client"] + }, + "include": ["./lib/**/*.*"] +} diff --git a/packages/@glimmer-workspace/eslint-plugin/index.js b/packages/@glimmer-workspace/eslint-plugin/lib/index.cjs similarity index 57% rename from packages/@glimmer-workspace/eslint-plugin/index.js rename to packages/@glimmer-workspace/eslint-plugin/lib/index.cjs index e0a5f52cf0..f95197c168 100644 --- a/packages/@glimmer-workspace/eslint-plugin/index.js +++ b/packages/@glimmer-workspace/eslint-plugin/lib/index.cjs @@ -8,12 +8,30 @@ module.exports = { }, configs: { recommended: { + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.js', '.cjs', '.mjs', '.mts', '.ts', '.d.ts'], + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + node: { + allowModules: ['@glimmer/debug', '@glimmer/local-debug-flags'], + tryExtensions: ['.cjs', '.js', '.ts', '.d.ts', '.json'], + }, + }, + env: { + es6: true, + }, plugins: [ '@typescript-eslint', 'prettier', 'qunit', 'simple-import-sort', 'unused-imports', + 'import', 'prettier', 'n', ], @@ -21,20 +39,36 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'plugin:n/recommended', + 'plugin:n/recommended-module', 'plugin:import/recommended', 'plugin:qunit/recommended', 'plugin:regexp/recommended', + 'plugin:deprecation/recommended', 'prettier', ], rules: { + 'prefer-arrow-callback': 'error', + 'no-restricted-imports': 'off', + 'no-inner-declarations': 'off', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { group: ['**/generated/**'], message: "Don't import directly from generated files" }, + { + group: ['console', 'node:console'], + message: "Don't import directly from 'console'", + }, + ], + }, + ], + '@typescript-eslint/no-redundant-type-constituents': 'off', 'no-console': 'error', 'no-debugger': 'error', 'no-loop-func': 'error', 'prefer-const': 'off', 'no-fallthrough': 'off', - 'import/no-relative-packages': 'error', - 'import/default': 'off', + 'qunit/require-expect': ['error', 'never-except-zero'], // we're using assert.step instead of this sort of thing 'qunit/no-conditional-assertions': 'off', @@ -50,27 +84,11 @@ module.exports = { 'regexp/prefer-regexp-exec': 'error', 'regexp/prefer-quantifier': 'error', 'require-unicode-regexp': 'off', - 'unused-imports/no-unused-imports': 'error', - 'unused-imports/no-unused-vars': [ - 'warn', - { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, - ], - 'n/no-unpublished-require': 'off', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/ban-types': 'off', '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/consistent-type-imports': [ - 'error', - { - fixStyle: 'separate-type-imports', - }, - ], - '@typescript-eslint/consistent-type-exports': [ - 'error', - { - fixMixedExportsWithInlineTypeSpecifier: true, - }, - ], + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/naming-convention': [ 'error', @@ -105,7 +123,66 @@ module.exports = { '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/restrict-template-expressions': 'off', + 'n/no-missing-import': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'error', + { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, + ], + 'n/no-unpublished-require': 'off', + 'import/consistent-type-specifier-style': 'error', + 'import/no-relative-packages': 'error', + 'import/default': 'off', + 'import/no-unresolved': 'error', + 'import/no-extraneous-dependencies': 'error', + 'sort-imports': 'off', + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // == Side effect imports. == + ['^\\u0000'], + + // == from node:* == + [ + // + '^node:.+\\u0000$', + '^node:', + ], + + // == From (optionally scoped) packages + [ + // import type + '^@?\\w.+\\u0000$', + '^@?\\w', + ], + + // == from absolute imports == + // + // (Absolute imports and other imports such as Vue-style `@/foo`. Anything not matched + // in another group.) + [ + // import type + '^.+\\u0000$', + '^', + ], + + // == Relative imports ==. + [ + // import type + '^\\..+\\u0000$', + '^\\.', + ], + ], + }, + ], + 'simple-import-sort/exports': 'error', + 'no-unused-private-class-members': 'error', + 'import/first': 'error', + 'import/newline-after-import': 'error', + 'import/no-duplicates': 'error', + 'n/no-unsupported-features/es-syntax': 'off', 'n/no-unsupported-features/node-builtins': 'off', }, diff --git a/packages/@glimmer-workspace/eslint-plugin/package.json b/packages/@glimmer-workspace/eslint-plugin/package.json index 24ec828dd4..671ab3ccdc 100644 --- a/packages/@glimmer-workspace/eslint-plugin/package.json +++ b/packages/@glimmer-workspace/eslint-plugin/package.json @@ -1,24 +1,30 @@ { "name": "@glimmer-workspace/eslint-plugin", "private": true, - "main": "index.js", + "type": "commonjs", + "main": "lib/index.cjs", "dependencies": { - "@typescript-eslint/eslint-plugin": "^5.59.5", - "@typescript-eslint/parser": "^5.59.5", - "eslint-config-prettier": "^8.8.0", - "eslint-import-resolver-typescript": "^3.5.5", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-n": "^15.7.0", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.6.0", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-n": "^16.0.1", "eslint-plugin-regexp": "^1.15.0", - "eslint-plugin-qunit": "^7.3.4", + "eslint-plugin-qunit": "^8.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", - "eslint-plugin-unused-imports": "^2.0.0" + "eslint-plugin-unused-imports": "^3.0.0", + "eslint-plugin-deprecation": "^1.5.0", + "eslint-utils": "^3.0.0", + "ignore": "^5.2.4" }, "peerDependencies": { - "eslint": "^8.40.0" + "eslint": "^8.47.0" }, "devDependencies": { - "eslint": "^8.40.0", - "@types/eslint": "^8.37.0" + "eslint": "^8.47.0", + "@types/eslint": "^8.44.2", + "@types/eslint-utils": "^3.0.4", + "@types/node": "^20.8.10" } } diff --git a/packages/@glimmer-workspace/integration-tests/index.ts b/packages/@glimmer-workspace/integration-tests/index.ts deleted file mode 100644 index 83c13f6c91..0000000000 --- a/packages/@glimmer-workspace/integration-tests/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import './lib/setup'; - -export * from './lib/base-env'; -export * from './lib/compile'; -export * from './lib/components'; -export * from './lib/dom/assertions'; -export * from './lib/dom/blocks'; -export * from './lib/dom/simple-utils'; -export * from './lib/markers'; -export * from './lib/modes/env'; -export * from './lib/modes/jit/delegate'; -export * from './lib/modes/jit/register'; -export * from './lib/modes/jit/resolver'; -export * from './lib/modes/node/env'; -export * from './lib/modes/rehydration/delegate'; -export * from './lib/modes/rehydration/partial-rehydration-delegate'; -export type { RenderDelegateOptions } from './lib/render-delegate'; -export * from './lib/render-test'; -export * from './lib/setup-harness'; -export * from './lib/snapshot'; -export * from './lib/suites'; -export * from './lib/test-helpers/define'; -export * from './lib/test-helpers/module'; -export * from './lib/test-helpers/strings'; -export * from './lib/test-helpers/test'; -export * from './lib/test-helpers/tracked'; -export * from './lib/test-helpers/tracked-object'; -export { syntaxErrorFor } from '@glimmer-workspace/test-utils'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/assertions.ts b/packages/@glimmer-workspace/integration-tests/lib/assertions.ts new file mode 100644 index 0000000000..922fb30054 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/assertions.ts @@ -0,0 +1,24 @@ +export function notThrows(callback: () => void, message?: string): void; +export function notThrows(callback: () => Promise, message?: string): Promise; +export function notThrows( + callback: () => void | Promise, + message = 'expected callback to not throw' +): void | Promise { + try { + const result = callback(); + + if (result) { + return result + .then(() => { + QUnit.assert.ok(true, message); + }) + .catch((e) => { + QUnit.assert.notOk(e, message); + }); + } else { + QUnit.assert.ok(true, message); + } + } catch (e) { + QUnit.assert.ok(false, message); + } +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/compile.ts b/packages/@glimmer-workspace/integration-tests/lib/compile.ts index 9238ada29d..169dba10d2 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/compile.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/compile.ts @@ -1,12 +1,12 @@ -import { precompileJSON } from '@glimmer/compiler'; import type { Nullable, SerializedTemplateWithLazyBlock, Template, TemplateFactory, } from '@glimmer/interfaces'; -import { templateFactory } from '@glimmer/opcode-compiler'; import type { PrecompileOptions } from '@glimmer/syntax'; +import { precompileJSON } from '@glimmer/compiler'; +import { templateFactory } from '@glimmer/opcode-compiler'; // TODO: This fundamentally has little to do with testing and // most tests should just use a more generic preprocess, extracted diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/build.ts b/packages/@glimmer-workspace/integration-tests/lib/components/build.ts new file mode 100644 index 0000000000..1b014c6ebc --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/components/build.ts @@ -0,0 +1,70 @@ +import type { Dict, Maybe } from '@glimmer/interfaces'; + +import type { BuildStyle } from './delegate'; + +export class InvocationBuilder { + static ANGLE = new InvocationBuilder('angle'); + static CURLIES = new InvocationBuilder('curlies'); + + readonly #style: BuildStyle; + + constructor(style: BuildStyle) { + this.#style = style; + } + + args(args: Dict): string { + return buildArgs(ARGS[this.#style])(args); + } + + attributes(attrs: Dict = {}) { + return buildArgs(ATTRS[this.#style])(attrs); + } + + blockParams(blockParams: string[]): string { + return `${blockParams.length > 0 ? ` as |${blockParams.join(' ')}|` : ''}`; + } + + else(elseBlock: string | undefined): string { + return `${elseBlock ? `{{else}}${elseBlock}` : ''}`; + } +} + +const ARGS = { + angle: { sigil: '@', curlies: true }, + curlies: { sigil: '', curlies: false }, +} as const; + +const ATTRS = { + angle: { sigil: '', curlies: true }, + curlies: { sigil: '', curlies: false }, +} as const; + +export function buildArgs({ + sigil, + curlies, +}: { + sigil: string; + curlies: boolean; +}): (args: Dict) => string { + return (args) => { + return `${Object.keys(args) + .map((arg) => { + let rightSide: string; + + let value = args[arg] as Maybe; + if (curlies) { + let isString = value && (value[0] === "'" || value[0] === '"'); + if (isString) { + rightSide = `${value}`; + } else { + rightSide = `{{${value}}}`; + } + } else { + rightSide = `${value}`; + } + + return `${sigil}${arg}=${rightSide}`; + }) + .join(' ')}`; + }; +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/delegate.ts b/packages/@glimmer-workspace/integration-tests/lib/components/delegate.ts new file mode 100644 index 0000000000..b0812e1435 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/components/delegate.ts @@ -0,0 +1,143 @@ +import type { Dict, Nullable, SimpleElement } from '@glimmer/interfaces'; + +import type { TestJitRegistry } from '../modes/jit/registry'; +import type { DeclaredComponentType } from '../test-helpers/constants'; +import type { BuildInvocation, BuildTemplate } from './styles'; +import type { ComponentBlueprint, ComponentTypes } from './types'; + +import { assertElementShape, assertEmberishElement } from '../dom/assertions'; +import { + registerEmberishCurlyComponent, + registerGlimmerishComponent, + registerTemplateOnlyComponent, +} from '../modes/jit/register'; +import { + BuildCurlyInvoke, + BuildCurlyTemplate, + BuildDynamicInvoke, + BuildDynamicTemplate, + BuildGlimmerInvoke, + BuildGlimmerTemplate, +} from './styles'; + +export function buildTemplate( + delegate: ComponentDelegate, + component: ComponentBlueprint +): string { + const build = delegate.build; + + const template = build.template(component); + QUnit.assert.ok(true, `generated ${delegate.type} layout as ${template}`); + + return template; +} + +export function buildInvoke( + delegate: ComponentDelegate, + component: ComponentBlueprint +): { name: string; invocation: string } { + const build = delegate.build; + + const invocation = build.invoke(component); + QUnit.assert.ok( + true, + `generated ${delegate.type} invocation as ${invocation.invocation} (name=${invocation.name})` + ); + + return invocation; +} + +export interface BuildDelegate { + style: BuildStyle; + template: BuildTemplate; + invoke: BuildInvocation; +} + +export interface ComponentDelegate { + readonly type: K; + + register: ( + registry: TestJitRegistry, + name: string, + layout: Nullable, + Class?: ComponentTypes[K] + ) => void; + + assert: (element: SimpleElement, tagName: string, attrs?: Dict, contents?: string) => void; + + build: BuildDelegate; +} + +export type BuildStyle = 'angle' | 'curlies'; + +export const GlimmerDelegate: ComponentDelegate<'glimmer'> = { + type: 'glimmer', + register: (registry, name, layout, Class) => { + registerGlimmerishComponent(registry, name, Class ?? null, layout); + }, + + assert: assertElementShape, + + build: { + style: 'angle', + template: BuildGlimmerTemplate, + invoke: BuildGlimmerInvoke, + }, +}; + +export const CurlyDelegate: ComponentDelegate<'curly'> = { + type: 'curly', + register: (registry, name, layout, Class) => { + registerEmberishCurlyComponent(registry, name, Class ?? null, layout); + }, + + assert: assertEmberishElement, + + build: { + style: 'curlies', + template: BuildCurlyTemplate, + invoke: BuildCurlyInvoke, + }, +}; + +export const DynamicDelegate: ComponentDelegate<'dynamic'> = { + type: 'dynamic', + register: (registry, name, layout, Class) => { + registerEmberishCurlyComponent(registry, name, Class ?? null, layout); + }, + + assert: assertEmberishElement, + + build: { + style: 'curlies', + invoke: BuildDynamicInvoke, + template: BuildDynamicTemplate, + }, +}; + +export const TemplateOnlyDelegate: ComponentDelegate<'templateOnly'> = { + type: 'templateOnly', + register: (registry, name, layout) => { + registerTemplateOnlyComponent(registry, name, layout ?? ''); + }, + + assert: assertElementShape, + + build: { + style: 'angle', + template: BuildGlimmerTemplate, + invoke: BuildGlimmerInvoke, + }, +}; + +const DELEGATES = { + glimmer: GlimmerDelegate, + curly: CurlyDelegate, + dynamic: DynamicDelegate, + templateOnly: TemplateOnlyDelegate, +} as const satisfies { [K in DeclaredComponentType]: ComponentDelegate }; +type DELEGATES = typeof DELEGATES; + +export const getDelegate = (type: K): ComponentDelegate => { + return DELEGATES[type] as ComponentDelegate; +}; diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts index 0dd63560f0..f31987858f 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts @@ -1,6 +1,5 @@ -import { registerDestructor } from '@glimmer/destroyable'; import type { - Bounds, + BlockBounds, CapturedNamedArguments, CompilableProgram, Destroyable, @@ -12,30 +11,27 @@ import type { Nullable, Owner, PreparedArguments, - Reference, + Reactive, + TagDescription, Template, VMArguments, WithCreateInstance, WithDynamicLayout, WithDynamicTagName, } from '@glimmer/interfaces'; +import type { DirtyableTag } from '@glimmer/validator'; +import { registerDestructor } from '@glimmer/destroyable'; import { setInternalComponentManager } from '@glimmer/manager'; import { - childRefFor, - createComputeRef, - createConstRef, - createPrimitiveRef, - valueForRef, + createPrimitiveCell, + Formula, + getReactiveProperty, + ReadonlyCell, + unwrapReactive, } from '@glimmer/reference'; import { reifyNamed, reifyPositional } from '@glimmer/runtime'; -import { assign, EMPTY_ARRAY, keys, unwrapTemplate } from '@glimmer/util'; -import { - consumeTag, - createTag, - type DirtyableTag, - dirtyTag, - dirtyTagFor, -} from '@glimmer/validator'; +import { assign, devmode, EMPTY_ARRAY, keys, unwrapTemplate } from '@glimmer/util'; +import { consumeTag, createTag, dirtyTag, dirtyTagFor } from '@glimmer/validator'; import type { TestJitRuntimeResolver } from '../modes/jit/resolver'; import type { TestComponentConstructor } from './types'; @@ -56,14 +52,22 @@ let GUID = 1; export class EmberishCurlyComponent { public static positionalParams: string[] | string = []; - public dirtinessTag: DirtyableTag = createTag(); + public dirtinessTag: DirtyableTag = createTag( + devmode( + () => + ({ + reason: 'component', + label: ['(emberish)'], + }) satisfies TagDescription + ) + ); public declare layout: Template; public declare name: string; public tagName: Nullable = null; public attributeBindings: Nullable = null; public declare attrs: Attrs; public declare element: Element; - public declare bounds: Bounds; + public declare bounds: BlockBounds; public parentView: Nullable = null; public declare args: CapturedNamedArguments; @@ -119,7 +123,7 @@ export class EmberishCurlyComponent { export interface EmberishCurlyComponentState { component: EmberishCurlyComponent; - selfRef: Reference; + selfRef: Reactive; } const EMBERISH_CURLY_CAPABILITIES: InternalComponentCapabilities = { @@ -180,7 +184,7 @@ export class EmberishCurlyComponentManager let named = args.named.capture(); let positional = args.positional.capture(); - named[positionalParams] = createComputeRef(() => reifyPositional(positional)); + named[positionalParams] = Formula(() => reifyPositional(positional)); return { positional: EMPTY_ARRAY, named } as PreparedArguments; } else if (Array.isArray(positionalParams)) { @@ -211,11 +215,11 @@ export class EmberishCurlyComponentManager _args: VMArguments, _env: Environment, dynamicScope: DynamicScope, - callerSelf: Reference, + callerSelf: Reactive, hasDefaultBlock: boolean ): EmberishCurlyComponentState { let klass = definition || EmberishCurlyComponent; - let self = valueForRef(callerSelf); + let self = unwrapReactive(callerSelf); let args = _args.named.capture(); let attrs = reifyNamed(args); let merged = assign( @@ -235,7 +239,7 @@ export class EmberishCurlyComponentManager if (dyn) { for (let i = 0; i < dyn.length; i++) { let name = dyn[i] as string; - component.set(name, valueForRef(dynamicScope.get(name))); + component.set(name, unwrapReactive(dynamicScope.get(name))); } } @@ -248,12 +252,12 @@ export class EmberishCurlyComponentManager registerDestructor(component, () => component.destroy()); - const selfRef = createConstRef(component, 'this'); + const selfRef = ReadonlyCell(component, 'this'); return { component, selfRef }; } - getSelf({ selfRef }: EmberishCurlyComponentState): Reference { + getSelf({ selfRef }: EmberishCurlyComponentState): Reactive { return selfRef; } @@ -274,21 +278,21 @@ export class EmberishCurlyComponentManager ): void { component.element = element; - operations.setAttribute('id', createPrimitiveRef(`ember${component._guid}`), false, null); - operations.setAttribute('class', createPrimitiveRef('ember-view'), false, null); + operations.setAttribute('id', createPrimitiveCell(`ember${component._guid}`), false, null); + operations.setAttribute('class', createPrimitiveCell('ember-view'), false, null); let bindings = component.attributeBindings; if (bindings) { for (const attribute of bindings) { - let reference = childRefFor(selfRef, attribute); + let reference = getReactiveProperty(selfRef, attribute); operations.setAttribute(attribute, reference, false, null); } } } - didRenderLayout({ component }: EmberishCurlyComponentState, bounds: Bounds): void { + didRenderLayout({ component }: EmberishCurlyComponentState, bounds: BlockBounds): void { component.bounds = bounds; } diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-glimmer.ts b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-glimmer.ts index 93dc23453f..4113a9161a 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-glimmer.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-glimmer.ts @@ -1,5 +1,5 @@ -import { destroy, isDestroyed, isDestroying, registerDestructor } from '@glimmer/destroyable'; import type { Arguments, ComponentManager, Dict, Owner } from '@glimmer/interfaces'; +import { destroy, isDestroyed, isDestroying, registerDestructor } from '@glimmer/destroyable'; import { componentCapabilities, setComponentManager } from '@glimmer/manager'; import { setOwner } from '@glimmer/owner'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/styles.ts b/packages/@glimmer-workspace/integration-tests/lib/components/styles.ts new file mode 100644 index 0000000000..51d87dbf37 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/components/styles.ts @@ -0,0 +1,252 @@ +import type {ComponentBlueprint} from './types'; + +import { InvocationBuilder } from './build'; +import { CURLY_TEST_COMPONENT, GLIMMER_TEST_COMPONENT } from './types'; + +export type ComponentStyle = (blueprint: ComponentBlueprint) => { + // the name of the template + name: string; + template: string; + invocation: string; +}; + +export type BuildTemplate = (blueprint: ComponentBlueprint) => string; + +export type BuildInvocation = (blueprint: ComponentBlueprint) => { + invocation: string; + name: string; +}; + +const AngleBracketStyle = ((blueprint: ComponentBlueprint) => { + let { + args = {}, + attributes = {}, + template, + name = GLIMMER_TEST_COMPONENT, + else: elseBlock, + blockParams = [], + } = blueprint; + + const builder = InvocationBuilder.ANGLE; + + let invocation: string | string[] = []; + + invocation.push(`<${name}`); + + let componentArgs = builder.args(args); + + if (componentArgs !== '') { + invocation.push(componentArgs); + } + + let attrs = builder.attributes(attributes); + if (attrs !== '') { + invocation.push(attrs); + } + + let open = invocation.join(' '); + invocation = [open]; + + if (template) { + let block: string | string[] = []; + let params = builder.blockParams(blockParams); + + if (elseBlock) { + block.push(`><:default${params}>${template}<:else>${elseBlock}`); + } else { + block.push(`${params}>${template}`); + } + + block.push(``); + invocation.push(block.join('')); + } else { + invocation.push(' '); + invocation.push(`/>`); + } + + return { name, invocation: invocation.join('') }; +}) satisfies BuildInvocation; + +export const BuildGlimmerInvoke = (blueprint: ComponentBlueprint) => { + return AngleBracketStyle(blueprint); +}; + +export const BuildGlimmerTemplate = (blueprint: ComponentBlueprint) => { + let { tag = 'div', layout } = blueprint; + const builder = InvocationBuilder.ANGLE; + + let layoutAttrs = builder.attributes(blueprint.layoutAttributes); + + return layout.includes(`...attributes`) + ? `<${tag} ${layoutAttrs}>${layout}` + : `<${tag} ${layoutAttrs} ...attributes>${layout}`; +}; + +export const BuildGlimmerComponent = (blueprint: ComponentBlueprint) => { + let { tag = 'div', layout, name = GLIMMER_TEST_COMPONENT } = blueprint; + const builder = InvocationBuilder.ANGLE; + + let invocation = AngleBracketStyle(blueprint); + let layoutAttrs = builder.attributes(blueprint.layoutAttributes); + + return { + name, + invocation, + template: layout.includes(`...attributes`) + ? `<${tag} ${layoutAttrs}>${layout}` + : `<${tag} ${layoutAttrs} ...attributes>${layout}`, + }; +}; + +const CurlyTemplate = ( + builder: InvocationBuilder, + { + name, + template, + blockParams, + else: elseBlock, + }: { name: string; template: string; blockParams: string[]; else?: string | undefined } +) => { + let block: string[] = []; + block.push(builder.blockParams(blockParams)); + block.push('}}'); + block.push(template); + block.push(builder.else(elseBlock)); + block.push(`{{/${name}}}`); + return block.join(''); +}; + +export const BuildCurlyInvoke = ((blueprint: ComponentBlueprint) => { + const builder = InvocationBuilder.CURLIES; + + let { + args = {}, + template, + attributes, + else: elseBlock, + name = CURLY_TEST_COMPONENT, + blockParams = [], + } = blueprint; + + if (attributes) { + throw new Error('Cannot pass attributes to curly components'); + } + + let invocation: string[] | string = []; + + if (template) { + invocation.push(`{{#${name}`); + } else { + invocation.push(`{{${name}`); + } + + let componentArgs = builder.args(args); + + if (componentArgs !== '') { + invocation.push(' '); + invocation.push(componentArgs); + } + + if (template) { + invocation.push(CurlyTemplate(builder, { name, template, blockParams, else: elseBlock })); + } else { + invocation.push('}}'); + } + + return { name, invocation: invocation.join('') }; +}) satisfies BuildInvocation; + +export const BuildCurlyTemplate = (blueprint: ComponentBlueprint) => { + return blueprint.layout; +}; + +export const BuildDynamicInvoke = ((blueprint: ComponentBlueprint) => { + const builder = InvocationBuilder.CURLIES; + + let { + args = {}, + name = GLIMMER_TEST_COMPONENT, + template, + attributes, + else: elseBlock, + blockParams = [], + } = blueprint; + + if (attributes) { + throw new Error('Cannot pass attributes to curly components'); + } + + let invocation: string | string[] = []; + if (template) { + invocation.push('{{#component this.componentName'); + } else { + invocation.push('{{component this.componentName'); + } + + let componentArgs = builder.args(args); + + if (componentArgs !== '') { + invocation.push(' '); + invocation.push(componentArgs); + } + + if (template) { + invocation.push( + CurlyTemplate(builder, { name: 'component', template, blockParams, else: elseBlock }) + ); + } else { + invocation.push('}}'); + } + + return { name, invocation: invocation.join('') }; +}) satisfies BuildInvocation; + +export const BuildDynamicTemplate = (blueprint: ComponentBlueprint) => { + return blueprint.layout; +}; + +export const BuildDynamicComponent = ((blueprint: ComponentBlueprint) => { + const builder = InvocationBuilder.CURLIES; + + let { + args = {}, + layout, + template, + attributes, + else: elseBlock, + name = GLIMMER_TEST_COMPONENT, + blockParams = [], + } = blueprint; + + if (attributes) { + throw new Error('Cannot pass attributes to curly components'); + } + + let invocation: string | string[] = []; + if (template) { + invocation.push('{{#component this.componentName'); + } else { + invocation.push('{{component this.componentName'); + } + + let componentArgs = builder.args(args); + + if (componentArgs !== '') { + invocation.push(' '); + invocation.push(componentArgs); + } + + if (template) { + invocation.push( + CurlyTemplate(builder, { name: 'component', template, blockParams, else: elseBlock }) + ); + } else { + invocation.push('}}'); + } + + return { + name, + template: layout, + invocation: invocation.join(''), + }; +}) satisfies ComponentStyle; diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/types.ts b/packages/@glimmer-workspace/integration-tests/lib/components/types.ts index 15a1d61a28..c46056edc7 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/components/types.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/components/types.ts @@ -4,18 +4,19 @@ import type { TemplateOnlyComponent } from '@glimmer/runtime'; import type { EmberishCurlyComponent } from './emberish-curly'; import type { GlimmerishComponent } from './emberish-glimmer'; -export type ComponentKind = 'Glimmer' | 'Curly' | 'Dynamic' | 'TemplateOnly' | 'Custom' | 'unknown'; +export type EveryComponentKind = ComponentKind | 'Custom'; +export type ComponentKind = 'Glimmer' | 'Curly' | 'Dynamic' | 'TemplateOnly'; export interface TestComponentConstructor { new (): T; } export interface ComponentTypes { - Glimmer: typeof GlimmerishComponent; - Curly: TestComponentConstructor; - Dynamic: TestComponentConstructor; - TemplateOnly: TemplateOnlyComponent; - Custom: unknown; + glimmer: typeof GlimmerishComponent; + curly: TestComponentConstructor; + dynamic: TestComponentConstructor; + templateOnly: TemplateOnlyComponent; + custom: unknown; unknown: unknown; } diff --git a/packages/@glimmer-workspace/integration-tests/lib/dom/assertions.ts b/packages/@glimmer-workspace/integration-tests/lib/dom/assertions.ts index 98c581b07d..372d190814 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/dom/assertions.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/dom/assertions.ts @@ -1,5 +1,5 @@ import type { Dict, SimpleElement, SimpleNode } from '@glimmer/interfaces'; -import { assign, dict, isSimpleElement } from '@glimmer/util'; +import { dict, ELEMENT_NODE, isSimpleElement } from '@glimmer/util'; export interface DebugElement { element: SimpleElement | null | undefined; @@ -102,25 +102,25 @@ export function equalsElement( } // TODO: Consider removing this -interface CompatibleTagNameMap extends ElementTagNameMap { - foreignobject: SVGForeignObjectElement; -} export function assertIsElement(node: SimpleNode | null): node is SimpleElement { let nodeType = node === null ? null : node.nodeType; QUnit.assert.pushResult({ - result: nodeType === 1, + result: nodeType === ELEMENT_NODE, expected: 1, actual: nodeType, message: 'expected node to be an element', }); - return nodeType === 1; + return nodeType === ELEMENT_NODE; } -export function assertNodeTagName< - T extends keyof CompatibleTagNameMap, - U extends CompatibleTagNameMap[T] ->(node: SimpleNode | null, tagName: T): node is SimpleNode & U { +type AllTagNameMap = HTMLElementTagNameMap & + Omit; + +export function assertNodeTagName( + node: SimpleNode | null, + tagName: T +): node is SimpleNode & U { if (assertIsElement(node)) { const lowerTagName = node.tagName.toLowerCase(); const nodeTagName = node.tagName; @@ -156,22 +156,12 @@ export function equalsAttr(expected: any): Matcher { export function assertEmberishElement( element: SimpleElement, tagName: string, - attrs: Object, - contents: string -): void; -export function assertEmberishElement(element: SimpleElement, tagName: string, attrs: Object): void; -export function assertEmberishElement( - element: SimpleElement, - tagName: string, - contents: string -): void; -export function assertEmberishElement(element: SimpleElement, tagName: string): void; -export function assertEmberishElement(...args: any[]): void { - let [element, tagName, attrs, contents] = processAssertComponentArgs(args); - - let fullAttrs = assign({ class: classes('ember-view'), id: regex(/^ember\d*$/u) }, attrs); + attrs?: Dict, + contents?: string +): void { + let fullAttrs = { class: classes('ember-view'), id: regex(/^ember\d*$/u), ...attrs }; - equalsElement(element, tagName, fullAttrs, contents); + equalsElement(element, tagName, fullAttrs, contents ?? null); } export function assertSerializedInElement(result: string, expected: string, message?: string) { diff --git a/packages/@glimmer-workspace/integration-tests/lib/dom/simple-utils.ts b/packages/@glimmer-workspace/integration-tests/lib/dom/simple-utils.ts index b445cf4c54..ba7f1ddd14 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/dom/simple-utils.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/dom/simple-utils.ts @@ -10,15 +10,8 @@ import type { SimpleNode, SimpleText, } from '@glimmer/interfaces'; -import { - clearElement, - type COMMENT_NODE, - type DOCUMENT_FRAGMENT_NODE, - type DOCUMENT_NODE, - ELEMENT_NODE, - INSERT_AFTER_BEGIN, - type TEXT_NODE, -} from '@glimmer/util'; +import type { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, TEXT_NODE } from '@glimmer/util'; +import { clearElement, ELEMENT_NODE, INSERT_AFTER_BEGIN } from '@glimmer/util'; import Serializer from '@simple-dom/serializer'; import voidMap from '@simple-dom/void-map'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/helpers.ts b/packages/@glimmer-workspace/integration-tests/lib/helpers.ts index 2805b08564..dc4139a5d3 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/helpers.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/helpers.ts @@ -1,9 +1,10 @@ import type { CapturedArguments, Dict } from '@glimmer/interfaces'; -import { createComputeRef, type Reference } from '@glimmer/reference'; +import type {Reactive} from '@glimmer/reference'; +import { Formula } from '@glimmer/reference'; import { reifyNamed, reifyPositional } from '@glimmer/runtime'; export type UserHelper = (args: ReadonlyArray, named: Dict) => unknown; -export function createHelperRef(helper: UserHelper, args: CapturedArguments): Reference { - return createComputeRef(() => helper(reifyPositional(args.positional), reifyNamed(args.named))); +export function createHelperRef(helper: UserHelper, args: CapturedArguments): Reactive { + return Formula(() => helper(reifyPositional(args.positional), reifyNamed(args.named))); } diff --git a/packages/@glimmer-workspace/integration-tests/lib/index.ts b/packages/@glimmer-workspace/integration-tests/lib/index.ts new file mode 100644 index 0000000000..6c7e415c9a --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/index.ts @@ -0,0 +1,32 @@ +import './setup'; + +export * from './base-env'; +export * from './compile'; +export * from './components'; +export * from './dom/assertions'; +export * from './dom/blocks'; +export * from './dom/simple-utils'; +export * from './markers'; +export * from './matrix'; +export * from './modes/env'; +export * from './modes/jit/delegate'; +export * from './modes/jit/register'; +export * from './modes/jit/resolver'; +export * from './modes/node/env'; +export * from './modes/rehydration/delegate'; +export * from './modes/rehydration/partial-rehydration-delegate'; +export * from './render-delegate'; +export * from './render-test'; +export * from './setup-harness'; +export * from './snapshot'; +export * from './suites'; +export * from './test-decorator'; +export * from './test-helpers/constants'; +export * from './test-helpers/define'; +export * from './test-helpers/module'; +export * from './test-helpers/recorded'; +export * from './test-helpers/strings'; +export * from './test-helpers/test'; +export * from './test-helpers/tracked'; +export * from './test-helpers/tracked-object'; +export { syntaxErrorFor } from '@glimmer-workspace/test-utils'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/matrix.ts b/packages/@glimmer-workspace/integration-tests/lib/matrix.ts new file mode 100644 index 0000000000..1a0ffbb1fd --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/matrix.ts @@ -0,0 +1,228 @@ +import type { + DeclaredComponentType, + RenderDelegate, + RenderDelegateOptions, +} from '@glimmer-workspace/integration-tests'; +import { + ClientSideRenderDelegate, + ErrorRecoveryRenderDelegate, + RenderTestContext, + RenderTestState, +} from '@glimmer-workspace/integration-tests'; + +const { module, test } = QUnit; + +interface RenderDelegateClass { + readonly style: string; + new (options?: RenderDelegateOptions): RenderDelegate; +} + +export interface CoreMatrixOptions { + template: DeclaredComponentType | 'all' | undefined; + invokeAs?: DeclaredComponentType | 'all' | undefined; +} + +export type MatrixOptions = CoreMatrixOptions & + ( + | { + delegate: RenderDelegateClass; + } + | { + delegates: RenderDelegateClass[]; + } + ); + +const EXPANSIONS = { + curly: ['curly', 'dynamic'], + glimmer: ['glimmer', 'templateOnly'], + dynamic: ['dynamic'], + templateOnly: ['templateOnly'], + all: ['curly', 'glimmer', 'dynamic', 'templateOnly'], +} as const; +type EXPANSIONS = typeof EXPANSIONS; + +function expand( + test: Partial | undefined, + suite: Partial +): { + template: DeclaredComponentType; + invokeAs: DeclaredComponentType; +}[] { + const template = EXPANSIONS[test?.template ?? suite.template ?? 'all']; + // const invokeAs= EXPANSIONS[test.invokeAs ?? suite.invokeAs ]; + + return template.flatMap((t) => { + return [{ template: t, invokeAs: t }]; + // return invokeAs.map((i) => { + // return { template: t, invokeAs: i }; + // }); + }); +} + +interface TestsFn { + ( + ...args: + | [description: string, body: (context: T) => void | Promise] + | [ + options: { + type: DeclaredComponentType; + }, + description: string, + body: (context: T) => void | Promise, + ] + ): void; +} + +type ErrorsFn = ( + description: string, + spec: { + /** + * A template. The error handler is wrapped in `{{#-try}}...{{-/try}}`. + * The value is included as `{{value}}`. + */ + template: string; + /** + * The error that should be rendered in `{{value}}` when present. + */ + value: string; + /** + * The error that should be rendered in `{{value}}` when empty. Defaults to `""`. + */ + empty?: string; + } +) => void; + +interface CustomOptions { + context: new (delegate: RenderDelegate, context: RenderTestState) => T; + extends?: Matrix | Matrix[]; +} + +type MatrixFn = (options: MatrixOptions) => void; + +export class Matrix { + readonly #fn: MatrixFn; + + constructor(fn: MatrixFn) { + this.#fn = fn; + } + + client() { + this.#fn({ + delegates: [ClientSideRenderDelegate, ErrorRecoveryRenderDelegate], + template: 'all', + invokeAs: 'all', + }); + } + + test(delegate: RenderDelegateClass) { + this.#fn({ + delegate, + template: 'all', + invokeAs: 'all', + }); + } + + apply(options: MatrixOptions) { + this.#fn(options); + } +} + +export function matrix( + description: string, + define: (define: TestsFn, errors: ErrorsFn) => void +): Matrix; +export function matrix( + custom: CustomOptions, + description: string, + define: (define: TestsFn, errors: ErrorsFn) => void +): Matrix; +export function matrix( + ...args: + | [description: string, define: (define: TestsFn, errors: ErrorsFn) => void] + | [ + custom: CustomOptions, + description: string, + define: (define: TestsFn, errors: ErrorsFn) => void, + ] +): Matrix { + const [{ context: Context, extends: extendsMatrix }, description, define] = + args.length === 2 + ? [{ context: RenderTestContext } satisfies CustomOptions, ...args] + : args; + + const extendsMatrixes = extendsMatrix + ? Array.isArray(extendsMatrix) + ? extendsMatrix + : [extendsMatrix] + : []; + + return new Matrix((options: MatrixOptions) => { + const delegates = 'delegate' in options ? [options.delegate] : options.delegates; + + for (const delegate of delegates) { + for (const matrix of extendsMatrixes) { + matrix.apply({ + delegate: delegate, + template: options.template, + invokeAs: options.invokeAs, + }); + } + } + + module(description, () => { + for (const delegate of delegates) { + const TESTS: Record< + /* DeclaredComponentType */ string, + [description: string, test: (assert: Assert) => void | Promise][] + > = {}; + + const tests: TestsFn = (...args) => { + const [testOptions, description, fn] = + args.length === 2 ? ([undefined, ...args] as const) : args; + + const fullMatrix = expand({ template: testOptions?.type }, options); + + for (const row of fullMatrix) { + TESTS[row.template] ??= []; + + TESTS[row.template]?.push([ + description, + (assert) => { + const context = new Context( + new delegate(), + RenderTestState(assert, row.template) + ) as T; + return fn(context); + }, + ]); + } + }; + + define(tests, errors(tests)); + + module(`[${delegate.style} style]`, () => { + for (const [type, tests] of Object.entries(TESTS)) { + module(type, () => { + for (const [description, testFn] of tests) { + test(description, testFn); + } + }); + } + }); + } + }); + }); +} + +const errors = (spec: TestsFn) => + function errors( + description: string, + actual: { template: string; value: string; empty?: string; attribute?: boolean } + ) { + spec(`${description} (errors: initial)`, (ctx) => { + ctx.assertError(actual); + }); + spec(`${description} (errors: update)`, (ctx) => { + ctx.assertOk(actual); + }); + }; diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/delegate.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/delegate.ts index 9fbb4d3155..29caa0bb6f 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/delegate.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/delegate.ts @@ -1,71 +1,44 @@ import type { CapturedRenderNode, - CompileTimeCompilationContext, Cursor, - Dict, - DynamicScope, ElementBuilder, - ElementNamespace, Environment, HandleResult, - Helper, + JitContext, Nullable, RenderResult, RuntimeContext, SimpleDocument, - SimpleDocumentFragment, SimpleElement, - SimpleText, } from '@glimmer/interfaces'; +import type { CurriedValue, EnvironmentDelegate } from '@glimmer/runtime'; +import type { ASTPluginBuilder } from '@glimmer/syntax'; import { programCompilationContext } from '@glimmer/opcode-compiler'; import { artifacts, RuntimeOpImpl } from '@glimmer/program'; -import { createConstRef, type Reference } from '@glimmer/reference'; -import { - array, - clientBuilder, - concat, - type CurriedValue, - type EnvironmentDelegate, - fn, - get, - hash, - on, - renderComponent, - renderSync, - runtimeContext, -} from '@glimmer/runtime'; -import type { ASTPluginBuilder, PrecompileOptions } from '@glimmer/syntax'; -import { assign, castToBrowser, castToSimple, expect, unwrapTemplate } from '@glimmer/util'; +import { array, clientBuilder, concat, fn, get, hash, on, runtimeContext } from '@glimmer/runtime'; +import { assign, castToSimple, expect, unwrapTemplate } from '@glimmer/util'; + +import type { RenderDelegate, RenderDelegateOptions, WrappedTemplate } from '../../render-delegate'; +import type { Self } from '../../render-test'; import { BaseEnv } from '../../base-env'; import { preprocess } from '../../compile'; -import type { ComponentKind, ComponentTypes } from '../../components'; -import type { UserHelper } from '../../helpers'; -import type { TestModifierConstructor } from '../../modifiers'; -import type RenderDelegate from '../../render-delegate'; -import type { RenderDelegateOptions } from '../../render-delegate'; import JitCompileTimeLookup from './compilation-context'; -import { - componentHelper, - registerComponent, - registerHelper, - registerInternalHelper, - registerModifier, -} from './register'; +import { componentHelper } from './register'; import { TestJitRegistry } from './registry'; import { renderTemplate } from './render'; import { TestJitRuntimeResolver } from './resolver'; -export interface JitTestDelegateContext { +export interface TestJitContext { runtime: RuntimeContext; - program: CompileTimeCompilationContext; + program: JitContext; } export function JitDelegateContext( doc: SimpleDocument, resolver: TestJitRuntimeResolver, env: EnvironmentDelegate -): JitTestDelegateContext { +): TestJitContext { let sharedArtifacts = artifacts(); let context = programCompilationContext( sharedArtifacts, @@ -76,27 +49,38 @@ export function JitDelegateContext( return { runtime, program: context }; } -export class JitRenderDelegate implements RenderDelegate { +export class ClientSideRenderDelegate implements RenderDelegate { static readonly isEager = false; - static style = 'jit'; + static style = 'client-side'; protected registry: TestJitRegistry; protected resolver: TestJitRuntimeResolver; - private plugins: ASTPluginBuilder[] = []; - private _context: JitTestDelegateContext | null = null; - private self: Nullable = null; private doc: SimpleDocument; private env: EnvironmentDelegate; + readonly registries: TestJitRegistry[]; + readonly context: TestJitContext; + constructor({ - doc, + doc: specifiedDoc, env, resolver = (registry) => new TestJitRuntimeResolver(registry), }: RenderDelegateOptions = {}) { + const doc = specifiedDoc ?? document; + this.registry = new TestJitRegistry(); this.resolver = resolver(this.registry); - this.doc = castToSimple(doc ?? document); + this.doc = castToSimple(doc); + this.dom = { + document: this.doc, + getInitialElement: (doc) => + isBrowserTestDocument(doc) + ? castToSimple( + expect(doc.querySelector('#qunit-fixture'), 'expected #qunit-fixture to exist') + ) + : doc.createElement('div'), + } satisfies RenderDelegate['dom']; this.env = assign({}, env ?? BaseEnv); this.registry.register('modifier', 'on', on); this.registry.register('helper', 'fn', fn); @@ -104,15 +88,15 @@ export class JitRenderDelegate implements RenderDelegate { this.registry.register('helper', 'array', array); this.registry.register('helper', 'get', get); this.registry.register('helper', 'concat', concat); - } - get context(): JitTestDelegateContext { - if (this._context === null) { - this._context = JitDelegateContext(this.doc, this.resolver, this.env); - } + this.registries = [this.registry]; - return this._context; + this.context = JitDelegateContext(this.doc, this.resolver, this.env); } + dom: { + document: SimpleDocument | Document; + getInitialElement: (doc: SimpleDocument | Document) => SimpleElement; + }; getCapturedRenderTree(): CapturedRenderNode[] { return expect( @@ -121,125 +105,58 @@ export class JitRenderDelegate implements RenderDelegate { ).capture(); } - getInitialElement(): SimpleElement { - if (isBrowserTestDocument(this.doc)) { - return castToSimple(castToBrowser(this.doc).getElementById('qunit-fixture')!); - } else { - return this.createElement('div'); - } - } - - createElement(tagName: string): SimpleElement { - return this.doc.createElement(tagName); - } - - createTextNode(content: string): SimpleText { - return this.doc.createTextNode(content); - } - - createElementNS(namespace: ElementNamespace, tagName: string): SimpleElement { - return this.doc.createElementNS(namespace, tagName); - } - - createDocumentFragment(): SimpleDocumentFragment { - return this.doc.createDocumentFragment(); - } - - createCurriedComponent(name: string): CurriedValue | null { + createCurriedComponent(name: string): Nullable { return componentHelper(this.registry, name, this.context.program.constants); } - registerPlugin(plugin: ASTPluginBuilder): void { - this.plugins.push(plugin); - } - - registerComponent( - type: K, - _testType: L, - name: string, - layout: string, - Class?: ComponentTypes[K] - ): void; - registerComponent( - type: K, - _testType: L, - name: string, - layout: Nullable, - Class?: ComponentTypes[K] - ): void; - registerComponent( - type: K, - _testType: L, - name: string, - layout: Nullable, - Class?: ComponentTypes[K] - ) { - registerComponent(this.registry, type, name, layout, Class); - } - - registerModifier(name: string, ModifierClass: TestModifierConstructor): void { - registerModifier(this.registry, name, ModifierClass); - } - - registerHelper(name: string, helper: UserHelper): void { - registerHelper(this.registry, name, helper); - } - - registerInternalHelper(name: string, helper: Helper) { - registerInternalHelper(this.registry, name, helper); - } - getElementBuilder(env: Environment, cursor: Cursor): ElementBuilder { return clientBuilder(env, cursor); } - getSelf(_env: Environment, context: unknown): Reference { - if (!this.self) { - this.self = createConstRef(context, 'this'); - } - - return this.self; + wrap(template: string): WrappedTemplate { + return { template }; } - compileTemplate(template: string): HandleResult { - let compiled = preprocess(template, this.precompileOptions); + compileTemplate(template: string, plugins: ASTPluginBuilder[]): HandleResult { + let compiled = preprocess(this.wrap(template).template, { + plugins: { + ast: plugins, + }, + }); return unwrapTemplate(compiled).asLayout().compile(this.context.program); } - renderTemplate(template: string, context: Dict, element: SimpleElement): RenderResult { + renderTemplate( + rawTemplate: string, + self: Self, + element: SimpleElement, + _: () => void, + plugins: ASTPluginBuilder[] + ): RenderResult { let cursor = { element, nextSibling: null }; let { env } = this.context.runtime; - return renderTemplate( - template, - this.context, - this.getSelf(env, context), - this.getElementBuilder(env, cursor), - this.precompileOptions - ); - } + const { template, properties } = this.wrap(rawTemplate); - renderComponent( - component: object, - args: Record, - element: SimpleElement, - dynamicScope?: DynamicScope - ): RenderResult { - let cursor = { element, nextSibling: null }; - let { program, runtime } = this.context; - let builder = this.getElementBuilder(runtime.env, cursor); - let iterator = renderComponent(runtime, builder, program, {}, component, args, dynamicScope); + if (properties) self.update(properties); - return renderSync(runtime.env, iterator); + return renderTemplate(template, this.context, self.ref, this.getElementBuilder(env, cursor), { + plugins: { + ast: plugins, + }, + }); } +} + +export class ErrorRecoveryRenderDelegate extends ClientSideRenderDelegate { + static override style = 'in a no-op error recovery'; - private get precompileOptions(): PrecompileOptions { + override wrap(template: string): WrappedTemplate { return { - plugins: { - ast: this.plugins, - }, + template: `{{#-try this.errorRecoveryHandle}}${template}{{/-try}}`, + properties: { errorRecoveryHandle: () => {} }, }; } } diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/dom.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/dom.ts new file mode 100644 index 0000000000..23204f1260 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/dom.ts @@ -0,0 +1,45 @@ +import type { + ElementNamespace, + SimpleDocument, + SimpleDocumentFragment, + SimpleElement, + SimpleText, +} from '@glimmer/interfaces'; +import { castToSimple } from '@glimmer/util'; + +import type { DomDelegate } from '../../render-delegate'; +import type RenderDelegate from '../../render-delegate'; + +export class BuildDomDelegate implements DomDelegate { + readonly #document: SimpleDocument | Document; + readonly #getInitial: (doc: SimpleDocument | Document) => SimpleElement; + + constructor({ document: doc, getInitialElement: getInitial }: RenderDelegate['dom']) { + this.#document = doc; + this.#getInitial = getInitial; + } + + getInitialElement(): SimpleElement { + return this.#getInitial(this.#document); + } + + get doc() { + return castToSimple(this.#document); + } + + createElement(tagName: string): SimpleElement { + return this.doc.createElement(tagName); + } + + createTextNode(content: string): SimpleText { + return this.doc.createTextNode(content); + } + + createElementNS(namespace: ElementNamespace, tagName: string): SimpleElement { + return this.doc.createElementNS(namespace, tagName); + } + + createDocumentFragment(): SimpleDocumentFragment { + return this.doc.createDocumentFragment(); + } +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/register.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/register.ts index 41ff4ab4ad..7469dc9c84 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/register.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/register.ts @@ -5,26 +5,30 @@ import type { ResolutionTimeConstants, TemplateFactory, } from '@glimmer/interfaces'; +import type {CurriedValue} from '@glimmer/runtime'; import { getInternalComponentManager, setComponentTemplate, setInternalHelperManager, setInternalModifierManager, } from '@glimmer/manager'; -import { type CurriedValue, curry, templateOnlyComponent } from '@glimmer/runtime'; +import { curry, templateOnlyComponent } from '@glimmer/runtime'; import { CurriedTypes } from '@glimmer/vm'; +import type { ComponentTypes } from '../../components'; +import type {UserHelper} from '../../helpers'; +import type {TestModifierConstructor} from '../../modifiers'; +import type { DeclaredComponentType } from '../../test-helpers/constants'; +import type { TestJitRegistry } from './registry'; + import { createTemplate } from '../../compile'; -import type { ComponentKind, ComponentTypes } from '../../components'; import { EmberishCurlyComponent } from '../../components/emberish-curly'; import { GlimmerishComponent } from '../../components/emberish-glimmer'; -import { createHelperRef, type UserHelper } from '../../helpers'; +import { createHelperRef } from '../../helpers'; import { - type TestModifierConstructor, TestModifierDefinitionState, - TestModifierManager, + TestModifierManager } from '../../modifiers'; -import type { TestJitRegistry } from './registry'; export function registerTemplateOnlyComponent( registry: TestJitRegistry, @@ -42,7 +46,7 @@ export function registerTemplateOnlyComponent( export function registerEmberishCurlyComponent( registry: TestJitRegistry, name: string, - Component: Nullable, + Component: Nullable, layoutSource: Nullable ): void { let ComponentClass = Component || class extends EmberishCurlyComponent {}; @@ -58,7 +62,7 @@ export function registerEmberishCurlyComponent( export function registerGlimmerishComponent( registry: TestJitRegistry, name: string, - Component: Nullable, + Component: Nullable, layoutSource: Nullable ): void { if (name.indexOf('-') !== -1) { @@ -107,7 +111,7 @@ export function registerModifier( registry.register('modifier', name, state); } -export function registerComponent( +export function registerComponent( registry: TestJitRegistry, type: K, name: string, @@ -115,22 +119,17 @@ export function registerComponent( Class?: ComponentTypes[K] ): void { switch (type) { - case 'Glimmer': - registerGlimmerishComponent(registry, name, Class as ComponentTypes['Glimmer'], layout); + case 'glimmer': + registerGlimmerishComponent(registry, name, Class as ComponentTypes['glimmer'], layout); break; - case 'Curly': - registerEmberishCurlyComponent(registry, name, Class as ComponentTypes['Curly'], layout); + case 'curly': + registerEmberishCurlyComponent(registry, name, Class as ComponentTypes['curly'], layout); break; - case 'Dynamic': - registerEmberishCurlyComponent( - registry, - name, - Class as any as typeof EmberishCurlyComponent, - layout - ); + case 'dynamic': + registerEmberishCurlyComponent(registry, name, Class as ComponentTypes['dynamic'], layout); break; - case 'TemplateOnly': + case 'templateOnly': registerTemplateOnlyComponent(registry, name, layout ?? ''); break; } @@ -163,7 +162,7 @@ export function componentHelper( registry: TestJitRegistry, name: string, constants: ResolutionTimeConstants -): CurriedValue | null { +): Nullable { let definition = registry.lookupComponent(name); if (definition === null) return null; diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/render.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/render.ts index 5e6a13bd1a..3442af93dc 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/render.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/render.ts @@ -1,16 +1,17 @@ import type { ElementBuilder, RenderResult } from '@glimmer/interfaces'; -import type { Reference } from '@glimmer/reference'; -import { renderMain, renderSync } from '@glimmer/runtime'; +import type { Reactive } from '@glimmer/reference'; import type { PrecompileOptions } from '@glimmer/syntax'; +import { renderMain, renderSync } from '@glimmer/runtime'; import { unwrapTemplate } from '@glimmer/util'; +import type { TestJitContext } from './delegate'; + import { preprocess } from '../../compile'; -import type { JitTestDelegateContext } from './delegate'; export function renderTemplate( src: string, - { runtime, program }: JitTestDelegateContext, - self: Reference, + { runtime, program }: TestJitContext, + self: Reactive, builder: ElementBuilder, options?: PrecompileOptions ): RenderResult { diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts index f182e82058..b12aabddf3 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts @@ -5,11 +5,11 @@ import type { } from '@glimmer/interfaces'; import createHTMLDocument from '@simple-dom/document'; -import { assertingElement, toInnerHTML } from '../../dom/simple-utils'; -import type RenderDelegate from '../../render-delegate'; import type { RenderDelegateOptions } from '../../render-delegate'; -import { RenderTest } from '../../render-test'; -import { JitRenderDelegate } from '../jit/delegate'; + +import { toInnerHTML } from '../../dom/simple-utils'; +import { RenderTestContext } from '../../render-test'; +import { ClientSideRenderDelegate } from '../jit/delegate'; export interface NodeEnvironmentOptions { document: SimpleDocument; @@ -17,7 +17,7 @@ export interface NodeEnvironmentOptions { updateOperations?: GlimmerTreeChanges; } -export class NodeJitRenderDelegate extends JitRenderDelegate { +export class NodeJitRenderDelegate extends ClientSideRenderDelegate { static override style = 'node jit'; constructor(options: RenderDelegateOptions = {}) { @@ -26,26 +26,21 @@ export class NodeJitRenderDelegate extends JitRenderDelegate { } } -export class AbstractNodeTest extends RenderTest { - constructor(delegate: RenderDelegate) { - super(delegate); - } - +export abstract class NodeRenderTest extends RenderTestContext { override assertHTML(html: string) { let serialized = toInnerHTML(this.element); this.assert.strictEqual(serialized, html); } override assertComponent(html: string) { - let el = assertingElement(this.element.firstChild); + let el = this.assertingElement; - if (this.testType !== 'Glimmer') { + if (this.testType !== 'Glimmer' && this.testType !== 'TemplateOnly') { this.assert.strictEqual(el.getAttribute('class'), 'ember-view'); this.assert.ok(el.getAttribute('id')); this.assert.ok(el.getAttribute('id')!.indexOf('ember') > -1); } - let serialized = toInnerHTML(el); - this.assert.strictEqual(serialized, html); + this.assert.strictEqual(toInnerHTML(el), html); } } diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/delegate.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/delegate.ts index c998624378..e715ef56a0 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/delegate.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/delegate.ts @@ -1,42 +1,31 @@ import type { Cursor, - Dict, ElementBuilder, - ElementNamespace, Environment, - Helper, - Nullable, RenderResult, SimpleDocument, - SimpleDocumentFragment, SimpleElement, SimpleNode, - SimpleText, } from '@glimmer/interfaces'; +import type { Reactive } from '@glimmer/reference'; +import type { ASTPluginBuilder } from '@glimmer/syntax'; import { serializeBuilder } from '@glimmer/node'; -import { createConstRef, type Reference } from '@glimmer/reference'; -import type { ASTPluginBuilder, PrecompileOptions } from '@glimmer/syntax'; import { assign, castToSimple } from '@glimmer/util'; import createHTMLDocument from '@simple-dom/document'; +import type RenderDelegate from '../../render-delegate'; +import type { RenderDelegateOptions, WrappedTemplate } from '../../render-delegate'; +import type { Self } from '../../render-test'; +import type {TestJitContext} from '../jit/delegate'; +import type {DebugRehydrationBuilder} from './builder'; + import { BaseEnv } from '../../base-env'; -import type { ComponentKind } from '../../components'; import { replaceHTML, toInnerHTML } from '../../dom/simple-utils'; -import type { UserHelper } from '../../helpers'; -import type { TestModifierConstructor } from '../../modifiers'; -import type RenderDelegate from '../../render-delegate'; -import type { RenderDelegateOptions } from '../../render-delegate'; -import { JitDelegateContext, type JitTestDelegateContext } from '../jit/delegate'; -import { - registerComponent, - registerHelper, - registerInternalHelper, - registerModifier, -} from '../jit/register'; +import { JitDelegateContext } from '../jit/delegate'; import { TestJitRegistry } from '../jit/registry'; import { renderTemplate } from '../jit/render'; import { TestJitRuntimeResolver } from '../jit/resolver'; -import { debugRehydration, type DebugRehydrationBuilder } from './builder'; +import { debugRehydration } from './builder'; export interface RehydrationStats { clearedNodes: SimpleNode[]; @@ -46,10 +35,10 @@ export class RehydrationDelegate implements RenderDelegate { static readonly isEager = false; static readonly style = 'rehydration'; - private plugins: ASTPluginBuilder[] = []; + readonly style: string = 'rehydration'; - public clientEnv: JitTestDelegateContext; - public serverEnv: JitTestDelegateContext; + public clientEnv: TestJitContext; + public serverEnv: TestJitContext; private clientResolver: TestJitRuntimeResolver; private serverResolver: TestJitRuntimeResolver; @@ -60,42 +49,31 @@ export class RehydrationDelegate implements RenderDelegate { public clientDoc: SimpleDocument; public serverDoc: SimpleDocument; - public declare rehydrationStats: RehydrationStats; + readonly context: TestJitContext; + readonly dom: RenderDelegate['dom']; - private self: Nullable = null; + public declare rehydrationStats: RehydrationStats; constructor(options?: RenderDelegateOptions) { - let delegate = assign(options?.env ?? {}, BaseEnv); + let envDelegate = assign(options?.env ?? {}, BaseEnv); + + const clientDoc = castToSimple(document); - this.clientDoc = castToSimple(document); + this.clientDoc = clientDoc; this.clientRegistry = new TestJitRegistry(); this.clientResolver = new TestJitRuntimeResolver(this.clientRegistry); - this.clientEnv = JitDelegateContext(this.clientDoc, this.clientResolver, delegate); + this.clientEnv = JitDelegateContext(this.clientDoc, this.clientResolver, envDelegate); + + this.dom = { + document: this.clientDoc, + getInitialElement: () => this.clientDoc.createElement('div'), + }; + this.context = JitDelegateContext(clientDoc, this.clientResolver, envDelegate); this.serverDoc = createHTMLDocument(); this.serverRegistry = new TestJitRegistry(); this.serverResolver = new TestJitRuntimeResolver(this.serverRegistry); - this.serverEnv = JitDelegateContext(this.serverDoc, this.serverResolver, delegate); - } - - getInitialElement(): SimpleElement { - return this.clientDoc.createElement('div'); - } - - createElement(tagName: string): SimpleElement { - return this.clientDoc.createElement(tagName); - } - - createTextNode(content: string): SimpleText { - return this.clientDoc.createTextNode(content); - } - - createElementNS(namespace: ElementNamespace, tagName: string): SimpleElement { - return this.clientDoc.createElementNS(namespace, tagName); - } - - createDocumentFragment(): SimpleDocumentFragment { - return this.clientDoc.createDocumentFragment(); + this.serverEnv = JitDelegateContext(this.serverDoc, this.serverResolver, envDelegate); } getElementBuilder(env: Environment, cursor: Cursor): ElementBuilder { @@ -106,55 +84,52 @@ export class RehydrationDelegate implements RenderDelegate { return serializeBuilder(env, cursor); } + wrap(template: string): WrappedTemplate { + return { template }; + } + renderServerSide( template: string, - context: Dict, + self: Reactive, takeSnapshot: () => void, - element: SimpleElement | undefined = undefined + element: SimpleElement | undefined = undefined, + plugins: ASTPluginBuilder[] ): string { element = element || this.serverDoc.createElement('div'); let cursor = { element, nextSibling: null }; let { env } = this.serverEnv.runtime; // Emulate server-side render - renderTemplate( - template, - this.serverEnv, - this.getSelf(env, context), - this.getElementBuilder(env, cursor), - this.precompileOptions - ); + renderTemplate(template, this.serverEnv, self, this.getElementBuilder(env, cursor), { + plugins: { + ast: plugins, + }, + }); takeSnapshot(); return this.serialize(element); } - getSelf(_env: Environment, context: unknown): Reference { - if (!this.self) { - this.self = createConstRef(context, 'this'); - } - - return this.self; - } - serialize(element: SimpleElement): string { return toInnerHTML(element); } - renderClientSide(template: string, context: Dict, element: SimpleElement): RenderResult { + renderClientSide( + template: string, + self: Reactive, + element: SimpleElement, + plugins: ASTPluginBuilder[] + ): RenderResult { let env = this.clientEnv.runtime.env; - this.self = null; // Client-side rehydration let cursor = { element, nextSibling: null }; let builder = this.getElementBuilder(env, cursor) as DebugRehydrationBuilder; - let result = renderTemplate( - template, - this.clientEnv, - this.getSelf(env, context), - builder, - this.precompileOptions - ); + let result = renderTemplate(template, this.clientEnv, self, builder, { + plugins: { + ast: plugins, + }, + }); this.rehydrationStats = { clearedNodes: builder['clearedNodes'], @@ -165,47 +140,20 @@ export class RehydrationDelegate implements RenderDelegate { renderTemplate( template: string, - context: Dict, + self: Self, element: SimpleElement, - snapshot: () => void + snapshot: () => void, + plugins: ASTPluginBuilder[] ): RenderResult { - let serialized = this.renderServerSide(template, context, snapshot); + let serialized = this.renderServerSide(template, self.ref, snapshot, undefined, plugins); replaceHTML(element, serialized); qunitFixture().appendChild(element); - return this.renderClientSide(template, context, element); - } - - registerPlugin(plugin: ASTPluginBuilder): void { - this.plugins.push(plugin); + return this.renderClientSide(template, self.ref, element, plugins); } - registerComponent(type: ComponentKind, _testType: string, name: string, layout: string): void { - registerComponent(this.clientRegistry, type, name, layout); - registerComponent(this.serverRegistry, type, name, layout); - } - - registerHelper(name: string, helper: UserHelper): void { - registerHelper(this.clientRegistry, name, helper); - registerHelper(this.serverRegistry, name, helper); - } - - registerInternalHelper(name: string, helper: Helper) { - registerInternalHelper(this.clientRegistry, name, helper); - registerInternalHelper(this.serverRegistry, name, helper); - } - - registerModifier(name: string, ModifierClass: TestModifierConstructor): void { - registerModifier(this.clientRegistry, name, ModifierClass); - registerModifier(this.serverRegistry, name, ModifierClass); - } - - private get precompileOptions(): PrecompileOptions { - return { - plugins: { - ast: this.plugins, - }, - }; + get registries() { + return [this.clientRegistry, this.serverRegistry]; } } diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/partial-rehydration-delegate.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/partial-rehydration-delegate.ts index 1daa7cec3c..85ee35de85 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/partial-rehydration-delegate.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/rehydration/partial-rehydration-delegate.ts @@ -2,12 +2,11 @@ import type { Dict, RenderResult, SimpleElement } from '@glimmer/interfaces'; import { renderComponent, renderSync } from '@glimmer/runtime'; import type { DebugRehydrationBuilder } from './builder'; + import { RehydrationDelegate } from './delegate'; export class PartialRehydrationDelegate extends RehydrationDelegate { - registerTemplateOnlyComponent(name: string, layout: string) { - this.registerComponent('TemplateOnly', 'TemplateOnly', name, layout); - } + override readonly style = 'partial rehydration'; renderComponentClientSide( name: string, diff --git a/packages/@glimmer-workspace/integration-tests/lib/modifiers.ts b/packages/@glimmer-workspace/integration-tests/lib/modifiers.ts index 9289429ee8..5e9afd1e37 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modifiers.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modifiers.ts @@ -1,4 +1,3 @@ -import { registerDestructor } from '@glimmer/destroyable'; import type { CapturedArguments, Destroyable, @@ -8,8 +7,11 @@ import type { Owner, SimpleElement, } from '@glimmer/interfaces'; +import type {UpdatableTag} from '@glimmer/validator'; +import { registerDestructor } from '@glimmer/destroyable'; import { reifyNamed, reifyPositional } from '@glimmer/runtime'; -import { createUpdatableTag, type UpdatableTag } from '@glimmer/validator'; +import { devmode } from '@glimmer/util'; +import { createUpdatableTag } from '@glimmer/validator'; export interface TestModifierConstructor { new (): TestModifierInstance; @@ -78,7 +80,14 @@ export class TestModifierManager } export class TestModifier { - public tag = createUpdatableTag(); + public tag = createUpdatableTag( + devmode(() => ({ + kind: 'modifier', + label: ['test'], + readonly: true, + fallible: true, + })) + ); constructor( public element: SimpleElement, diff --git a/packages/@glimmer-workspace/integration-tests/lib/render-delegate.ts b/packages/@glimmer-workspace/integration-tests/lib/render-delegate.ts index a2fdd35750..6ae6160043 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/render-delegate.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/render-delegate.ts @@ -1,61 +1,87 @@ import type { Cursor, Dict, - DynamicScope, ElementBuilder, ElementNamespace, Environment, - Helper, + Optional, RenderResult, SimpleDocument, SimpleDocumentFragment, SimpleElement, SimpleText, } from '@glimmer/interfaces'; -import type { Reference } from '@glimmer/reference'; import type { EnvironmentDelegate } from '@glimmer/runtime'; import type { ASTPluginBuilder } from '@glimmer/syntax'; -import type { ComponentKind, ComponentTypes } from './components'; -import type { UserHelper } from './helpers'; +import type { TestJitContext } from './modes/jit/delegate'; import type { TestJitRegistry } from './modes/jit/registry'; import type { TestJitRuntimeResolver } from './modes/jit/resolver'; +import type { Self } from './render-test'; export interface RenderDelegateOptions { doc?: SimpleDocument | Document | undefined; - env?: EnvironmentDelegate | undefined; + env?: Optional; resolver?: (registry: TestJitRegistry) => TestJitRuntimeResolver; } -export default interface RenderDelegate { +export interface DomDelegate { getInitialElement(): SimpleElement; + createElement(tagName: string): SimpleElement; createTextNode(content: string): SimpleText; createElementNS(namespace: ElementNamespace, tagName: string): SimpleElement; createDocumentFragment(): SimpleDocumentFragment; - registerComponent( - type: K, - testType: L, - name: string, - layout: string, - Class?: ComponentTypes[K] - ): void; - registerPlugin(plugin: ASTPluginBuilder): void; - registerHelper(name: string, helper: UserHelper): void; - registerInternalHelper(name: string, helper: Helper): void; - registerModifier(name: string, klass: unknown): void; +} + +/** + * A WrappedTemplate is used to wrap tests in no-op handling (such as `{{#-try}}` error recovery). + * + * It has the wrapped template itself, as well as any properties (`this` values) needed to make the template work. + */ +export interface WrappedTemplate { + template: string; + properties?: Dict; +} + +export interface LogRender { + template: string; + self: Self; + element: SimpleElement; +} + +export default interface RenderDelegate { + // Each registered value (using the `test.register.XXX` APIs) will be registered in each of these + // registries. In rehydration tests, this causes values to be registered on both the emulated + // server and client. + readonly registries: TestJitRegistry[]; + + // The compilation and runtime contexts for the current testing environment. + readonly context: TestJitContext; + + readonly dom: { + document: SimpleDocument | Document; + getInitialElement: (doc: SimpleDocument | Document) => SimpleElement; + }; + + /** + * Optionally wrap the template in some additional code. This is used to wrap tests in no-op handling (such as + * `{{#-try}}` error recovery). + */ + wrap(template: string): WrappedTemplate; + + // Render the template into the given element. Rehydration delegates will emulate + // rendering on the server and having the contents already present in the DOM. renderTemplate( template: string, - context: Dict, + self: Self, element: SimpleElement, - snapshot: () => void - ): RenderResult; - renderComponent?( - component: object, - args: Record, - element: SimpleElement, - dynamicScope?: DynamicScope + snapshot: () => void, + plugins: ASTPluginBuilder[] ): RenderResult; + + // Get the appropriate element builder for the current environment. getElementBuilder(env: Environment, cursor: Cursor): ElementBuilder; - getSelf(env: Environment, context: unknown): Reference; } + +export type { RenderDelegate }; diff --git a/packages/@glimmer-workspace/integration-tests/lib/render-test.ts b/packages/@glimmer-workspace/integration-tests/lib/render-test.ts index 5feba341b2..471eecd922 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/render-test.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/render-test.ts @@ -1,48 +1,72 @@ -import { destroy } from '@glimmer/destroyable'; import type { - ComponentDefinitionState, Dict, DynamicScope, Helper, Maybe, Nullable, + Reactive, RenderResult, SimpleElement, SimpleNode, } from '@glimmer/interfaces'; -import { inTransaction } from '@glimmer/runtime'; import type { ASTPluginBuilder } from '@glimmer/syntax'; -import { assert, clearElement, dict, expect, isPresent, unwrap } from '@glimmer/util'; -import { dirtyTagFor } from '@glimmer/validator'; import type { NTuple } from '@glimmer-workspace/test-utils'; +import { destroy } from '@glimmer/destroyable'; +import { hasFlagWith } from '@glimmer/local-debug-flags'; +import { ReadonlyCell } from '@glimmer/reference'; +import { inTransaction, renderComponent, renderSync } from '@glimmer/runtime'; +import { clearElement, dict, expect, isPresent, LOCAL_LOGGER, unwrap } from '@glimmer/util'; +import { dirtyTagFor } from '@glimmer/validator'; -import { - type ComponentBlueprint, - type ComponentKind, - type ComponentTypes, - CURLY_TEST_COMPONENT, - GLIMMER_TEST_COMPONENT, -} from './components'; -import { assertElementShape, assertEmberishElement } from './dom/assertions'; -import { assertingElement, toInnerHTML } from './dom/simple-utils'; +import type { ComponentBlueprint, ComponentKind, ComponentTypes } from './components'; +import type { ComponentDelegate } from './components/delegate'; import type { UserHelper } from './helpers'; import type { TestModifierConstructor } from './modifiers'; -import type RenderDelegate from './render-delegate'; -import { equalTokens, isServerMarker, type NodesSnapshot, normalizeSnapshot } from './snapshot'; +import type { DomDelegate, LogRender, RenderDelegate } from './render-delegate'; +import type { NodesSnapshot } from './snapshot'; +import type { DeclaredComponentType, TypeFor } from './test-helpers/constants'; +import type { RenderTestState } from './test-helpers/module'; + +import { GLIMMER_TEST_COMPONENT } from './components'; +import { + buildInvoke, + buildTemplate, + CurlyDelegate, + DynamicDelegate, + getDelegate, + GlimmerDelegate, +} from './components/delegate'; +import { assertingElement, toInnerHTML } from './dom/simple-utils'; +import { BuildDomDelegate } from './modes/jit/dom'; +import { + registerComponent, + registerHelper, + registerInternalHelper, + registerModifier, +} from './modes/jit/register'; +import { equalTokens, isServerMarker, normalizeSnapshot } from './snapshot'; +import { KIND_FOR, TYPE_FOR } from './test-helpers/constants'; +import { Woops } from './test-helpers/error'; +import { RecordedEvents } from './test-helpers/recorded'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; type Present = Exclude; export interface IRenderTest { - readonly count: Count; - testType: ComponentKind; - beforeEach?(): void; - afterEach?(): void; + readonly testType: ComponentKind; + readonly context: RenderTestState; + readonly beforeEach?: () => void; + readonly afterEach?: () => void; } export class Count { private expected: Record = {}; private actual: Record = {}; + readonly #events: RecordedEvents; + + constructor(events: RecordedEvents) { + this.#events = events; + } expect(name: string, count = 1) { this.expected[name] = count; @@ -51,304 +75,242 @@ export class Count { } assert() { - QUnit.assert.deepEqual(this.actual, this.expected, 'TODO'); + this.#events.finalize(); + + // don't print anything if the counts match + if (QUnit.equiv(this.expected, this.actual)) return; + + QUnit.assert.deepEqual(this.actual, this.expected, "Expected and actual counts don't match"); } } -export class RenderTest implements IRenderTest { - testType: ComponentKind = 'unknown'; - - protected element: SimpleElement; - protected assert = QUnit.assert; - protected context: Dict = dict(); - protected renderResult: Nullable = null; - protected helpers = dict(); - protected snapshot: NodesSnapshot = []; - readonly count = new Count(); +export class Self { + #properties: Dict; + readonly ref: Reactive; - constructor(protected delegate: RenderDelegate) { - this.element = delegate.getInitialElement(); + constructor(properties: Dict) { + this.#properties = properties; + this.ref = ReadonlyCell(this.#properties, 'this'); } - capture() { - let instance: T; - return { - capture: (value: T) => (instance = value), - get captured(): T { - return unwrap(instance); - }, - }; + get inner(): Dict { + return this.#properties; } - registerPlugin(plugin: ASTPluginBuilder): void { - this.delegate.registerPlugin(plugin); + set(key: string, value: unknown): void { + this.#properties[key] = value; + dirtyTagFor(this.#properties, key); } - registerHelper(name: string, helper: UserHelper): void { - this.delegate.registerHelper(name, helper); + delete(key: string): void { + delete this.#properties[key]; + dirtyTagFor(this.#properties, key); } - registerInternalHelper(name: string, helper: Helper): void { - this.delegate.registerInternalHelper(name, helper); - } + initialize(properties: Dict): void { + for (const [key, value] of Object.entries(properties)) { + this.set(key, value); + } - registerModifier(name: string, ModifierClass: TestModifierConstructor): void { - this.delegate.registerModifier(name, ModifierClass); + for (const key of Object.keys(this.#properties)) { + if (!(key in properties)) { + this.delete(key); + } + } } - registerComponent( - type: K, - name: string, - layout: string, - Class?: ComponentTypes[K] - ): void { - this.delegate.registerComponent(type, this.testType, name, layout, Class); + update(properties: Dict): void { + for (const [key, value] of Object.entries(properties)) { + this.set(key, value); + } } +} - buildComponent(blueprint: ComponentBlueprint): string { - let invocation = ''; - switch (this.testType) { - case 'Glimmer': - invocation = this.buildGlimmerComponent(blueprint); - break; - case 'Curly': - invocation = this.buildCurlyComponent(blueprint); - break; - case 'Dynamic': - invocation = this.buildDynamicComponent(blueprint); - break; - case 'TemplateOnly': - invocation = this.buildTemplateOnlyComponent(blueprint); - break; +export class RenderTestContext implements IRenderTest { + readonly events = new RecordedEvents(); + readonly name?: string; - default: - throw new Error(`Invalid test type ${this.testType}`); - } + readonly self = new Self({}); + #element: SimpleElement; + readonly assert = QUnit.assert; + protected renderResult: Nullable = null; + protected helpers = dict(); + protected snapshot: NodesSnapshot = []; + readonly dom: DomDelegate; + readonly context: RenderTestState; - return invocation; + readonly plugins: ASTPluginBuilder[] = []; + readonly #delegate: ComponentDelegate; + + constructor( + protected delegate: RenderDelegate, + context: RenderTestState + ) { + this.#element = delegate.dom.getInitialElement(delegate.dom.document); + this.context = context; + this.dom = new BuildDomDelegate(delegate.dom); + + this.#delegate = getDelegate(context.types.template); } - private buildArgs(args: Dict): string { - let { testType } = this; - let sigil = ''; - let needsCurlies = false; + get element() { + return this.#element; + } - if (testType === 'Glimmer' || testType === 'TemplateOnly') { - sigil = '@'; - needsCurlies = true; - } + set element(element: SimpleElement) { + this.#element = element; + } - return `${Object.keys(args) - .map((arg) => { - let rightSide: string; - - let value = args[arg] as Maybe; - if (needsCurlies) { - let isString = value && (value[0] === "'" || value[0] === '"'); - if (isString) { - rightSide = `${value}`; - } else { - rightSide = `{{${value}}}`; - } - } else { - rightSide = `${value}`; - } + declare readonly beforeEach?: () => void; + declare readonly afterEach?: () => void; - return `${sigil}${arg}=${rightSide}`; - }) - .join(' ')}`; + get testType(): ComponentKind { + return KIND_FOR[this.context.types.template]; } - private buildBlockParams(blockParams: string[]): string { - return `${blockParams.length > 0 ? ` as |${blockParams.join(' ')}|` : ''}`; + get invoker(): ComponentDelegate { + return getDelegate(this.context.types.invoker); } - private buildElse(elseBlock: string | undefined): string { - return `${elseBlock ? `{{else}}${elseBlock}` : ''}`; + get invokeAs(): DeclaredComponentType { + return this.context.types.invoker; } - private buildAttributes(attrs: Dict = {}): string { - return Object.keys(attrs) - .map((attr) => `${attr}=${attrs[attr]}`) - .join(' '); + getInitialElement(): SimpleElement { + return this.element; } - private buildAngleBracketComponent(blueprint: ComponentBlueprint): string { - let { - args = {}, - attributes = {}, - template, - name = GLIMMER_TEST_COMPONENT, - blockParams = [], - } = blueprint; + getClearedElement(): SimpleElement { + (this.element as unknown as HTMLElement).innerHTML = ''; + return this.element; + } - let invocation: string | string[] = []; + clearElement() { + (this.element as unknown as HTMLElement).innerHTML = ''; + } - invocation.push(`<${name}`); + get assertingElement() { + return assertingElement(this.element.firstChild); + } - let componentArgs = this.buildArgs(args); + escape(text: string): string { + const textNode = this.dom.createTextNode(text); + const div = this.dom.createElement('div'); + div.appendChild(textNode); - if (componentArgs !== '') { - invocation.push(componentArgs); - } + return toInnerHTML(div); + } - let attrs = this.buildAttributes(attributes); - if (attrs !== '') { - invocation.push(attrs); - } + capture() { + let instance: T; + return { + capture: (value: T) => (instance = value), + get captured(): T { + return unwrap(instance); + }, + }; + } - let open = invocation.join(' '); - invocation = [open]; + readonly register = { + plugin: (plugin: ASTPluginBuilder) => { + this.plugins.push(plugin); + }, - if (template) { - let block: string | string[] = []; - let params = this.buildBlockParams(blockParams); - if (params !== '') { - block.push(params); + helper: (name: string, helper: UserHelper) => { + for (const registry of this.delegate.registries) { + registerHelper(registry, name, helper); } - block.push(`>`); - block.push(template); - block.push(``); - invocation.push(block.join('')); - } else { - invocation.push(' '); - invocation.push(`/>`); - } + }, - return invocation.join(''); - } + internalHelper: (name: string, helper: Helper) => { + for (const registry of this.delegate.registries) { + registerInternalHelper(registry, name, helper); + } + }, - private buildGlimmerComponent(blueprint: ComponentBlueprint): string { - let { tag = 'div', layout, name = GLIMMER_TEST_COMPONENT } = blueprint; - let invocation = this.buildAngleBracketComponent(blueprint); - let layoutAttrs = this.buildAttributes(blueprint.layoutAttributes); - this.assert.ok( - true, - `generated glimmer layout as ${`<${tag} ${layoutAttrs} ...attributes>${layout}`}` - ); - this.delegate.registerComponent( - 'Glimmer', - this.testType, - name, - `<${tag} ${layoutAttrs} ...attributes>${layout}` - ); - this.assert.ok(true, `generated glimmer invocation as ${invocation}`); - return invocation; - } + modifier: (name: string, ModifierClass: TestModifierConstructor) => { + for (const registry of this.delegate.registries) { + registerModifier(registry, name, ModifierClass); + } + }, - private buildCurlyBlockTemplate( - name: string, - template: string, - blockParams: string[], - elseBlock?: string - ): string { - let block: string[] = []; - block.push(this.buildBlockParams(blockParams)); - block.push('}}'); - block.push(template); - block.push(this.buildElse(elseBlock)); - block.push(`{{/${name}}}`); - return block.join(''); - } + component: ( + kind: K, + name: string, + layout: Nullable, + Class?: ComponentTypes[TypeFor] + ): void => { + for (const registry of this.delegate.registries) { + registerComponent(registry, TYPE_FOR[kind], name, layout, Class); + } + }, + }; - private buildCurlyComponent(blueprint: ComponentBlueprint): string { - let { - args = {}, - layout, - template, - attributes, - else: elseBlock, - name = CURLY_TEST_COMPONENT, - blockParams = [], - } = blueprint; - - if (attributes) { - throw new Error('Cannot pass attributes to curly components'); - } + readonly build = { + glimmer: (blueprint: ComponentBlueprint) => this.buildGlimmerComponent(blueprint), + curly: (blueprint: ComponentBlueprint) => this.buildCurlyComponent(blueprint), + dynamic: (blueprint: ComponentBlueprint) => this.buildDynamicComponent(blueprint), + templateOnly: (blueprint: ComponentBlueprint) => this.buildTemplateOnlyComponent(blueprint), + }; - let invocation: string[] | string = []; + buildComponent(blueprint: ComponentBlueprint): string { + switch (this.testType) { + case 'Glimmer': + return this.buildGlimmerComponent(blueprint); + case 'Curly': + return this.buildCurlyComponent(blueprint); + case 'Dynamic': + return this.buildDynamicComponent(blueprint); + case 'TemplateOnly': + return this.buildTemplateOnlyComponent(blueprint); - if (template) { - invocation.push(`{{#${name}`); - } else { - invocation.push(`{{${name}`); + default: + throw new Error(`Invalid test type ${this.testType}`); } + } - let componentArgs = this.buildArgs(args); + #buildInvoke(blueprint: ComponentBlueprint): { name: string; invocation: string } { + return buildInvoke(getDelegate(this.context.types.invoker), blueprint); + } - if (componentArgs !== '') { - invocation.push(' '); - invocation.push(componentArgs); - } + #buildComponent(templateDelegate: ComponentDelegate, blueprint: ComponentBlueprint): string { + const template = buildTemplate(templateDelegate, blueprint); + const { name, invocation } = this.#buildInvoke(blueprint); - if (template) { - invocation.push(this.buildCurlyBlockTemplate(name, template, blockParams, elseBlock)); - } else { - invocation.push('}}'); - } - this.assert.ok(true, `generated curly layout as ${layout}`); - this.delegate.registerComponent('Curly', this.testType, name, layout); - invocation = invocation.join(''); - this.assert.ok(true, `generated curly invocation as ${invocation}`); + this.register.component(this.testType, name, template); return invocation; } - private buildTemplateOnlyComponent(blueprint: ComponentBlueprint): string { - let { layout, name = GLIMMER_TEST_COMPONENT } = blueprint; - let invocation = this.buildAngleBracketComponent(blueprint); - this.assert.ok(true, `generated fragment layout as ${layout}`); - this.delegate.registerComponent('TemplateOnly', this.testType, name, `${layout}`); - this.assert.ok(true, `generated fragment invocation as ${invocation}`); - return invocation; + private buildGlimmerComponent(blueprint: ComponentBlueprint): string { + return this.#buildComponent(GlimmerDelegate, blueprint); } - private buildDynamicComponent(blueprint: ComponentBlueprint): string { - let { - args = {}, - layout, - template, - attributes, - else: elseBlock, - name = GLIMMER_TEST_COMPONENT, - blockParams = [], - } = blueprint; - - if (attributes) { - throw new Error('Cannot pass attributes to curly components'); - } - - let invocation: string | string[] = []; - if (template) { - invocation.push('{{#component this.componentName'); - } else { - invocation.push('{{component this.componentName'); - } - - let componentArgs = this.buildArgs(args); - - if (componentArgs !== '') { - invocation.push(' '); - invocation.push(componentArgs); - } - - if (template) { - invocation.push(this.buildCurlyBlockTemplate('component', template, blockParams, elseBlock)); - } else { - invocation.push('}}'); - } + private buildCurlyComponent(blueprint: ComponentBlueprint): string { + return this.#buildComponent(CurlyDelegate, blueprint); + } - this.assert.ok(true, `generated dynamic layout as ${layout}`); - this.delegate.registerComponent('Curly', this.testType, name, layout); - invocation = invocation.join(''); - this.assert.ok(true, `generated dynamic invocation as ${invocation}`); + private buildTemplateOnlyComponent(blueprint: ComponentBlueprint): string { + return this.buildGlimmerComponent(blueprint); + // let { layout, name = GLIMMER_TEST_COMPONENT } = blueprint; let invocation = + // this.buildAngleBracketComponent(blueprint); this.assert.ok(true, `generated fragment layout + // as ${layout}`); this.register.component('TemplateOnly', name, `${layout}`); + // this.assert.ok(true, `generated fragment invocation as ${invocation}`); return invocation; + } - return invocation; + private buildDynamicComponent(blueprint: ComponentBlueprint): string { + return this.#buildComponent(DynamicDelegate, blueprint); } shouldBeVoid(tagName: string) { clearElement(this.element); let html = '<' + tagName + " data-foo='bar'>

hello

"; - this.delegate.renderTemplate(html, this.context, this.element, () => this.takeSnapshot()); + this.delegate.renderTemplate( + html, + this.self, + this.element, + () => this.takeSnapshot(), + this.plugins + ); let tag = '<' + tagName + ' data-foo="bar">'; let closing = ''; @@ -363,13 +325,29 @@ export class RenderTest implements IRenderTest { }); } - render(template: string | ComponentBlueprint, properties: Dict = {}): void { - try { - QUnit.assert.ok(true, `Rendering ${template} with ${JSON.stringify(properties)}`); - } catch { - // couldn't stringify, possibly has a circular dependency - } + readonly render = { + template: (template: string | ComponentBlueprint, properties: Dict = {}) => + this.#renderTemplate(template, properties), + + component: ( + component: object, + args: Record = {}, + { + into: element = this.element, + dynamicScope, + }: { into?: SimpleElement; dynamicScope?: DynamicScope } = {} + ): void => { + let cursor = { element, nextSibling: null }; + + let { program, runtime } = this.delegate.context; + let builder = this.delegate.getElementBuilder(runtime.env, cursor); + let iterator = renderComponent(runtime, builder, program, {}, component, args, dynamicScope); + + this.renderResult = renderSync(runtime.env, iterator); + }, + }; + #renderTemplate(template: string | ComponentBlueprint, properties: Dict = {}): void { if (typeof template === 'object') { let blueprint = template; template = this.buildComponent(blueprint); @@ -379,66 +357,70 @@ export class RenderTest implements IRenderTest { } } - this.setProperties(properties); - - this.renderResult = this.delegate.renderTemplate(template, this.context, this.element, () => - this.takeSnapshot() - ); - } + this.self.initialize(properties); - renderComponent( - component: ComponentDefinitionState, - args: Dict = {}, - dynamicScope?: DynamicScope - ): void { - try { - QUnit.assert.ok(true, `Rendering ${String(component)} with ${JSON.stringify(args)}`); - } catch { - // couldn't stringify, possibly has a circular dependency - } + this.#log({ + template, + self: this.self, + element: this.element, + }); - assert( - !!this.delegate.renderComponent, - 'Attempted to render a component, but the delegate did not implement renderComponent' + this.renderResult = this.delegate.renderTemplate( + template, + this.self, + this.element, + () => this.takeSnapshot(), + this.plugins ); - - this.renderResult = this.delegate.renderComponent(component, args, this.element, dynamicScope); } - rerender(properties: Dict = {}): void { + rerender(updates?: Dict | string): void { + const properties = typeof updates === 'string' ? {} : updates ?? {}; + const message = + typeof updates === 'string' + ? updates + : updates === undefined + ? '' + : ` ${JSON.stringify(updates)}`; + try { - QUnit.assert.ok(true, `rerender ${JSON.stringify(properties)}`); + QUnit.assert.ok(true, `rerender ${message}`); } catch { // couldn't stringify, possibly has a circular dependency } - this.setProperties(properties); + this.self.update(properties); let result = expect(this.renderResult, 'the test should call render() before rerender()'); try { + this.events.record('env:begin'); result.env.begin(); result.rerender(); } finally { result.env.commit(); + this.events.record('env:commit'); } } - destroy(): void { - let result = expect(this.renderResult, 'the test should call render() before destroy()'); + #log(render: LogRender) { + const { template, properties: addedProperties } = this.delegate.wrap(render.template); - inTransaction(result.env, () => destroy(result)); - } + QUnit.assert.ok(true, `Rendering ${String(template)}`); - protected set(key: string, value: unknown): void { - this.context[key] = value; - dirtyTagFor(this.context, key); + if (hasFlagWith('enable_internals_logging', 'render')) { + const properties = { ...render.self.inner, ...addedProperties }; + LOCAL_LOGGER.groupCollapsed(`%c[render] Rendering ${template}`, 'font-weight: normal'); + LOCAL_LOGGER.debug('element ', render.element); + LOCAL_LOGGER.debug('properties', { ...render.self.inner, ...properties }); + LOCAL_LOGGER.groupEnd(); + } } - protected setProperties(properties: Dict): void { - for (let key in properties) { - this.set(key, properties[key]); - } + destroy(): void { + let result = expect(this.renderResult, 'the test should call render() before destroy()'); + + inTransaction(result.env, () => destroy(result)); } protected takeSnapshot(): NodesSnapshot { @@ -475,7 +457,70 @@ export class RenderTest implements IRenderTest { return snapshot; } - protected assertStableRerender() { + assertError( + this: This, + spec: { + template: string; + value: string; + empty?: string; + attribute?: boolean; + } + ) { + const { template, expected, error } = distill(spec); + + QUnit.assert.ok(true, `> template: ${template} <`); + QUnit.assert.ok(true, `> expected: ${expected(`contents`, this)} <`); + QUnit.assert.ok(true, `> error : ${error} <`); + + const woops = Woops.error(spec.value); + + this.render.template(template, { result: woops, handleError: woops.handleError }); + + this.assertStableHTML(error); + + this.assertUpdate('after recovering', () => woops.recover(), expected(spec.value, this)); + this.assertUpdate( + 'after emptying', + () => (woops.value = spec.empty ?? ''), + expected(spec.empty ?? '', this) + ); + this.assertUpdate('after erroring', () => (woops.isError = true), error); + this.assertUpdate( + 'after recovering directly to empty', + () => { + woops.recover(); + woops.value = spec.empty ?? ''; + }, + expected(spec.empty ?? '', this) + ); + } + + assertOk( + this: This, + spec: { + template: string; + value: string; + empty?: string; + attribute?: boolean; + } + ) { + const { template, expected, error } = distill(spec); + + const woops = Woops.noop(spec.value); + this.render.template(template, { result: woops, handleError: woops.handleError }); + + this.assertStableHTML(expected(spec.value, this)); + + this.assertUpdate('after erroring', () => (woops.isError = true), error); + this.assertUpdate('after recovering', () => woops.recover(), expected(spec.value, this)); + this.assertUpdate( + 'after emptying', + () => (woops.value = spec.empty ?? ''), + expected(spec.empty ?? '', this) + ); + } + + assertStableRerender() { this.takeSnapshot(); this.runTask(() => this.rerender()); this.assertStableNodes(); @@ -522,30 +567,30 @@ export class RenderTest implements IRenderTest { return value as Present; } - protected guardArray[], K extends string>(desc: { [P in K]: T }): { + guardArray[], K extends string>(desc: { [P in K]: T }): { [K in keyof T]: Present; }; - protected guardArray( + guardArray( desc: { [P in K]: Iterable | ArrayLike }, options: { min: N } ): Expand>>; - protected guardArray( + guardArray( desc: { [P in K]: Iterable | ArrayLike }, options: { min: N; condition: (value: T) => value is U } ): Expand>; - protected guardArray>(desc: { [P in K]: A }): Expand< + guardArray>(desc: { [P in K]: A }): Expand< NTuple> >; - protected guardArray(desc: { + guardArray(desc: { [P in K]: Iterable | ArrayLike; }): Present[]; - protected guardArray( + guardArray( desc: { [P in K]: Iterable | ArrayLike; }, options: { condition: (value: T) => value is U; min?: number } ): U[]; - protected guardArray( + guardArray( desc: Record | ArrayLike>, options?: { min?: Maybe; @@ -585,7 +630,7 @@ export class RenderTest implements IRenderTest { return array; } - protected assertHTML(html: string, elementOrMessage?: SimpleElement | string, message?: string) { + assertHTML(html: string, elementOrMessage?: SimpleElement | string, message?: string) { if (typeof elementOrMessage === 'object') { equalTokens(elementOrMessage || this.element, html, message ? `${html} (${message})` : html); } else { @@ -594,16 +639,19 @@ export class RenderTest implements IRenderTest { this.takeSnapshot(); } - protected assertComponent(content: string, attrs: Object = {}) { - let element = assertingElement(this.element.firstChild); + assertStableHTML(html: string, message?: string) { + equalTokens(this.element, { expected: html, ignore: 'comments' }, message); + this.assertStableRerender(); + } - switch (this.testType) { - case 'Glimmer': - assertElementShape(element, 'div', attrs, content); - break; - default: - assertEmberishElement(element, 'div', attrs, content); - } + assertUpdate(explanation: string, update: () => void, html: string, message?: string) { + update(); + this.rerender(explanation); + this.assertStableHTML(html, message); + } + + assertComponent(content: string, attrs: Dict = {}) { + this.#delegate.assert(this.assertingElement, 'div', attrs, content); this.takeSnapshot(); } @@ -612,7 +660,7 @@ export class RenderTest implements IRenderTest { return callback(); } - protected assertStableNodes( + assertStableNodes( { except: _except }: { except: SimpleNode | SimpleNode[] } = { except: [], } @@ -641,3 +689,42 @@ function uniq(arr: any[]) { return accum; }, []); } + +export function distill({ template }: { template: string; attribute?: boolean }) { + return { + template: createTemplate(template), + expected: createExpected(template), + error: createError(template), + }; +} + +/** + * "

{{#-try}}beforeafter{{/-try}}

" + * + * -> + * + * "

{{#-try this.handleError}}beforeafter{{/-try}}

" + */ +function createTemplate(template: string): string { + return template + .replaceAll('{{#-try}}', '{{#-try this.handleError}}') + .replaceAll('{{/-try}}', '{{/-try}}') + .replaceAll('{{{value}}}', '{{{this.result.value}}}') + .replaceAll('{{value}}', '{{this.result.value}}'); +} + +function createExpected(template: string): (value: string, ctx: RenderTestContext) => string { + return (value: string, ctx: RenderTestContext) => { + const result = template + .replaceAll('{{#-try}}', '') + .replaceAll('{{/-try}}', '') + .replaceAll('{{{value}}}', value) + .replaceAll('{{value}}', ctx.escape(value)); + + return result; + }; +} + +function createError(template: string): string { + return template.replaceAll(/\{\{#-try\}\}.*?\{\{\/-try\}\}/gu, ''); +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/setup-harness.ts b/packages/@glimmer-workspace/integration-tests/lib/setup-harness.ts index b05c2a7fd3..c532f19c04 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/setup-harness.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/setup-harness.ts @@ -1,26 +1,58 @@ /* eslint-disable no-console */ +import type { Expand } from '@glimmer/interfaces'; import { debug } from '@glimmer/validator'; import { autoRegister } from 'js-reporters'; +import { default as QUnit } from 'qunit'; export async function setupQunit() { - const qunit = await import('qunit'); + const qunitLib: QUnit = await import('qunit'); await import('qunit/qunit/qunit.css'); + const testing = Testing.withConfig( + { + id: 'smoke_tests', + label: 'Smoke Tests', + tooltip: 'Enable Smoke Tests', + }, + { + id: 'ci', + label: 'CI Mode', + tooltip: + 'CI mode emits tap output and makes tests run faster by sacrificing UI responsiveness', + }, + { + id: 'enable_internals_logging', + label: 'Log Deep Internals', + tooltip: 'Logs internals that are used in the development of the trace logs', + }, + + { + id: 'enable_trace_logging', + label: 'Trace Logs', + tooltip: 'Trace logs emit information about the internal VM state', + }, + + { + id: 'enable_subtle_logging', + label: '+ Subtle', + tooltip: + 'Subtle logs include unchanged information and other details not necessary for normal debugging', + }, + + { + id: 'enable_trace_explanations', + label: '+ Explanations', + tooltip: 'Also explain the trace logs', + } + ); + const runner = autoRegister(); - // @ts-expect-error qunit types don't expose "reporters" - const tap = qunit.reporters.tap; - tap.init(runner, { log: console.info }); - - QUnit.config.urlConfig.push({ - id: 'smoke_tests', - label: 'Enable Smoke Tests', - tooltip: 'Enable Smoke Tests', - }); - QUnit.config.urlConfig.push({ - id: 'ci', - label: 'Enable CI Mode', - tooltip: 'CI mode makes tests run faster by sacrificing UI responsiveness', + testing.begin(() => { + if (testing.config.ci) { + const tap = qunitLib.reporters.tap; + tap.init(runner, { log: console.info }); + } }); await Promise.resolve(); @@ -34,7 +66,7 @@ export async function setupQunit() { console.log(`[HARNESS] ci=${hasFlag('ci')}`); - QUnit.testStart(() => { + testing.testStart(() => { debug.resetTrackingTransaction?.(); }); @@ -52,7 +84,7 @@ export async function setupQunit() { }); let start = performance.now(); - qunit.testDone(async () => { + qunitLib.testDone(async () => { let gap = performance.now() - start; if (gap > 200) { await pause(); @@ -60,10 +92,10 @@ export async function setupQunit() { } }); - qunit.moduleDone(pause); + qunitLib.moduleDone(pause); } - qunit.done(() => { + qunitLib.done(() => { console.log('[HARNESS] done'); }); @@ -72,7 +104,74 @@ export async function setupQunit() { }; } +class Testing { + static withConfig(...configs: C): Testing> { + return new Testing(withConfig(...configs)); + } + + readonly #qunit: Q; + + constructor(qunit: Q) { + this.#qunit = qunit; + } + + get config(): Q['config'] { + return this.#qunit.config; + } + + readonly begin = (begin: (details: QUnit.BeginDetails) => void | Promise): void => { + this.#qunit.begin(begin); + }; + + readonly testStart = ( + callback: (details: QUnit.TestStartDetails) => void | Promise + ): void => { + this.#qunit.testStart(callback); + }; +} + function hasFlag(flag: string): boolean { + return hasSpecificFlag(flag); +} + +function hasSpecificFlag(flag: string): boolean { let location = typeof window !== 'undefined' && window.location; return location && new RegExp(`[?&]${flag}`).test(location.search); } + +// eslint-disable-next-line unused-imports/no-unused-vars +function getSpecificFlag(flag: string): string | undefined { + let location = typeof window !== 'undefined' && window.location; + if (!location) { + return undefined; + } + + const matches = new RegExp(`[?&]${flag}=([^&]*)`).exec(location.search); + return matches ? matches[1] : undefined; +} + +interface UrlConfig { + id: string; + label?: string | undefined; + tooltip?: string | undefined; + value?: string | string[] | { [key: string]: string } | undefined; +} + +type WithConfig = typeof QUnit & { + config: QUnit['config'] & { + [P in C[number]['id']]: string | undefined; + }; +}; + +function withConfig(...configs: C): Expand> { + for (let config of configs) { + QUnit.config.urlConfig.push(config); + } + + const index = QUnit.config.urlConfig.findIndex((c) => c.id === 'noglobals'); + if (index !== -1) { + QUnit.config.urlConfig.splice(index, 1); + } + + return QUnit as any; +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/setup.ts b/packages/@glimmer-workspace/integration-tests/lib/setup.ts index d4cbad411b..25f41bcaf7 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/setup.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/setup.ts @@ -1,11 +1,11 @@ -import setGlobalContext from '@glimmer/global-context'; import type { Destroyable, Destructor, Dict, Nullable } from '@glimmer/interfaces'; import type { IteratorDelegate } from '@glimmer/reference'; +import type { TestBase } from 'qunit'; +import setGlobalContext from '@glimmer/global-context'; import { consumeTag, dirtyTagFor, tagFor } from '@glimmer/validator'; import { scheduleDidDestroy, scheduleWillDestroy } from './base-env'; import { NativeIteratorDelegate } from './modes/env'; -import type { TestBase } from 'qunit'; let actualDeprecations: string[] = []; @@ -91,7 +91,7 @@ setGlobalContext({ getProp(obj: unknown, key: string): unknown { if (typeof obj === 'object' && obj !== null) { - consumeTag(tagFor(obj, key)); + consumeTag(tagFor(obj, key, undefined)); } return (obj as Dict)[key]; diff --git a/packages/@glimmer-workspace/integration-tests/lib/snapshot.ts b/packages/@glimmer-workspace/integration-tests/lib/snapshot.ts index 4b1e9323a7..474d71fae6 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/snapshot.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/snapshot.ts @@ -1,44 +1,58 @@ import type { Nullable, SimpleElement, SimpleNode } from '@glimmer/interfaces'; +import type { EndTag, Token, TokenType } from 'simple-html-tokenizer'; import { castToSimple, COMMENT_NODE, TEXT_NODE, unwrap } from '@glimmer/util'; -import { type EndTag, type Token, tokenize } from 'simple-html-tokenizer'; +import { tokenize } from 'simple-html-tokenizer'; import { replaceHTML, toInnerHTML } from './dom/simple-utils'; export type IndividualSnapshot = 'up' | 'down' | SimpleNode; export type NodesSnapshot = IndividualSnapshot[]; +export const DOCTYPE_TOKEN = 'Doctype' as TokenType.Doctype; +export const START_TAG_TOKEN = 'StartTag' as TokenType.StartTag; +export const END_TAG_TOKEN = 'EndTag' as TokenType.EndTag; +export const CHARS_TOKEN = 'Chars' as TokenType.Chars; +export const COMMENT_TOKEN = 'Comment' as TokenType.Comment; + export function snapshotIsNode(snapshot: IndividualSnapshot): snapshot is SimpleNode { return snapshot !== 'up' && snapshot !== 'down'; } export function equalTokens( - testFragment: SimpleElement | string | null, - testHTML: SimpleElement | string, + actual: SimpleElement | string | null, + expected: + | SimpleElement + | string + | { + expected: string | SimpleElement; + ignore: 'comments'; + }, message: Nullable = null ) { - if (testFragment === null) { + if (actual === null) { throw new Error(`Unexpectedly passed null to equalTokens`); } - const fragTokens = generateTokens(testFragment); - const htmlTokens = generateTokens(testHTML); + const { element: expectedElement, ignoreComments } = extract(expected); + const expectedTokens = generateTokens(expectedElement, ignoreComments); + const fragTokens = generateTokens(extract(actual).element, ignoreComments); cleanEmberIds(fragTokens.tokens); - cleanEmberIds(htmlTokens.tokens); + cleanEmberIds(expectedTokens.tokens); - const equiv = QUnit.equiv(fragTokens.tokens, htmlTokens.tokens); + const equiv = QUnit.equiv(fragTokens.tokens, expectedTokens.tokens); - if (equiv && fragTokens.html !== htmlTokens.html) { + if (equiv && fragTokens.html !== expectedTokens.html) { QUnit.assert.deepEqual( fragTokens.tokens, - htmlTokens.tokens, + expectedTokens.tokens, message || 'expected tokens to match' ); } else { QUnit.assert.pushResult({ - result: QUnit.equiv(fragTokens.tokens, htmlTokens.tokens), + result: QUnit.equiv(fragTokens.tokens, expectedTokens.tokens), actual: fragTokens.html, - expected: htmlTokens.html, + expected: expectedTokens.html, message: message || 'expected tokens to match', }); } @@ -84,19 +98,43 @@ export function generateSnapshot(element: SimpleElement): SimpleNode[] { return snapshot; } -function generateTokens(divOrHTML: SimpleElement | string): { tokens: Token[]; html: string } { - let div: SimpleElement; - if (typeof divOrHTML === 'string') { - div = castToSimple(document.createElement('div')); - replaceHTML(div, divOrHTML); +function extract( + expected: SimpleElement | string | { expected: string | SimpleElement; ignore: 'comments' } +): { + ignoreComments: boolean; + element: SimpleElement; +} { + if (typeof expected === 'string') { + const div = castToSimple(document.createElement('div')); + replaceHTML(div, expected); + return { + ignoreComments: false, + element: div, + }; + } else if ('nodeType' in expected) { + return { + ignoreComments: false, + element: expected, + }; } else { - div = divOrHTML; + return { + ignoreComments: true, + element: extract(expected.expected).element, + }; } +} - let tokens = tokenize(toInnerHTML(div), {}); +function generateTokens( + element: SimpleElement, + ignoreComments: boolean +): { + tokens: Token[]; + html: string; +} { + let tokens = tokenize(toInnerHTML(element), {}); tokens = tokens.reduce((tokens, token) => { - if (token.type === 'StartTag') { + if (token.type === START_TAG_TOKEN) { if (token.attributes) { token.attributes.sort((a, b) => { if (a[0] > b[0]) { @@ -116,14 +154,14 @@ function generateTokens(divOrHTML: SimpleElement | string): { tokens: Token[]; h } else { tokens.push(token); } - } else { + } else if (!ignoreComments || token.type !== COMMENT_TOKEN) { tokens.push(token); } return tokens; }, new Array()); - return { tokens, html: toInnerHTML(div) }; + return { tokens, html: toInnerHTML(element) }; } export function equalSnapshots(a: SimpleNode[], b: SimpleNode[]) { diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites.ts b/packages/@glimmer-workspace/integration-tests/lib/suites.ts index a3ecce2e83..26ca47d43f 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites.ts @@ -1,13 +1,14 @@ +import './suites/has-block'; +import './suites/has-block-params'; + export * from './suites/components'; export * from './suites/custom-dom-helper'; export * from './suites/debugger'; export * from './suites/each'; export * from './suites/emberish-components'; export * from './suites/entry-point'; -export * from './suites/has-block'; -export * from './suites/has-block-params'; export * from './suites/in-element'; -export * from './suites/initial-render'; +export * from './suites/initial-render/index'; export * from './suites/scope'; export * from './suites/shadowing'; export * from './suites/ssr'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts index 56e989173c..b6728ba6f0 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts @@ -3,19 +3,16 @@ import type { Dict, Owner } from '@glimmer/interfaces'; import { GlimmerishComponent } from '../components'; import { assertElementShape } from '../dom/assertions'; import { assertingElement } from '../dom/simple-utils'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render, suite } from '../test-decorator'; import { strip, stripTight } from '../test-helpers/strings'; import { tracked } from '../test-helpers/tracked'; -export class TemplateOnlyComponents extends RenderTest { - static suiteName = 'TemplateOnly'; - - @test({ - kind: 'templateOnly', - }) +@suite('TemplateOnly', { kind: 'templateOnly' }) +export class TemplateOnlyComponents extends RenderTestContext { + @render 'creating a new component'() { - this.render( + this.render.template( { name: 'MyComponent', layout: '{{yield}} - {{@color}}', @@ -25,29 +22,27 @@ export class TemplateOnlyComponents extends RenderTest { { color: 'red' } ); - this.assertHTML(`hello! - red`); + this.assertHTML(`
hello! - red
`); this.assertStableRerender(); this.rerender({ color: 'green' }); - this.assertHTML(`hello! - green`); + this.assertHTML(`
hello! - green
`); this.assertStableNodes(); this.rerender({ color: 'red' }); - this.assertHTML(`hello! - red`); + this.assertHTML(`
hello! - red
`); this.assertStableNodes(); } - @test({ - kind: 'templateOnly', - }) + @render 'inner ...attributes'() { - this.render( + this.render.template( { name: 'MyComponent', - layout: '
{{yield}} - {{@color}}
', + layout: '{{yield}} - {{@color}}', template: 'hello!', args: { color: 'this.color' }, - attributes: { color: '{{this.color}}' }, + attributes: { color: 'this.color' }, }, { color: 'red' } ); @@ -65,15 +60,12 @@ export class TemplateOnlyComponents extends RenderTest { } } -export class GlimmerishComponents extends RenderTest { - static suiteName = 'Glimmerish'; - - @test({ - kind: 'glimmer', - }) +@suite('Glimmerish', { kind: 'glimmer' }) +export class GlimmerishComponents extends RenderTestContext { + @render 'invoking dynamic component (named arg) via angle brackets'() { - this.registerComponent('Glimmer', 'Foo', 'hello world!'); - this.render({ + this.register.component('Glimmer', 'Foo', 'hello world!'); + this.render.template({ layout: '<@foo />', args: { foo: 'component "Foo"', @@ -84,13 +76,11 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg path) via angle brackets'() { - this.registerHelper('hash', (_positional, named) => named); - this.registerComponent('Glimmer', 'Foo', 'hello world!'); - this.render({ + this.register.helper('hash', (_positional, named) => named); + this.register.component('Glimmer', 'Foo', 'hello world!'); + this.render.template({ layout: '<@stuff.Foo />', args: { stuff: 'hash Foo=(component "Foo")', @@ -101,17 +91,15 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking curried component with attributes via angle brackets (invocation attributes clobber)'() { - this.registerHelper('hash', (_positional, named) => named); - this.registerComponent( + this.register.helper('hash', (_positional, named) => named); + this.register.component( 'Glimmer', 'Foo', '

hello world!

' ); - this.render({ + this.render.template({ layout: '<@stuff.Foo data-foo="invocation" />', args: { stuff: 'hash Foo=(component "Foo")', @@ -122,13 +110,11 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking curried component with attributes via angle brackets (invocation classes merge)'() { - this.registerHelper('hash', (_positional, named) => named); - this.registerComponent('Glimmer', 'Foo', '

hello world!

'); - this.render({ + this.register.helper('hash', (_positional, named) => named); + this.register.component('Glimmer', 'Foo', '

hello world!

'); + this.render.template({ layout: '<@stuff.Foo class="invocation" />', args: { stuff: 'hash Foo=(component "Foo")', @@ -139,16 +125,14 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg) via angle brackets supports attributes (invocation attributes clobber)'() { - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', '
hello world!
' ); - this.render({ + this.render.template({ layout: '<@foo data-test="foo"/>', args: { foo: 'component "Foo"', @@ -159,12 +143,10 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg) via angle brackets supports attributes'() { - this.registerComponent('Glimmer', 'Foo', '
hello world!
'); - this.render({ + this.register.component('Glimmer', 'Foo', '
hello world!
'); + this.render.template({ layout: '<@foo data-test="foo"/>', args: { foo: 'component "Foo"', @@ -175,12 +157,10 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg) via angle brackets supports args'() { - this.registerComponent('Glimmer', 'Foo', 'hello {{@name}}!'); - this.render({ + this.register.component('Glimmer', 'Foo', 'hello {{@name}}!'); + this.render.template({ layout: '<@foo @name="world" />', args: { foo: 'component "Foo"', @@ -191,12 +171,10 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg) via angle brackets supports passing a block'() { - this.registerComponent('Glimmer', 'Foo', 'hello {{yield}}!'); - this.render({ + this.register.component('Glimmer', 'Foo', 'hello {{yield}}!'); + this.render.template({ layout: '<@foo>world', args: { foo: 'component "Foo"', @@ -207,9 +185,7 @@ export class GlimmerishComponents extends RenderTest { this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (named arg) via angle brackets supports args and attributes'() { let instance = this.capture(); @@ -222,14 +198,14 @@ export class GlimmerishComponents extends RenderTest { this.localProperty = 'local'; } } - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', '
[{{this.localProperty}} {{@staticNamedArg}} {{@dynamicNamedArg}}]
', Foo ); - this.render( + this.render.template( { layout: stripTight`<@foo @staticNamedArg="static" data-test1={{@outerArg}} data-test2="static" @dynamicNamedArg={{@outerArg}} />`, args: { @@ -263,76 +239,64 @@ export class GlimmerishComponents extends RenderTest { ); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets'() { - this.registerComponent('Glimmer', 'Foo', 'hello world!'); - this.render(`{{#with (component 'Foo') as |Other|}}{{/with}}`); + this.register.component('Glimmer', 'Foo', 'hello world!'); + this.render.template(`{{#with (component 'Foo') as |Other|}}{{/with}}`); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local path) via angle brackets'() { - this.registerHelper('hash', (_positional, named) => named); - this.registerComponent('Glimmer', 'Foo', 'hello world!'); - this.render(`{{#with (hash Foo=(component 'Foo')) as |Other|}}{{/with}}`); + this.register.helper('hash', (_positional, named) => named); + this.register.component('Glimmer', 'Foo', 'hello world!'); + this.render.template(`{{#with (hash Foo=(component 'Foo')) as |Other|}}{{/with}}`); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets (ill-advised "htmlish element name" but supported)'() { - this.registerComponent('Glimmer', 'Foo', 'hello world!'); - this.render(`{{#with (component 'Foo') as |div|}}
{{/with}}`); + this.register.component('Glimmer', 'Foo', 'hello world!'); + this.render.template(`{{#with (component 'Foo') as |div|}}
{{/with}}`); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets supports attributes'() { - this.registerComponent('Glimmer', 'Foo', '
hello world!
'); - this.render(`{{#with (component 'Foo') as |Other|}}{{/with}}`); + this.register.component('Glimmer', 'Foo', '
hello world!
'); + this.render.template( + `{{#with (component 'Foo') as |Other|}}{{/with}}` + ); this.assertHTML(`
hello world!
`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets supports args'() { - this.registerComponent('Glimmer', 'Foo', 'hello {{@name}}!'); - this.render(`{{#with (component 'Foo') as |Other|}}{{/with}}`); + this.register.component('Glimmer', 'Foo', 'hello {{@name}}!'); + this.render.template(`{{#with (component 'Foo') as |Other|}}{{/with}}`); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets supports passing a block'() { - this.registerComponent('Glimmer', 'Foo', 'hello {{yield}}!'); - this.render(`{{#with (component 'Foo') as |Other|}}world{{/with}}`); + this.register.component('Glimmer', 'Foo', 'hello {{yield}}!'); + this.render.template(`{{#with (component 'Foo') as |Other|}}world{{/with}}`); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (local) via angle brackets supports args, attributes, and blocks'() { let instance = this.capture(); class Foo extends GlimmerishComponent { @@ -344,13 +308,13 @@ export class GlimmerishComponents extends RenderTest { this.localProperty = 'local'; } } - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', '
[{{this.localProperty}} {{@staticNamedArg}} {{@dynamicNamedArg}}] - {{yield}}
', Foo ); - this.render( + this.render.template( `{{#with (component 'Foo') as |Other|}}template{{/with}}`, { outer: 'outer' } ); @@ -378,60 +342,50 @@ export class GlimmerishComponents extends RenderTest { ); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets'() { - this.registerComponent('Glimmer', 'TestHarness', ''); - this.registerComponent('Glimmer', 'Foo', 'hello world!'); + this.register.component('Glimmer', 'TestHarness', ''); + this.register.component('Glimmer', 'Foo', 'hello world!'); - this.render(''); + this.render.template(''); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets does not work for string'() { - this.registerComponent('Glimmer', 'TestHarness', ''); - this.registerComponent('Glimmer', 'Foo', 'hello world!'); + this.register.component('Glimmer', 'TestHarness', ''); + this.register.component('Glimmer', 'Foo', 'hello world!'); this.assert.throws(() => { - this.render(''); + this.render.template(''); }, /Expected a component definition, but received Foo. You may have accidentally done , where "this.args.Foo" was a string instead of a curried component definition. You must either use the component definition directly, or use the \{\{component\}\} helper to create a curried component definition when invoking dynamically/u); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets with named block'() { - this.registerComponent( + this.register.component( 'Glimmer', 'TestHarness', '<:bar>Stuff!' ); - this.registerComponent('Glimmer', 'Foo', '{{yield to="bar"}}'); + this.register.component('Glimmer', 'Foo', '{{yield to="bar"}}'); - this.render(''); + this.render.template(''); this.assertHTML(`Stuff!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets does not support implicit `this` fallback'() { this.assert.throws(() => { - this.registerComponent('TemplateOnly', 'Test', ''); + this.register.component('TemplateOnly', 'Test', ''); }, /stuff is not in scope/u); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets supports attributes'() { class TestHarness extends GlimmerishComponent { public Foo: any; @@ -441,17 +395,15 @@ export class GlimmerishComponents extends RenderTest { this.Foo = args['Foo']; } } - this.registerComponent('Glimmer', 'TestHarness', '', TestHarness); - this.registerComponent('Glimmer', 'Foo', '
hello world!
'); - this.render(''); + this.register.component('Glimmer', 'TestHarness', '', TestHarness); + this.register.component('Glimmer', 'Foo', '
hello world!
'); + this.render.template(''); this.assertHTML(`
hello world!
`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets supports args'() { class TestHarness extends GlimmerishComponent { public Foo: any; @@ -461,17 +413,15 @@ export class GlimmerishComponents extends RenderTest { this.Foo = args['Foo']; } } - this.registerComponent('Glimmer', 'TestHarness', '', TestHarness); - this.registerComponent('Glimmer', 'Foo', 'hello {{@name}}!'); - this.render(''); + this.register.component('Glimmer', 'TestHarness', '', TestHarness); + this.register.component('Glimmer', 'Foo', 'hello {{@name}}!'); + this.render.template(''); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets supports passing a block'() { class TestHarness extends GlimmerishComponent { public Foo: any; @@ -481,17 +431,15 @@ export class GlimmerishComponents extends RenderTest { this.Foo = args['Foo']; } } - this.registerComponent('Glimmer', 'TestHarness', 'world', TestHarness); - this.registerComponent('Glimmer', 'Foo', 'hello {{yield}}!'); - this.render(''); + this.register.component('Glimmer', 'TestHarness', 'world', TestHarness); + this.register.component('Glimmer', 'Foo', 'hello {{yield}}!'); + this.render.template(''); this.assertHTML(`hello world!`); this.assertStableRerender(); } - @test({ - kind: 'glimmer', - }) + @render 'invoking dynamic component (path) via angle brackets supports args, attributes, and blocks'() { let instance = this.capture(); @@ -513,19 +461,19 @@ export class GlimmerishComponents extends RenderTest { this.localProperty = 'local'; } } - this.registerComponent( + this.register.component( 'Glimmer', 'TestHarness', 'template', TestHarness ); - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', '
[{{this.localProperty}} {{@staticNamedArg}} {{@dynamicNamedArg}}] - {{yield}}
', Foo ); - this.render('', { + this.render.template('', { outer: 'outer', }); @@ -552,126 +500,126 @@ export class GlimmerishComponents extends RenderTest { ); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can pass forward ...attributes to a nested component'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can allow invocation side to override attributes with ...attributes'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can allow invocation side to override the type attribute with ...attributes'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can override invocation side attributes with ...attributes'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can override invocation side type attribute with ...attributes'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can forward classes before ...attributes to a nested component'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render 'angle bracket invocation can forward classes after ...attributes to a nested component'() { - this.registerComponent('Glimmer', 'Qux', '
'); - this.registerComponent('Glimmer', 'Bar', ''); - this.registerComponent('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Qux', '
'); + this.register.component('Glimmer', 'Bar', ''); + this.register.component('Glimmer', 'Foo', ''); - this.render(''); + this.render.template(''); this.assertHTML('
'); } - @test({ kind: 'glimmer' }) + @render '[BUG: #644 popping args should be balanced]'() { class MainComponent extends GlimmerishComponent { salutation = 'Glimmer'; } - this.registerComponent( + this.register.component( 'Glimmer', 'Main', '
', MainComponent ); - this.registerComponent('Glimmer', 'HelloWorld', '

Hello {{@name}}!

'); - this.render('
'); + this.register.component('Glimmer', 'HelloWorld', '

Hello {{@name}}!

'); + this.render.template('
'); this.assertHTML('

Hello Glimmer!

'); } - @test({ kind: 'glimmer' }) + @render 'Only one arg reference is created per argument'() { let count = 0; - this.registerHelper('count', () => count++); + this.register.helper('count', () => count++); class MainComponent extends GlimmerishComponent { salutation = 'Glimmer'; } - this.registerComponent( + this.register.component( 'Glimmer', 'Main', '
', MainComponent ); - this.registerComponent('Glimmer', 'Child', '{{@value}} {{this.args.value}}'); - this.render('
'); + this.register.component('Glimmer', 'Child', '{{@value}} {{this.args.value}}'); + this.render.template('
'); this.assertHTML('
0 0
'); } - @test({ kind: 'glimmer' }) + @render '[BUG] Gracefully handles application of curried args when invoke starts with 0 args'() { class MainComponent extends GlimmerishComponent { salutation = 'Glimmer'; } - this.registerComponent( + this.register.component( 'Glimmer', 'Main', '
{{wat}}
', MainComponent ); - this.registerComponent('Glimmer', 'HelloWorld', '{{yield (component "A" a=@a)}}'); - this.registerComponent('Glimmer', 'A', 'A {{@a}}'); - this.render('
', { a: 'a' }); + this.register.component('Glimmer', 'HelloWorld', '{{yield (component "A" a=@a)}}'); + this.register.component('Glimmer', 'A', 'A {{@a}}'); + this.render.template('
', { a: 'a' }); this.assertHTML('
A a
'); this.assertStableRerender(); this.rerender({ a: 'A' }); @@ -679,15 +627,15 @@ export class GlimmerishComponents extends RenderTest { this.assertStableNodes(); } - @test({ kind: 'glimmer' }) + @render 'Static block component helper'() { - this.registerComponent( + this.register.component( 'Glimmer', 'A', 'A {{#component "B" arg1=@one arg2=@two arg3=@three}}{{/component}}' ); - this.registerComponent('Glimmer', 'B', 'B {{@arg1}} {{@arg2}} {{@arg3}}'); - this.render('', { + this.register.component('Glimmer', 'B', 'B {{@arg1}} {{@arg2}} {{@arg3}}'); + this.render.template('', { first: 1, second: 2, third: 3, @@ -699,11 +647,11 @@ export class GlimmerishComponents extends RenderTest { this.assertStableNodes(); } - @test({ kind: 'glimmer' }) + @render 'Static inline component helper'() { - this.registerComponent('Glimmer', 'A', 'A {{component "B" arg1=@one arg2=@two arg3=@three}}'); - this.registerComponent('Glimmer', 'B', 'B {{@arg1}} {{@arg2}} {{@arg3}}'); - this.render('', { + this.register.component('Glimmer', 'A', 'A {{component "B" arg1=@one arg2=@two arg3=@three}}'); + this.register.component('Glimmer', 'B', 'B {{@arg1}} {{@arg2}} {{@arg3}}'); + this.render.template('', { first: 1, second: 2, third: 3, @@ -715,14 +663,14 @@ export class GlimmerishComponents extends RenderTest { this.assertStableNodes(); } - @test({ kind: 'glimmer' }) + @render 'top level in-element'() { - this.registerComponent('Glimmer', 'Foo', ''); - this.registerComponent('Glimmer', 'Bar', '
Hello World
'); + this.register.component('Glimmer', 'Foo', ''); + this.register.component('Glimmer', 'Bar', '
Hello World
'); - let el = this.delegate.getInitialElement(); + let el = this.getInitialElement(); - this.render( + this.render.template( strip` {{#each this.components key="id" as |c|}} {{#in-element c.mount}} @@ -740,7 +688,7 @@ export class GlimmerishComponents extends RenderTest { assertElementShape(first, 'div', { 'data-bar': 'Bar' }, 'Hello World'); } - @test({ kind: 'glimmer' }) + @render 'recursive component invocation'() { let counter = 0; @@ -757,20 +705,20 @@ export class GlimmerishComponents extends RenderTest { } } - this.registerComponent( + this.register.component( 'Glimmer', 'RecursiveInvoker', '{{this.id}}{{#if this.showChildren}}{{/if}}', RecursiveInvoker ); - this.render(''); + this.render.template(''); this.assertHTML('123'); } - @test({ kind: 'templateOnly' }) + @render('templateOnly') 'throwing an error during component construction does not put result into a bad state'() { - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', 'Hello', @@ -782,7 +730,7 @@ export class GlimmerishComponents extends RenderTest { } ); - this.render('{{#if this.showing}}{{/if}}', { + this.render.template('{{#if this.showing}}{{/if}}', { showing: false, }); @@ -796,9 +744,9 @@ export class GlimmerishComponents extends RenderTest { this.assertHTML('', 'destroys correctly'); } - @test({ kind: 'templateOnly' }) + @render('templateOnly') 'throwing an error during component construction does not put result into a bad state with multiple prior nodes'() { - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', 'Hello', @@ -810,7 +758,7 @@ export class GlimmerishComponents extends RenderTest { } ); - this.render( + this.render.template( '{{#if this.showing}}
{{/if}}', { showing: false, @@ -830,9 +778,9 @@ export class GlimmerishComponents extends RenderTest { this.assertHTML('', 'destroys correctly'); } - @test({ kind: 'templateOnly' }) + @render('templateOnly') 'throwing an error during component construction does not put result into a bad state with nested components'() { - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', 'Hello', @@ -844,9 +792,9 @@ export class GlimmerishComponents extends RenderTest { } ); - this.registerComponent('TemplateOnly', 'Bar', '
'); + this.register.component('TemplateOnly', 'Bar', '
'); - this.render('{{#if this.showing}}
{{/if}}', { + this.render.template('{{#if this.showing}}
{{/if}}', { showing: false, }); @@ -863,7 +811,7 @@ export class GlimmerishComponents extends RenderTest { this.assertHTML('', 'destroys correctly'); } - @test({ kind: 'templateOnly' }) + @render('templateOnly') 'throwing an error during rendering gives a readable error stack'(assert: Assert) { // eslint-disable-next-line no-console let originalConsoleError = console.error; @@ -879,7 +827,7 @@ export class GlimmerishComponents extends RenderTest { try { assert.expect(7); - this.registerComponent( + this.register.component( 'Glimmer', 'Foo', 'Hello', @@ -891,9 +839,9 @@ export class GlimmerishComponents extends RenderTest { } ); - this.registerComponent('TemplateOnly', 'Bar', '
'); + this.register.component('TemplateOnly', 'Bar', '
'); - this.render('{{#if this.showing}}
{{/if}}', { + this.render.template('{{#if this.showing}}
{{/if}}', { showing: false, }); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts index abf2623dfc..1a31f88c9c 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts @@ -1,18 +1,18 @@ -import { precompile } from '@glimmer/compiler'; import type { Cursor, ElementBuilder, Environment } from '@glimmer/interfaces'; +import { precompile } from '@glimmer/compiler'; import { NodeDOMTreeConstruction, serializeBuilder } from '@glimmer/node'; +import { RenderTestContext } from '@glimmer-workspace/integration-tests'; import { blockStack } from '../dom/blocks'; import { toInnerHTML } from '../dom/simple-utils'; -import { AbstractNodeTest, NodeJitRenderDelegate } from '../modes/node/env'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { NodeJitRenderDelegate, NodeRenderTest } from '../modes/node/env'; +import { render } from '../test-decorator'; import { strip } from '../test-helpers/strings'; -export class DOMHelperTests extends AbstractNodeTest { +export class DOMHelperTests extends NodeRenderTest { static suiteName = 'Server-side rendering in Node.js (normal)'; - @test + @render 'can instantiate NodeDOMTreeConstruction without a document'() { // this emulates what happens in Ember when using `App.visit('/', { shouldRender: false });` @@ -22,10 +22,10 @@ export class DOMHelperTests extends AbstractNodeTest { } } -export class CompilationTests extends RenderTest { +export class CompilationTests extends RenderTestContext { static suiteName = 'Id generation'; - @test + @render 'generates id in node'() { let template = precompile('hello'); let obj = JSON.parse(template); @@ -47,9 +47,9 @@ export class JitSerializationDelegate extends NodeJitRenderDelegate { export class SerializedDOMHelperTests extends DOMHelperTests { static override suiteName = 'Server-side rendering in Node.js (serialize)'; - @test + @render 'The compiler can handle unescaped HTML'() { - this.render('
{{{this.title}}}
', { title: 'hello' }); + this.render.template('
{{{this.title}}}
', { title: 'hello' }); let b = blockStack(); this.assertHTML(strip`
@@ -62,10 +62,10 @@ export class SerializedDOMHelperTests extends DOMHelperTests { `); } - @test + @render 'Unescaped helpers render correctly'() { - this.registerHelper('testing-unescaped', (params) => params[0]); - this.render('{{{testing-unescaped "hi"}}}'); + this.register.helper('testing-unescaped', (params) => params[0]); + this.render.template('{{{testing-unescaped "hi"}}}'); let b = blockStack(); this.assertHTML(strip` ${b(1)} @@ -76,15 +76,15 @@ export class SerializedDOMHelperTests extends DOMHelperTests { `); } - @test + @render 'Null literals do not have representation in DOM'() { - this.render('{{null}}'); + this.render.template('{{null}}'); this.assertHTML(strip``); } - @test + @render 'Elements inside a yielded block'() { - this.render('{{#if true}}
123
{{/if}}'); + this.render.template('{{#if true}}
123
{{/if}}'); let b = blockStack(); this.assertHTML(strip` ${b(1)} @@ -93,9 +93,9 @@ export class SerializedDOMHelperTests extends DOMHelperTests { `); } - @test + @render 'A simple block helper can return text'() { - this.render('{{#if true}}test{{else}}not shown{{/if}}'); + this.render.template('{{#if true}}test{{else}}not shown{{/if}}'); let b = blockStack(); this.assertHTML(strip` ${b(1)} diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/debugger.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/debugger.ts index 1b3e22cb37..6f24d03f1b 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/debugger.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/debugger.ts @@ -1,16 +1,16 @@ import { resetDebuggerCallback, setDebuggerCallback } from '@glimmer/runtime'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; -export class DebuggerSuite extends RenderTest { +export class DebuggerSuite extends RenderTestContext { static suiteName = 'Debugger'; - afterEach() { + override readonly afterEach = () => { resetDebuggerCallback(); - } + }; - @test + @render 'basic debugger statement'() { let expectedContext = { foo: 'bar', @@ -26,7 +26,7 @@ export class DebuggerSuite extends RenderTest { this.assert.strictEqual(get('foo'), expectedContext.foo); }); - this.render( + this.render.template( '{{#if this.a.b}}true{{debugger}}{{else}}false{{debugger}}{{/if}}', expectedContext ); @@ -57,7 +57,7 @@ export class DebuggerSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'can get locals'() { let expectedContext = { foo: 'bar', @@ -74,7 +74,7 @@ export class DebuggerSuite extends RenderTest { this.assert.deepEqual(get('this'), context); }); - this.render( + this.render.template( '{{#with this.foo as |bar|}}{{#if this.a.b}}true{{debugger}}{{else}}false{{debugger}}{{/if}}{{/with}}', expectedContext ); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/each.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/each.ts index 8726ccb9a4..3c65b0cf4d 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/each.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/each.ts @@ -1,32 +1,36 @@ +import type { TagDescription } from '@glimmer/interfaces/index'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; -import { beginTestSteps, endTestSteps, verifySteps } from '@glimmer/util'; +import { beginTestSteps, devmode, endTestSteps, verifySteps } from '@glimmer/util'; import { consumeTag, createTag, dirtyTag } from '@glimmer/validator'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; import { tracked } from '../test-helpers/tracked'; -export class EachSuite extends RenderTest { +export class EachSuite extends RenderTestContext { static suiteName = '#each'; - beforeEach() { + override readonly beforeEach = () => { if (LOCAL_DEBUG) { beginTestSteps?.(); } - } + }; - afterEach() { + override readonly afterEach = () => { if (LOCAL_DEBUG) { endTestSteps?.(); } - } + }; - @test + @render 'basic #each'() { let list = [1, 2, 3, 4]; - this.render('{{#each this.list key="@index" as |item|}}{{item}}{{else}}Empty{{/each}}', { - list, - }); + this.render.template( + '{{#each this.list key="@index" as |item|}}{{item}}{{else}}Empty{{/each}}', + { + list, + } + ); this.assertHTML('1234'); this.assertStableRerender(); @@ -46,7 +50,7 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'autotracked custom iterable'() { if (typeof Symbol !== 'function') { QUnit.assert.ok(true, 'skipping platform without iterable'); @@ -55,7 +59,15 @@ export class EachSuite extends RenderTest { let list = { arr: [1, 2, 3, 4], - tag: createTag(), + tag: createTag( + devmode( + () => + ({ + reason: 'cell', + label: ['list'], + }) satisfies TagDescription + ) + ), [Symbol.iterator]() { consumeTag(this.tag); @@ -72,9 +84,12 @@ export class EachSuite extends RenderTest { this.arr.splice(0, this.arr.length); }, }; - this.render('{{#each this.list key="@index" as |item|}}{{item}}{{else}}Empty{{/each}}', { - list, - }); + this.render.template( + '{{#each this.list key="@index" as |item|}}{{item}}{{else}}Empty{{/each}}', + { + list, + } + ); this.assertHTML('1234'); this.assertStableRerender(); @@ -94,12 +109,15 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'keyed #each'() { let list = [{ text: 'hello' }]; - this.render('{{#each this.list key="text" as |item|}}{{item.text}}{{else}}Empty{{/each}}', { - list, - }); + this.render.template( + '{{#each this.list key="text" as |item|}}{{item.text}}{{else}}Empty{{/each}}', + { + list, + } + ); this.assertHTML('hello'); this.assertStableRerender(); @@ -120,10 +138,21 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render + 'an empty list'() { + let list: number[] = []; + this.render.template( + '{{#each this.list key="@index" as |item|}}{{item}}{{else}}Empty{{/each}}', + { list } + ); + this.assertHTML('Empty'); + this.assertStableRerender(); + } + + @render 'receives the index as the second parameter'() { let list = [1, 2, 3, 4]; - this.render( + this.render.template( '{{#each this.list key="@index" as |item i|}}{{item}}-{{i}}:{{else}}Empty{{/each}}', { list, @@ -148,7 +177,7 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'receives the index as the second parameter (when key=@identity)'() { let v1 = val(1); let v2 = val(2); @@ -158,7 +187,7 @@ export class EachSuite extends RenderTest { let v6 = val(6); let list = [v1, v2, v3, v4]; - this.render( + this.render.template( '{{#each this.list key="@identity" as |item i|}}{{item.val}}-{{i}}{{else}}Empty{{/each}}', { list, @@ -188,10 +217,10 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'it can render duplicate primitive items'() { let list = ['a', 'a', 'a']; - this.render('{{#each this.list key="@index" as |item|}}{{item}}{{/each}}', { + this.render.template('{{#each this.list key="@index" as |item|}}{{item}}{{/each}}', { list, }); this.assertHTML('aaa'); @@ -208,11 +237,11 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'it can render duplicate objects'() { let dup = { text: 'dup' }; let list = [dup, dup, { text: 'uniq' }]; - this.render('{{#each this.list key="@index" as |item|}}{{item.text}}{{/each}}', { + this.render.template('{{#each this.list key="@index" as |item|}}{{item.text}}{{/each}}', { list, }); this.assertHTML('dupdupuniq'); @@ -229,7 +258,7 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'it renders all items with duplicate key values'() { class Item { @tracked text: string; @@ -241,7 +270,7 @@ export class EachSuite extends RenderTest { let list = [new Item('Hello'), new Item('Hello'), new Item('Hello')]; - this.render(`{{#each this.list key="text" as |item|}}{{item.text}}{{/each}}`, { + this.render.template(`{{#each this.list key="text" as |item|}}{{item.text}}{{/each}}`, { list, }); @@ -261,7 +290,7 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'it updates items if their key has not changed, and the items are tracked'() { class Item { @tracked public text: string; @@ -273,7 +302,7 @@ export class EachSuite extends RenderTest { let list = [new Item('Hello'), new Item('Hello'), new Item('Hello')]; - this.render(`{{#each this.list key="@identity" as |item|}}{{item.text}}{{/each}}`, { + this.render.template(`{{#each this.list key="@identity" as |item|}}{{item.text}}{{/each}}`, { list, }); @@ -293,11 +322,11 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'it does not update items if their key has not changed, and the items are not tracked'() { let list = [{ text: 'Hello' }, { text: 'Hello' }, { text: 'Hello' }]; - this.render(`{{#each this.list key="@identity" as |item|}}{{item.text}}{{/each}}`, { + this.render.template(`{{#each this.list key="@identity" as |item|}}{{item.text}}{{/each}}`, { list, }); @@ -311,11 +340,11 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'scoped variable not available outside list'() { let list = ['Wycats']; - this.render( + this.render.template( `{{this.name}}-{{#each this.list key="@index" as |name|}}{{name}}{{/each}}-{{this.name}}`, { list, @@ -342,11 +371,11 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'else template is displayed with context'() { let list: string[] = []; - this.render( + this.render.template( `{{#each this.list key="@index" as |name|}}Has thing{{else}}No thing {{this.otherThing}}{{/each}}`, { list, @@ -371,12 +400,12 @@ export class EachSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'When re-iterated via swap #1, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[1]; let b = arr[7]; @@ -401,12 +430,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #2, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[0]; let b = arr[7]; @@ -431,12 +460,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #3, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[0]; let b = arr[6]; @@ -461,12 +490,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #4, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[1]; let b = arr[3]; @@ -495,12 +524,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #5, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[1]; let b = arr[3]; @@ -527,12 +556,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #6, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = numbers(); - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let a = arr[1]; let b = arr[6]; @@ -560,12 +589,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #7, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); arr.shift(); arr.splice(2, 0, 9); @@ -589,12 +618,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #8, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let shifted = [8, 1, 2, 3, 4, 5, 6, 7]; @@ -616,12 +645,12 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #9, the original references are updated'() { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); let shifted = [2, 3, 4, 5, 6, 7, 8, 1]; @@ -643,18 +672,17 @@ export class EachSuite extends RenderTest { ); } - @test + @render 'When re-iterated via swap #10, the original references are updated'(assert: Assert) { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); for (let i = 0; i < 100; i++) { shuffleArray(arr); this.rerender({ arr }); - verifySteps?.('list-updates', (steps) => { let stats = getStepStats(steps as ListStep[]); @@ -668,12 +696,12 @@ export class EachSuite extends RenderTest { } } - @test + @render 'When re-iterated via swap #11, the original references are updated'(assert: Assert) { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); for (let i = 0; i < 100; i++) { let newArr = arr.slice(); @@ -681,7 +709,6 @@ export class EachSuite extends RenderTest { let semiArr = newArr.slice(0, 5); this.rerender({ arr: semiArr }); - verifySteps?.('list-updates', (steps) => { let stats = getStepStats(steps as ListStep[]); @@ -695,12 +722,12 @@ export class EachSuite extends RenderTest { } } - @test + @render 'When re-iterated via swap #12, the original references are updated'(assert: Assert) { if (!LOCAL_DEBUG) return; let arr = [1, 2, 3, 4, 5, 6, 7, 8]; - this.render(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); + this.render.template(`{{#each this.arr as |item|}}{{item}}{{/each}}`, { arr }); for (let i = 0; i < 100; i++) { let newArr = arr.slice(); @@ -708,7 +735,6 @@ export class EachSuite extends RenderTest { let semiArr = newArr.slice(0, 5).concat([11, 12]); this.rerender({ arr: semiArr }); - verifySteps?.('list-updates', (steps) => { let stats = getStepStats(steps as ListStep[]); @@ -722,16 +748,19 @@ export class EachSuite extends RenderTest { } } - @test + @render 're-iterating nested arrays works'() { let arr = [ [1, 2, 3, 4, 5], [4, 5, 6, 7, 8], [5, 6, 7, 8, 9], ]; - this.render(`{{#each this.arr as |sub|}}{{#each sub as |item|}}{{item}}{{/each}}{{/each}}`, { - arr, - }); + this.render.template( + `{{#each this.arr as |sub|}}{{#each sub as |item|}}{{item}}{{/each}}{{/each}}`, + { + arr, + } + ); for (let i = 0; i < 100; i++) { for (let sub of arr) { @@ -791,6 +820,6 @@ function numbers() { number, number, number, - number + number, ]; } diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/emberish-components.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/emberish-components.ts index 1a57c8c142..aeef622ed3 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/emberish-components.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/emberish-components.ts @@ -1,24 +1,28 @@ import type { SimpleElement } from '@glimmer/interfaces'; +import { unwrap } from '@glimmer/util'; + +import type { RenderTestState } from '../test-helpers/module'; import { EmberishCurlyComponent } from '../components'; import { assertEmberishElement, classes } from '../dom/assertions'; import { assertingElement, toInnerHTML } from '../dom/simple-utils'; -import { type Count, RenderTest } from '../render-test'; +import { RenderTestContext } from '../render-test'; import { equalTokens } from '../snapshot'; -import { test } from '../test-decorator'; -import { unwrap } from '@glimmer/util'; +import { render, suite } from '../test-decorator'; +import { defineComponent } from '../test-helpers/define'; -export class EmberishComponentTests extends RenderTest { - static suiteName = 'Emberish'; +@suite('Emberish', 'curly') +export class EmberishComponentTests extends RenderTestContext { + @render + 'Element modifier with hooks'(assert: RenderTestState) { + const { events } = assert; - @test - 'Element modifier with hooks'(assert: Assert, count: Count) { - this.registerModifier( + this.register.modifier( 'foo', class { element?: SimpleElement; didInsertElement() { - count.expect('didInsertElement'); + events.record('didInsertElement'); assert.ok(this.element, 'didInsertElement'); assert.strictEqual( unwrap(this.element).getAttribute('data-ok'), @@ -28,29 +32,34 @@ export class EmberishComponentTests extends RenderTest { } didUpdate() { - count.expect('didUpdate'); + events.record('didUpdate'); assert.ok(true, 'didUpdate'); } willDestroyElement() { - count.expect('willDestroyElement'); + events.record('willDestroyElement'); assert.ok(true, 'willDestroyElement'); } } ); - this.render('{{#if this.ok}}
{{/if}}', { + this.render.template('{{#if this.ok}}
{{/if}}', { bar: 'bar', ok: true, }); + events.expect(['didInsertElement']); + this.rerender({ bar: 'foo' }); + events.expect(['didUpdate']); + this.rerender({ ok: false }); + events.expect(['willDestroyElement']); } - @test + @render 'non-block without properties'() { - this.render({ + this.render.template({ layout: 'In layout', }); @@ -58,9 +67,9 @@ export class EmberishComponentTests extends RenderTest { this.assertStableRerender(); } - @test + @render 'block without properties'() { - this.render({ + this.render.template({ layout: 'In layout -- {{yield}}', template: 'In template', }); @@ -69,9 +78,9 @@ export class EmberishComponentTests extends RenderTest { this.assertStableRerender(); } - @test + @render 'yield inside a conditional on the component'() { - this.render( + this.render.template( { layout: 'In layout -- {{#if @predicate}}{{yield}}{{/if}}', template: 'In template', @@ -92,9 +101,9 @@ export class EmberishComponentTests extends RenderTest { this.assertStableNodes(); } - @test + @render 'non-block with properties on attrs'() { - this.render({ + this.render.template({ layout: 'In layout - someProp: {{@someProp}}', args: { someProp: '"something here"' }, }); @@ -103,9 +112,9 @@ export class EmberishComponentTests extends RenderTest { this.assertStableRerender(); } - @test + @render 'block with properties on attrs'() { - this.render({ + this.render.template({ layout: 'In layout - someProp: {{@someProp}} - {{yield}}', template: 'In template', args: { someProp: '"something here"' }, @@ -115,75 +124,87 @@ export class EmberishComponentTests extends RenderTest { this.assertStableRerender(); } - @test({ skip: true, kind: 'curly' }) + @render({ invokeAs: 'glimmer' }) 'with ariaRole specified'() { - this.render({ + this.render.template({ layout: 'Here!', - attributes: { id: '"aria-test"', ariaRole: '"main"' }, + attributes: { id: '"aria-test"', role: '"main"' }, }); - this.assertComponent('Here!', { id: '"aria-test"', role: '"main"' }); + this.assertComponent('Here!', { id: 'aria-test', role: 'main' }); this.assertStableRerender(); } - @test({ skip: true, kind: 'curly' }) + @render({ invokeAs: 'glimmer' }) 'with ariaRole and class specified'() { - this.render({ - layout: 'Here!', - attributes: { id: '"aria-test"', class: '"foo"', ariaRole: '"main"' }, - }); + this.render.template( + { + layout: 'Here!', + attributes: { id: '"aria-test"', class: '"foo"', role: 'this.ariaRole' }, + }, + { ariaRole: 'main' } + ); this.assertComponent('Here!', { - id: '"aria-test"', + id: 'aria-test', class: classes('ember-view foo'), - role: '"main"', + role: 'main', }); this.assertStableRerender(); } - @test({ skip: true, kind: 'curly' }) + @render({ invokeAs: 'glimmer' }) 'with ariaRole specified as an outer binding'() { - this.render( + this.render.template( { layout: 'Here!', - attributes: { id: '"aria-test"', class: '"foo"', ariaRole: 'ariaRole' }, + attributes: { id: '"aria-test"', class: '"foo"', role: 'this.ariaRole' }, }, { ariaRole: 'main' } ); this.assertComponent('Here!', { - id: '"aria-test"', + id: 'aria-test', class: classes('ember-view foo'), - role: '"main"', + role: 'main', }); this.assertStableRerender(); } - @test({ skip: true, kind: 'glimmer' }) + @render('glimmer') 'glimmer component with role specified as an outer binding and copied'() { - this.render( - { - layout: 'Here!', - attributes: { id: '"aria-test"', role: 'myRole' }, - }, - { myRole: 'main' } + const TestComponent = defineComponent({}, `
Here!
`); + + const component = defineComponent( + { myRole: 'main', TestComponent }, + `` ); - this.assertComponent('Here!', { id: '"aria-test"', role: '"main"' }); + this.render.component(component); + + // this.render.template( + // { + // layout: 'Here!', + // attributes: { id: '"aria-test"', role: 'this.myRole' }, + // }, + // { myRole: 'main' } + // ); + + this.assertComponent('Here!', { id: 'aria-test', role: 'main' }); this.assertStableRerender(); } - @test({ kind: 'curly' }) + @render('curly') 'invoking wrapped layout via angle brackets applies ...attributes'() { - this.registerComponent('Curly', 'FooBar', 'Hello world!'); + this.register.component('Curly', 'FooBar', 'Hello world!'); - this.render(``); + this.render.template(``); this.assertComponent('Hello world!', { 'data-foo': 'bar' }); this.assertStableRerender(); } - @test({ kind: 'curly' }) + @render('curly') 'invoking wrapped layout via angle brackets - invocation attributes clobber internal attributes'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -194,16 +215,16 @@ export class EmberishComponentTests extends RenderTest { this['data-foo'] = 'inner'; } } - this.registerComponent('Curly', 'FooBar', 'Hello world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello world!', FooBar); - this.render(``); + this.render.template(``); this.assertComponent('Hello world!', { 'data-foo': 'outer' }); this.assertStableRerender(); } // LOCKS - @test({ kind: 'curly' }) + @render('curly') 'yields named block'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -212,16 +233,16 @@ export class EmberishComponentTests extends RenderTest { super(); } } - this.registerComponent('Curly', 'FooBar', 'Hello{{yield to="baz"}}world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello{{yield to="baz"}}world!', FooBar); - this.render(`<:baz> my `); + this.render.template(`<:baz> my `); this.assertComponent('Hello my world!'); this.assertStableRerender(); } // LOCKS - @test({ kind: 'curly' }) + @render('curly') 'implicit default named block'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -230,16 +251,16 @@ export class EmberishComponentTests extends RenderTest { super(); } } - this.registerComponent('Curly', 'FooBar', 'Hello{{yield}}world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello{{yield}}world!', FooBar); - this.render(` my `); + this.render.template(` my `); this.assertComponent('Hello my world!'); this.assertStableRerender(); } // LOCKS - @test({ kind: 'curly' }) + @render('curly') 'explicit default named block'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -248,16 +269,16 @@ export class EmberishComponentTests extends RenderTest { super(); } } - this.registerComponent('Curly', 'FooBar', 'Hello{{yield to="default"}}world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello{{yield to="default"}}world!', FooBar); - this.render(`<:default> my `); + this.render.template(`<:default> my `); this.assertComponent('Hello my world!'); this.assertStableRerender(); } // LOCKS - @test({ kind: 'curly' }) + @render('curly') 'else named block'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -266,15 +287,15 @@ export class EmberishComponentTests extends RenderTest { super(); } } - this.registerComponent('Curly', 'FooBar', 'Hello{{yield "my" to="inverse"}}world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello{{yield "my" to="inverse"}}world!', FooBar); - this.render(`<:else as |value|> {{value}} `); + this.render.template(`<:else as |value|> {{value}} `); this.assertComponent('Hello my world!'); this.assertStableRerender(); } - @test({ kind: 'curly' }) + @render('curly') 'inverse named block'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -283,15 +304,15 @@ export class EmberishComponentTests extends RenderTest { super(); } } - this.registerComponent('Curly', 'FooBar', 'Hello{{yield "my" to="inverse"}}world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello{{yield "my" to="inverse"}}world!', FooBar); - this.render(`<:inverse as |value|> {{value}} `); + this.render.template(`<:inverse as |value|> {{value}} `); this.assertComponent('Hello my world!'); this.assertStableRerender(); } - @test({ kind: 'curly' }) + @render('curly') 'invoking wrapped layout via angle brackets - invocation attributes merges classes'() { class FooBar extends EmberishCurlyComponent { [index: string]: unknown; @@ -302,19 +323,19 @@ export class EmberishComponentTests extends RenderTest { this['class'] = 'inner'; } } - this.registerComponent('Curly', 'FooBar', 'Hello world!', FooBar); + this.register.component('Curly', 'FooBar', 'Hello world!', FooBar); - this.render(``); + this.render.template(``); this.assertComponent('Hello world!', { class: classes('ember-view inner outer') }); this.assertStableRerender(); } - @test({ kind: 'curly' }) + @render('curly') 'invoking wrapped layout via angle brackets also applies explicit ...attributes'() { - this.registerComponent('Curly', 'FooBar', '

Hello world!

'); + this.register.component('Curly', 'FooBar', '

Hello world!

'); - this.render(``); + this.render.template(``); let wrapperElement = assertingElement(this.element.firstChild); assertEmberishElement(wrapperElement, 'div', { 'data-foo': 'bar' }); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/entry-point.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/entry-point.ts index c0c388ce4d..c70d20b07e 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/entry-point.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/entry-point.ts @@ -1,85 +1,61 @@ -import { createPrimitiveRef } from '@glimmer/reference'; +import { createPrimitiveCell } from '@glimmer/reference'; import { DynamicScopeImpl } from '@glimmer/runtime'; import { castToBrowser } from '@glimmer/util'; +import { ClientSideRenderDelegate, matrix } from '@glimmer-workspace/integration-tests'; -import type { ComponentKind } from '../components/types'; -import { JitRenderDelegate } from '../modes/jit/delegate'; -import { Count, RenderTest } from '../render-test'; -import { test } from '../test-decorator'; import { defineComponent } from '../test-helpers/define'; -export class EntryPointTest extends RenderTest { - static suiteName = 'entry points'; - - declare readonly testType: ComponentKind; - - override readonly count = new Count(); - - @test - 'an entry point'() { - let delegate = new JitRenderDelegate(); +matrix('entry points', (spec) => { + spec('an entry point', (ctx) => { let Title = defineComponent({}, `

hello {{@title}}

`); - let element = delegate.getInitialElement(); - let title = createPrimitiveRef('renderComponent'); - delegate.renderComponent(Title, { title }, element); + ctx.render.component(Title, { title: 'renderComponent' }, { into: ctx.element }); QUnit.assert.strictEqual( - castToBrowser(element, 'HTML').innerHTML, + castToBrowser(ctx.element, 'HTML').innerHTML, '

hello renderComponent

' ); - } + }); - @test - 'does not leak args between invocations'() { - let delegate = new JitRenderDelegate(); + spec('does not leak args between invocations', (ctx) => { + let delegate = new ClientSideRenderDelegate(); let Title = defineComponent({}, `

hello {{@title}}

`); - let element = delegate.getInitialElement(); - let title = createPrimitiveRef('renderComponent'); - delegate.renderComponent(Title, { title }, element); + let element = delegate.dom.getInitialElement(delegate.dom.document); + ctx.render.component(Title, { title: 'renderComponent' }, { into: element }); QUnit.assert.strictEqual( castToBrowser(element, 'HTML').innerHTML, '

hello renderComponent

' ); - element = delegate.getInitialElement(); - let newTitle = createPrimitiveRef('new title'); - delegate.renderComponent(Title, { title: newTitle }, element); + element = ctx.getClearedElement(); + ctx.render.component(Title, { title: 'new title' }, { into: element }); QUnit.assert.strictEqual(castToBrowser(element, 'HTML').innerHTML, '

hello new title

'); - } + }); - @test - 'can render different components per call'() { - let delegate = new JitRenderDelegate(); + spec('can render different components per call', (ctx) => { let Title = defineComponent({}, `

hello {{@title}}

`); let Body = defineComponent({}, `

body {{@body}}

`); - let element = delegate.getInitialElement(); - let title = createPrimitiveRef('renderComponent'); - delegate.renderComponent(Title, { title }, element); + ctx.render.component(Title, { title: 'renderComponent' }); QUnit.assert.strictEqual( - castToBrowser(element, 'HTML').innerHTML, + castToBrowser(ctx.element, 'HTML').innerHTML, '

hello renderComponent

' ); - element = delegate.getInitialElement(); - let body = createPrimitiveRef('text'); - delegate.renderComponent(Body, { body }, element); + const element = ctx.getClearedElement(); + ctx.render.component(Body, { body: 'text' }, { into: element }); QUnit.assert.strictEqual(castToBrowser(element, 'HTML').innerHTML, '

body text

'); - } + }); - @test - 'supports passing in an initial dynamic context'() { - let delegate = new JitRenderDelegate(); + spec('supports passing in an initial dynamic context', (ctx) => { let Locale = defineComponent({}, `{{-get-dynamic-var "locale"}}`); - let element = delegate.getInitialElement(); let dynamicScope = new DynamicScopeImpl({ - locale: createPrimitiveRef('en_US'), + locale: createPrimitiveCell('en_US'), }); - delegate.renderComponent(Locale, {}, element, dynamicScope); + ctx.render.component(Locale, {}, { dynamicScope }); - QUnit.assert.strictEqual(castToBrowser(element, 'HTML').innerHTML, 'en_US'); - } -} + QUnit.assert.strictEqual(castToBrowser(ctx.element, 'HTML').innerHTML, 'en_US'); + }); +}).client(); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/error-recovery.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/error-recovery.ts new file mode 100644 index 0000000000..c4c396e78c --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/error-recovery.ts @@ -0,0 +1,209 @@ +import { render, RenderTestContext, stripTight } from '@glimmer-workspace/integration-tests'; + +export class ErrorRecoverySuite extends RenderTestContext { + static suiteName = 'ErrorRecovery'; + + @render + 'if no error is thrown, everything works as expected'() { + const actions = new Actions(); + + this.render.template('{{#-try}}message: {{this.message}}{{/-try}}', { + message: 'hello', + }); + + actions.expect([]); + } + + @render('templateOnly') + 'if no error is thrown and a component is rendered, everything works as expected'() { + this.register.component('TemplateOnly', 'Message', '{{yield}}'); + + this.render.template('{{#-try}}message: >{{/-try}}', {}); + } + + @render + 'if the error is handled, the DOM is cleaned up'() { + const actions = new Actions(); + + class Woops { + get woops() { + actions.record('get woops'); + throw Error('woops'); + } + } + + this.render.template('{{#-try this.handler}}message: [{{this.woops.woops}}]{{/-try}}', { + woops: new Woops(), + handler: (_err: unknown, _retry: () => void) => { + // it only runs once + actions.record('error handled'); + }, + }); + + actions.expect(['get woops', 'error handled']); + this.assertHTML(''); + } + + @render + 'if the error is unhandled, the DOM is cleaned up'(assert: Assert) { + const actions = new Actions(); + + class Woops { + get woops() { + actions.record('get woops'); + throw Error('woops'); + } + } + + assert.throws(() => { + this.render.template('{{#-try}}message: [{{this.woops.woops}}]{{/-try}}', { + woops: new Woops(), + }); + }); + + actions.expect(['get woops']); + this.assertHTML(''); + } + + @render + 'error boundaries can happen in nested context'() { + const actions = new Actions(); + + class Woops { + get woops() { + actions.record('get woops'); + throw Error('woops'); + } + } + + this.render.template( + stripTight` +

+ {{this.outer.before}}| + {{#-try this.handler}} + {{this.inner.before}}|message: [{{this.woops.woops}}]|{{this.inner.after}} + {{/-try}} + |{{this.outer.after}} +

+ `, + { + woops: new Woops(), + outer: { + before: 'outer:before', + after: 'outer:after', + }, + inner: { + before: 'inner:before', + after: 'inner:after', + }, + handler: (_err: unknown, _retry: () => void) => { + actions.record('error handled'); + }, + } + ); + + actions.expect(['get woops', 'error handled']); + this.assertHTML('

outer:before||outer:after

'); + } + + // @test + // 'errors are rethrown during initial render and DOM is cleaned up'() { + // const actions = new Actions(); + + // class Counter { + // @tracked count = 0; + + // get message() { + // actions.record(`get message`); + // if (this.count === 0) { + // throw Error('woops'); + // } else { + // return String(this.count); + // } + // } + // } + + // let counter = new Counter(); + + // this.render('message: [{{this.counter.message}}]', { + // counter, + // }); + // this.#assertRenderError('woops'); + + // actions.expect(['get message']); + + // // counter.count++; + + // // this.rerender(); + + // // this.assertHTML('message: [1]'); + // // actions.expect(['get message']); + // } + + // @test + // 'errors are rethrown during rerender and DOM is cleaned up'() { + // const actions = new Actions(); + + // class Counter { + // @tracked count = 0; + + // get message() { + // actions.record(`get message`); + // if (this.count === 1) { + // throw Error('woops'); + // } else { + // return String(this.count); + // } + // } + // } + + // let counter = new Counter(); + + // this.render('message: [{{this.counter.message}}]', { + // counter, + // }); + + // this.assertHTML('message: [0]'); + // actions.expect(['get message']); + + // // counter.count++; + + // // this.rerender(); + + // // this.#assertRenderError('woops'); + + // // actions.expect(['get message']); + + // // counter.count++; + + // // this.rerender(); + + // // this.assertHTML('message: [2]'); + // // actions.expect(['get message']); + // } + + // @test + // 'helper destructors run on rollback'(assert: Assert) { + // assert.false(true, 'TODO'); + // } + + // @test + // 'the tracking frame is cleared when an error was thrown'(assert: Assert) { + // assert.false(true, 'TODO'); + // } +} + +class Actions { + #actions: string[] = []; + + record(action: string) { + this.#actions.push(action); + } + + expect(expected: string[]) { + let actual = this.#actions; + this.#actions = []; + + QUnit.assert.deepEqual(actual, expected); + } +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/has-block-params.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/has-block-params.ts index ab408a1a6b..1c554b2b22 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/has-block-params.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/has-block-params.ts @@ -1,304 +1,380 @@ +import { matrix } from '@glimmer-workspace/integration-tests'; + import { GlimmerishComponent } from '../components'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; - -export class HasBlockParamsHelperSuite extends RenderTest { - static suiteName = 'has-block-params'; - - @test({ kind: 'curly' }) - 'parameterized has-block-params (subexpr, else) when else supplied without block params'() { - this.render({ - layout: '{{#if (has-block-params "inverse")}}Yes{{else}}No{{/if}}', - template: 'block here', - else: 'else here', - }); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'has-block-params from within a yielded + invoked curried component'() { - class TestHarness extends GlimmerishComponent { - public Foo: any; + +matrix('has-block-params', (spec) => { + spec( + { type: 'curly' }, + 'parameterized has-block-params (subexpr, else) when else supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block-params "inverse")}}Yes{{else}}No{{/if}}', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'has-block-params from within a yielded + invoked curried component', + (ctx) => { + class TestHarness extends GlimmerishComponent { + public Foo: any; + } + ctx.register.component('Glimmer', 'TestHarness', '{{yield (component "Foo")}}', TestHarness); + ctx.register.component('Glimmer', 'Foo', '{{#if (has-block-params)}}Yes{{else}}No{{/if}}'); + + ctx.render.template('{{Foo}}'); + + ctx.assertHTML('No'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (subexpr, else) when else not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block-params "inverse")}}Yes{{else}}No{{/if}}', + template: 'block here', + }); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (subexpr, default) when block supplied with block params', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', + blockParams: ['param'], + template: 'block here', + }); + + ctx.assertComponent('Yes'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (subexpr, default) when block supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', + template: 'block here', + }); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (subexpr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', + }); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (content, else) when else supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '{{has-block-params "inverse"}}', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (content, else) when else not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{has-block-params "inverse"}}', + template: 'block here', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (content, default) when block supplied with block params', + (ctx) => { + ctx.render.template({ + layout: '{{has-block-params}}', + blockParams: ['param'], + template: 'block here', + }); + + ctx.assertComponent('true'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (content, default) when block supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '{{has-block-params}}', + template: 'block here', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (content, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{has-block-params}}', + template: 'block here', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (prop, else) when else supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (prop, else) when else not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (prop, default) when block supplied with block params', + (ctx) => { + ctx.render.template({ + layout: '', + blockParams: ['param'], + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (prop, default) when block supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (prop, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (attr, else) when else supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (attr, else) when else not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (attr, default) when block supplied with block params', + (ctx) => { + ctx.render.template({ + layout: '', + blockParams: ['param'], + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (attr, default) when block supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (attr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (concatted attr, else) when else supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (concatted attr, else) when else not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (concatted attr, default) when block supplied with block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + blockParams: ['param'], + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (concatted attr, default) when block supplied without block params', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block-params (concatted attr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); } - this.registerComponent('Glimmer', 'TestHarness', '{{yield (component "Foo")}}', TestHarness); - this.registerComponent('Glimmer', 'Foo', '{{#if (has-block-params)}}Yes{{else}}No{{/if}}'); - - this.render('{{Foo}}'); - - this.assertHTML('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (subexpr, else) when else not supplied'() { - this.render({ - layout: '{{#if (has-block-params "inverse")}}Yes{{else}}No{{/if}}', - template: 'block here', - }); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (subexpr, default) when block supplied with block params'() { - this.render({ - layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', - blockParams: ['param'], - template: 'block here', - }); - - this.assertComponent('Yes'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (subexpr, default) when block supplied without block params'() { - this.render({ - layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', - template: 'block here', - }); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (subexpr, default) when block not supplied'() { - this.render({ - layout: '{{#if (has-block-params)}}Yes{{else}}No{{/if}}', - }); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (content, else) when else supplied without block params'() { - this.render({ - layout: '{{has-block-params "inverse"}}', - template: 'block here', - else: 'else here', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (content, else) when else not supplied'() { - this.render({ - layout: '{{has-block-params "inverse"}}', - template: 'block here', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (content, default) when block supplied with block params'() { - this.render({ - layout: '{{has-block-params}}', - blockParams: ['param'], - template: 'block here', - }); - - this.assertComponent('true'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (content, default) when block supplied without block params'() { - this.render({ - layout: '{{has-block-params}}', - template: 'block here', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (content, default) when block not supplied'() { - this.render({ - layout: '{{has-block-params}}', - template: 'block here', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (prop, else) when else supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - else: 'else here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (prop, else) when else not supplied'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (prop, default) when block supplied with block params'() { - this.render({ - layout: '', - blockParams: ['param'], - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (prop, default) when block supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (prop, default) when block not supplied'() { - this.render({ - layout: '', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (attr, else) when else supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - else: 'else here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (attr, else) when else not supplied'() { - this.render({ - layout: '', - template: 'block here', - else: 'else here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (attr, default) when block supplied with block params'() { - this.render({ - layout: '', - blockParams: ['param'], - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (attr, default) when block supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (attr, default) when block not supplied'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (concatted attr, else) when else supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - else: 'else here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (concatted attr, else) when else not supplied'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (concatted attr, default) when block supplied with block params'() { - this.render({ - layout: '', - template: 'block here', - blockParams: ['param'], - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (concatted attr, default) when block supplied without block params'() { - this.render({ - layout: '', - template: 'block here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block-params (concatted attr, default) when block not supplied'() { - this.render({ - layout: '', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } -} + ); +}).client(); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/has-block.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/has-block.ts index a47503b303..0739e63568 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/has-block.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/has-block.ts @@ -1,343 +1,357 @@ -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { matrix } from '@glimmer-workspace/integration-tests'; -export class HasBlockSuite extends RenderTest { - static suiteName = 'has-block'; - - @test({ kind: 'curly' }) - 'parameterized has-block (subexpr, else) when else supplied'() { - this.render({ +matrix('has-block', (spec) => { + spec({ type: 'curly' }, 'parameterized has-block (subexpr, else) when else supplied', (ctx) => { + ctx.render.template({ layout: '{{#if (has-block "inverse")}}Yes{{else}}No{{/if}}', template: 'block here', else: 'else here', }); - this.assertComponent('Yes'); - this.assertStableRerender(); - } + ctx.assertComponent('Yes'); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (subexpr, else) when else not supplied'() { - this.render({ + spec('parameterized has-block (subexpr, else) when else not supplied', (ctx) => { + ctx.render.template({ layout: '{{#if (has-block "inverse")}}Yes{{else}}No{{/if}}', template: 'block here', }); - this.assertComponent('No'); - this.assertStableRerender(); - } + ctx.assertComponent('No'); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (subexpr, default) when block supplied'() { - this.render({ + spec('parameterized has-block (subexpr, default) when block supplied', (ctx) => { + ctx.render.template({ layout: '{{#if (has-block)}}Yes{{else}}No{{/if}}', template: 'block here', }); - this.assertComponent('Yes'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (subexpr, default) when block not supplied'() { - this.render({ - layout: '{{#if (has-block)}}Yes{{else}}No{{/if}}', - }); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (content, else) when else supplied'() { - this.render({ + ctx.assertComponent('Yes'); + ctx.assertStableRerender(); + }); + + spec( + { type: 'curly' }, + 'parameterized has-block (subexpr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{#if (has-block)}}Yes{{else}}No{{/if}}', + }); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + } + ); + + spec({ type: 'curly' }, 'parameterized has-block (content, else) when else supplied', (ctx) => { + ctx.render.template({ layout: '{{has-block "inverse"}}', template: 'block here', else: 'else here', }); - this.assertComponent('true'); - this.assertStableRerender(); - } + ctx.assertComponent('true'); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (content, else) when else not supplied'() { - this.render({ + spec('parameterized has-block (content, else) when else not supplied', (ctx) => { + ctx.render.template({ layout: '{{has-block "inverse"}}', template: 'block here', }); - this.assertComponent('false'); - this.assertStableRerender(); - } + ctx.assertComponent('false'); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (content, default) when block supplied'() { - this.render({ + spec('parameterized has-block (content, default) when block supplied', (ctx) => { + ctx.render.template({ layout: '{{has-block}}', template: 'block here', }); - this.assertComponent('true'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (content, default) when block not supplied'() { - this.render({ - layout: '{{has-block}}', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (prop, else) when else supplied'() { - this.render({ + ctx.assertComponent('true'); + ctx.assertStableRerender(); + }); + + spec( + { type: 'curly' }, + 'parameterized has-block (content, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '{{has-block}}', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec({ type: 'curly' }, 'parameterized has-block (prop, else) when else supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', else: 'else here', }); - this.assertComponent(''); - this.assertStableRerender(); - } + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (prop, else) when else not supplied'() { - this.render({ + spec('parameterized has-block (prop, else) when else not supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (prop, default) when block supplied'() { - this.render({ + spec('parameterized has-block (prop, default) when block supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (prop, default) when block not supplied'() { - this.render({ - layout: '', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'has-block works when used directly as an argument without extra parens (prop, default)'() { - this.registerComponent('TemplateOnly', 'Foo', '{{@hasBlock}}'); - - this.render({ - layout: '', - }); - - this.assertComponent('false'); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (attr, else) when else supplied'() { - this.render({ + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); + + spec( + { type: 'curly' }, + 'parameterized has-block (prop, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'has-block works when used directly as an argument without extra parens (prop, default)', + (ctx) => { + ctx.register.component('TemplateOnly', 'Foo', '{{@hasBlock}}'); + + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent('false'); + ctx.assertStableRerender(); + } + ); + + spec({ type: 'curly' }, 'parameterized has-block (attr, else) when else supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', else: 'else here', }); - this.assertComponent(''); - this.assertStableRerender(); - } + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (attr, else) when else not supplied'() { - this.render({ + spec('parameterized has-block (attr, else) when else not supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (attr, default) when block supplied'() { - this.render({ + spec('parameterized has-block (attr, default) when block supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (attr, default) when block not supplied'() { - this.render({ - layout: '', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (concatted attr, else) when else supplied'() { - this.render({ - layout: '', - template: 'block here', - else: 'else here', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test - 'parameterized has-block (concatted attr, else) when else not supplied'() { - this.render({ + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); + + spec( + { type: 'curly' }, + 'parameterized has-block (attr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'curly' }, + 'parameterized has-block (concatted attr, else) when else supplied', + (ctx) => { + ctx.render.template({ + layout: '', + template: 'block here', + else: 'else here', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec('parameterized has-block (concatted attr, else) when else not supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); - @test - 'parameterized has-block (concatted attr, default) when block supplied'() { - this.render({ + spec('parameterized has-block (concatted attr, default) when block supplied', (ctx) => { + ctx.render.template({ layout: '', template: 'block here', }); - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'curly' }) - 'parameterized has-block (concatted attr, default) when block not supplied'() { - this.render({ - layout: '', - }); - - this.assertComponent(''); - this.assertStableRerender(); - } - - @test({ kind: 'glimmer' }) - 'self closing angle bracket invocation (subexpr, default)'() { - this.registerComponent( + ctx.assertComponent(''); + ctx.assertStableRerender(); + }); + + spec( + { type: 'curly' }, + 'parameterized has-block (concatted attr, default) when block not supplied', + (ctx) => { + ctx.render.template({ + layout: '', + }); + + ctx.assertComponent(''); + ctx.assertStableRerender(); + } + ); + + spec({ type: 'glimmer' }, 'self closing angle bracket invocation (subexpr, default)', (ctx) => { + ctx.register.component( 'Glimmer', 'TestComponent', `
{{#if (has-block)}}Yes{{else}}No{{/if}}
` ); - this.render(``); + ctx.render.template(``); - this.assertComponent('No'); - this.assertStableRerender(); - } + ctx.assertComponent('No'); + ctx.assertStableRerender(); + }); - @test({ kind: 'glimmer' }) - 'self closing angle bracket invocation (subexpr, else)'() { - this.registerComponent( + spec({ type: 'glimmer' }, 'self closing angle bracket invocation (subexpr, else)', (ctx) => { + ctx.register.component( 'Glimmer', 'TestComponent', `
{{#if (has-block 'inverse')}}Yes{{else}}No{{/if}}
` ); - this.render(``); - - this.assertComponent('No'); - this.assertStableRerender(); - } - - @test({ kind: 'glimmer' }) - 'self closing angle bracket invocation (concatted attr, default)'() { - this.registerComponent( - 'Glimmer', - 'TestComponent', - `
` - ); - this.render(``); - - this.assertComponent('', { 'data-has-block': 'false' }); - this.assertStableRerender(); - } - - @test({ kind: 'glimmer' }) - 'has-block works within a yielded curried component invoked within mustaches'() { - this.registerComponent( - 'Glimmer', - 'ComponentWithHasBlock', - `
` - ); - - this.registerComponent('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); - - this.registerComponent( - 'Glimmer', - 'TestComponent', - `{{componentWithHasBlock}}` - ); - - this.render(``); - - this.assertComponent('', { 'data-has-block': 'false' }); - this.assertStableRerender(); - } - - @test({ kind: 'glimmer' }) - 'has-block works within a yielded curried component invoked with angle bracket invocation (falsy)'() { - this.registerComponent( - 'Glimmer', - 'ComponentWithHasBlock', - `
` - ); - - this.registerComponent('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); - - this.registerComponent( - 'Glimmer', - 'TestComponent', - `` - ); - - this.render(``); - - this.assertComponent('', { 'data-has-block': 'false' }); - this.assertStableRerender(); - } - - @test({ kind: 'glimmer' }) - 'has-block works within a yielded curried component invoked with angle bracket invocation (truthy)'() { - this.registerComponent( - 'Glimmer', - 'ComponentWithHasBlock', - `
` - ); - - this.registerComponent('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); - - this.registerComponent( - 'Glimmer', - 'TestComponent', - `` - ); - - this.render(``); - - this.assertComponent('', { 'data-has-block': 'true' }); - this.assertStableRerender(); - } -} + ctx.render.template(``); + + ctx.assertComponent('No'); + ctx.assertStableRerender(); + }); + + spec( + { type: 'glimmer' }, + 'self closing angle bracket invocation (concatted attr, default)', + (ctx) => { + ctx.register.component( + 'Glimmer', + 'TestComponent', + `
` + ); + ctx.render.template(``); + + ctx.assertComponent('', { 'data-has-block': 'false' }); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'glimmer' }, + 'has-block works within a yielded curried component invoked within mustaches', + (ctx) => { + ctx.register.component( + 'Glimmer', + 'ComponentWithHasBlock', + `
` + ); + + ctx.register.component('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); + + ctx.register.component( + 'Glimmer', + 'TestComponent', + `{{componentWithHasBlock}}` + ); + + ctx.render.template(``); + + ctx.assertComponent('', { 'data-has-block': 'false' }); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'glimmer' }, + 'has-block works within a yielded curried component invoked with angle bracket invocation (falsy)', + (ctx) => { + ctx.register.component( + 'Glimmer', + 'ComponentWithHasBlock', + `
` + ); + + ctx.register.component('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); + + ctx.register.component( + 'Glimmer', + 'TestComponent', + `` + ); + + ctx.render.template(``); + + ctx.assertComponent('', { 'data-has-block': 'false' }); + ctx.assertStableRerender(); + } + ); + + spec( + { type: 'glimmer' }, + 'has-block works within a yielded curried component invoked with angle bracket invocation (truthy)', + (ctx) => { + ctx.register.component( + 'Glimmer', + 'ComponentWithHasBlock', + `
` + ); + + ctx.register.component('Glimmer', 'Yielder', `{{yield (component 'ComponentWithHasBlock')}}`); + + ctx.register.component( + 'Glimmer', + 'TestComponent', + `` + ); + + ctx.render.template(``); + + ctx.assertComponent('', { 'data-has-block': 'true' }); + ctx.assertStableRerender(); + } + ); +}).client(); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/in-element.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/in-element.ts index 0064029e30..64756a9b36 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/in-element.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/in-element.ts @@ -1,21 +1,21 @@ -import { destroy } from '@glimmer/destroyable'; import type { AST } from '@glimmer/syntax'; +import { destroy } from '@glimmer/destroyable'; import { assign, unwrap } from '@glimmer/util'; import { GlimmerishComponent } from '../components/emberish-glimmer'; import { equalsElement } from '../dom/assertions'; import { replaceHTML } from '../dom/simple-utils'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; import { stripTight } from '../test-helpers/strings'; import { tracked } from '../test-helpers/tracked'; -export class InElementSuite extends RenderTest { +export class InElementSuite extends RenderTestContext { static suiteName = '#in-element'; - @test + @render 'It works with AST transforms'() { - this.registerPlugin((env) => ({ + this.register.plugin((env) => ({ name: 'maybe-in-element', visitor: { BlockStatement(node: AST.BlockStatement) { @@ -30,11 +30,14 @@ export class InElementSuite extends RenderTest { }, })); - let externalElement = this.delegate.createElement('div'); - this.render('{{#maybe-in-element this.externalElement}}[{{this.foo}}]{{/maybe-in-element}}', { - externalElement, - foo: 'Yippie!', - }); + let externalElement = this.dom.createElement('div'); + this.render.template( + '{{#maybe-in-element this.externalElement}}[{{this.foo}}]{{/maybe-in-element}}', + { + externalElement, + foo: 'Yippie!', + } + ); equalsElement(externalElement, 'div', {}, '[Yippie!]'); this.assertStableRerender(); @@ -48,10 +51,10 @@ export class InElementSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'Renders curlies into external element'() { - let externalElement = this.delegate.createElement('div'); - this.render('{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}', { + let externalElement = this.dom.createElement('div'); + this.render.template('{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}', { externalElement, foo: 'Yippie!', }); @@ -68,13 +71,13 @@ export class InElementSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'clears existing content'() { - let externalElement = this.delegate.createElement('div'); + let externalElement = this.dom.createElement('div'); let initialContent = '

Hello there!

'; replaceHTML(externalElement, initialContent); - this.render('{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}', { + this.render.template('{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}', { externalElement, foo: 'Yippie!', }); @@ -91,12 +94,12 @@ export class InElementSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'Changing to falsey'() { - let first = this.delegate.createElement('div'); - let second = this.delegate.createElement('div'); + let first = this.dom.createElement('div'); + let second = this.dom.createElement('div'); - this.render( + this.render.template( stripTight` |{{this.foo}}| {{#in-element this.first}}[1{{this.foo}}]{{/in-element}} @@ -135,13 +138,13 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'With pre-existing content'() { - let externalElement = this.delegate.createElement('div'); + let externalElement = this.dom.createElement('div'); let initialContent = '

Hello there!

'; replaceHTML(externalElement, initialContent); - this.render( + this.render.template( stripTight`{{#in-element this.externalElement insertBefore=null}}[{{this.foo}}]{{/in-element}}`, { externalElement, @@ -169,12 +172,12 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'With insertBefore'() { - let externalElement = this.delegate.createElement('div'); + let externalElement = this.dom.createElement('div'); replaceHTML(externalElement, 'Hellothere!'); - this.render( + this.render.template( stripTight`{{#in-element this.externalElement insertBefore=this.insertBefore}}[{{this.foo}}]{{/in-element}}`, { externalElement, insertBefore: externalElement.lastChild, foo: 'Yippie!' } ); @@ -204,15 +207,18 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'Updating remote element'() { - let first = this.delegate.createElement('div'); - let second = this.delegate.createElement('div'); + let first = this.dom.createElement('div'); + let second = this.dom.createElement('div'); - this.render(stripTight`{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}`, { - externalElement: first, - foo: 'Yippie!', - }); + this.render.template( + stripTight`{{#in-element this.externalElement}}[{{this.foo}}]{{/in-element}}`, + { + externalElement: first, + foo: 'Yippie!', + } + ); equalsElement(first, 'div', {}, '[Yippie!]'); equalsElement(second, 'div', {}, ''); @@ -256,12 +262,12 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render "Inside an '{{if}}'"() { - let first = { element: this.delegate.createElement('div'), description: 'first' }; - let second = { element: this.delegate.createElement('div'), description: 'second' }; + let first = { element: this.dom.createElement('div'), description: 'first' }; + let second = { element: this.dom.createElement('div'), description: 'second' }; - this.render( + this.render.template( stripTight` {{#if this.showFirst}} {{#in-element this.first}}[{{this.foo}}]{{/in-element}} @@ -321,9 +327,9 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'Inside the current constructing element'() { - this.render( + this.render.template( stripTight` Before {{#in-element this.element insertBefore=null}} @@ -343,12 +349,12 @@ export class InElementSuite extends RenderTest { destroy(unwrap(this.renderResult)); } - @test + @render Multiple() { - let firstElement = this.delegate.createElement('div'); - let secondElement = this.delegate.createElement('div'); + let firstElement = this.dom.createElement('div'); + let secondElement = this.dom.createElement('div'); - this.render( + this.render.template( stripTight` {{#in-element this.firstElement}} [{{this.foo}}] @@ -389,12 +395,12 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'Inside a loop'() { - let { delegate } = this; + let { dom } = this; class Item { - element = delegate.createElement('div'); + element = dom.createElement('div'); @tracked value: string; @@ -403,14 +409,13 @@ export class InElementSuite extends RenderTest { } } - this.testType = 'Dynamic'; - this.registerComponent('TemplateOnly', 'FooBar', '

{{@value}}

'); + this.register.component('TemplateOnly', 'FooBar', '

{{@value}}

'); - this.registerHelper('log', () => {}); + this.register.helper('log', () => {}); let roots = [new Item('foo'), new Item('bar'), new Item('baz')]; - this.render( + this.render.template( stripTight` {{~#each this.roots as |root|~}} {{~log root~}} @@ -458,15 +463,14 @@ export class InElementSuite extends RenderTest { equalsElement(third.element, 'div', {}, '

baz

'); this.assertHTML(''); this.assertStableRerender(); - this.testType = 'TemplateOnly'; } - @test + @render Nesting() { - let firstElement = this.delegate.createElement('div'); - let secondElement = this.delegate.createElement('div'); + let firstElement = this.dom.createElement('div'); + let secondElement = this.dom.createElement('div'); - this.render( + this.render.template( stripTight` {{#in-element this.firstElement}} [{{this.foo}}] @@ -513,7 +517,7 @@ export class InElementSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'Components are destroyed'() { let destroyed = 0; @@ -524,10 +528,10 @@ export class InElementSuite extends RenderTest { } } - this.registerComponent('Glimmer', 'DestroyMe', 'destroy me!', DestroyMeComponent as any); - let externalElement = this.delegate.createElement('div'); + this.register.component('Glimmer', 'DestroyMe', 'destroy me!', DestroyMeComponent as any); + let externalElement = this.dom.createElement('div'); - this.render( + this.render.template( stripTight` {{#if this.showExternal}} {{#in-element this.externalElement}}[]{{/in-element}} diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render.ts deleted file mode 100644 index 994386d4f5..0000000000 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render.ts +++ /dev/null @@ -1,1318 +0,0 @@ -import type { SimpleElement } from '@glimmer/interfaces'; -import { castToBrowser, checkNode, NS_SVG, strip, unwrap } from '@glimmer/util'; - -import { assertNodeTagName } from '../dom/assertions'; -import { firstElementChild, getElementsByTagName } from '../dom/simple-utils'; -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; - -export class InitialRenderSuite extends RenderTest { - static suiteName = 'initial render'; - - name = 'BASE'; - @test - 'HTML text content'() { - this.render('content'); - this.assertHTML('content'); - this.assertStableRerender(); - } - - @test - 'HTML tags'() { - this.render('

hello!

content
'); - this.assertHTML('

hello!

content
'); - this.assertStableRerender(); - } - - @test - 'HTML attributes'() { - this.render("
content
"); - this.assertHTML("
content
"); - this.assertStableRerender(); - } - - @test - 'HTML data attributes'() { - this.render("
content
"); - this.assertHTML("
content
"); - this.assertStableRerender(); - } - - @test - 'HTML checked attributes'() { - this.render(""); - this.assertHTML(``); - this.assertStableRerender(); - } - - @test - 'HTML selected options'() { - this.render(strip` - - `); - this.assertHTML(strip` - - `); - this.assertStableRerender(); - } - - @test - 'HTML multi-select options'() { - this.render(strip` - - `); - this.assertHTML(strip` - - `); - this.assertStableRerender(); - } - - @test - 'Void Elements'() { - const voidElements = 'area base br embed hr img input keygen link meta param source track wbr'; - voidElements.split(' ').forEach((tagName) => this.shouldBeVoid(tagName)); - } - - @test - 'Nested HTML'() { - this.render( - "

hi!

 More content" - ); - this.assertHTML( - "

hi!

 More content" - ); - this.assertStableRerender(); - } - - @test - 'Custom Elements'() { - this.render(''); - this.assertHTML(''); - this.assertStableRerender(); - } - - @test - 'Nested Custom Elements'() { - this.render( - "Stuff
Here
" - ); - this.assertHTML( - "Stuff
Here
" - ); - this.assertStableRerender(); - } - - @test - 'Moar nested Custom Elements'() { - this.render( - "Here" - ); - this.assertHTML( - "Here" - ); - this.assertStableRerender(); - } - - @test - 'Custom Elements with dynamic attributes'() { - this.render( - "", - { someDynamicBits: 'things' } - ); - this.assertHTML(""); - this.assertStableRerender(); - } - - @test - 'Custom Elements with dynamic content'() { - this.render('{{this.derp}}', { derp: 'stuff' }); - this.assertHTML('stuff'); - this.assertStableRerender(); - } - - @test - 'Dynamic content within single custom element'() { - this.render('{{#if this.derp}}Content Here{{/if}}', { derp: 'stuff' }); - this.assertHTML('Content Here'); - this.assertStableRerender(); - - this.rerender({ derp: false }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ derp: true }); - this.assertHTML('Content Here'); - this.assertStableRerender(); - - this.rerender({ derp: 'stuff' }); - this.assertHTML('Content Here'); - this.assertStableRerender(); - } - - @test - 'Supports quotes'() { - this.render('
"This is a title," we\'re on a boat
'); - this.assertHTML('
"This is a title," we\'re on a boat
'); - this.assertStableRerender(); - } - - @test - 'Supports backslashes'() { - this.render('
This is a backslash: \\
'); - this.assertHTML('
This is a backslash: \\
'); - this.assertStableRerender(); - } - - @test - 'Supports new lines'() { - this.render('
common\n\nbro
'); - this.assertHTML('
common\n\nbro
'); - this.assertStableRerender(); - } - - @test - 'HTML tag with empty attribute'() { - this.render("
content
"); - this.assertHTML("
content
"); - this.assertStableRerender(); - } - - @test - 'Attributes containing a helper are treated like a block'() { - this.registerHelper('testing', (params) => { - this.assert.deepEqual(params, [123]); - return 'example.com'; - }); - - this.render('
linky'); - this.assertHTML('linky'); - this.assertStableRerender(); - } - - @test - "HTML boolean attribute 'disabled'"() { - this.render(''); - this.assertHTML(''); - - // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) - // assertNodeProperty(root.firstChild, 'input', 'disabled', true); - - this.assertStableRerender(); - } - - @test - 'Quoted attribute null values do not disable'() { - this.render('', { isDisabled: null }); - this.assertHTML(''); - this.assertStableRerender(); - - // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) - // assertNodeProperty(root.firstChild, 'input', 'disabled', false); - - this.rerender({ isDisabled: true }); - this.assertHTML(''); - this.assertStableNodes(); - - // TODO: ?????????? - this.rerender({ isDisabled: false }); - this.assertHTML(''); - this.assertStableNodes(); - - this.rerender({ isDisabled: null }); - this.assertHTML(''); - this.assertStableNodes(); - } - - @test - 'Unquoted attribute null values do not disable'() { - this.render('', { isDisabled: null }); - this.assertHTML(''); - this.assertStableRerender(); - - // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) - // assertNodeProperty(root.firstChild, 'input', 'disabled', false); - - this.rerender({ isDisabled: true }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ isDisabled: false }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ isDisabled: null }); - this.assertHTML(''); - this.assertStableRerender(); - } - - @test - 'Quoted attribute string values'() { - this.render("", { src: 'image.png' }); - this.assertHTML(""); - this.assertStableRerender(); - - this.rerender({ src: 'newimage.png' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: 'image.png' }); - this.assertHTML(""); - this.assertStableNodes(); - } - - @test - 'Unquoted attribute string values'() { - this.render('', { src: 'image.png' }); - this.assertHTML(""); - this.assertStableRerender(); - - this.rerender({ src: 'newimage.png' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: 'image.png' }); - this.assertHTML(""); - this.assertStableNodes(); - } - - @test - 'Unquoted img src attribute is not rendered when set to `null`'() { - this.render("", { src: null }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ src: 'newimage.png' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: null }); - this.assertHTML(''); - this.assertStableNodes(); - } - - @test - 'Unquoted img src attribute is not rendered when set to `undefined`'() { - this.render("", { src: undefined }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ src: 'newimage.png' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ src: undefined }); - this.assertHTML(''); - this.assertStableNodes(); - } - - @test - 'Unquoted a href attribute is not rendered when set to `null`'() { - this.render('', { href: null }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ href: 'http://example.com' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ href: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ href: null }); - this.assertHTML(''); - this.assertStableNodes(); - } - - @test - 'Unquoted a href attribute is not rendered when set to `undefined`'() { - this.render('', { href: undefined }); - this.assertHTML(''); - this.assertStableRerender(); - - this.rerender({ href: 'http://example.com' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ href: '' }); - this.assertHTML(""); - this.assertStableNodes(); - - this.rerender({ href: undefined }); - this.assertHTML(''); - this.assertStableNodes(); - } - - @test - 'Attribute expression can be followed by another attribute'() { - this.render("
", { funstuff: 'oh my' }); - this.assertHTML("
"); - this.assertStableRerender(); - - this.rerender({ funstuff: 'oh boy' }); - this.assertHTML("
"); - this.assertStableNodes(); - - this.rerender({ funstuff: '' }); - this.assertHTML("
"); - this.assertStableNodes(); - - this.rerender({ funstuff: 'oh my' }); - this.assertHTML("
"); - this.assertStableNodes(); - } - - @test - 'Dynamic selected options'() { - this.render( - strip` - - `, - { selected: true } - ); - - this.assertHTML(strip` - - `); - - let selectNode = checkNode(castToBrowser(this.element, 'HTML').firstElementChild, 'select'); - this.assert.strictEqual(selectNode.selectedIndex, 1); - this.assertStableRerender(); - - this.rerender({ selected: false }); - this.assertHTML(strip` - - `); - - selectNode = checkNode(castToBrowser(this.element, 'HTML').firstElementChild, 'select'); - - this.assert.strictEqual(selectNode.selectedIndex, 0); - - this.assertStableNodes(); - - this.rerender({ selected: '' }); - - this.assertHTML(strip` - - `); - - selectNode = checkNode(castToBrowser(this.element, 'HTML').firstElementChild, 'select'); - - this.assert.strictEqual(selectNode.selectedIndex, 0); - - this.assertStableNodes(); - - this.rerender({ selected: true }); - this.assertHTML(strip` - - `); - - selectNode = checkNode(castToBrowser(this.element, 'HTML').firstElementChild, 'select'); - this.assert.strictEqual(selectNode.selectedIndex, 1); - this.assertStableNodes(); - } - - @test - 'Dynamic multi-select'() { - this.render( - strip` - `, - { - somethingTrue: true, - somethingTruthy: 'is-true', - somethingUndefined: undefined, - somethingNull: null, - somethingFalse: false, - } - ); - - const selectNode = firstElementChild(this.element); - this.assert.ok(selectNode, 'rendered select'); - if (selectNode === null) { - return; - } - const options = getElementsByTagName(selectNode, 'option'); - const selected: SimpleElement[] = []; - - for (const option of options) { - // TODO: This is a real discrepancy with SimpleDOM - if ((option as any).selected) { - selected.push(option); - } - } - - const [first, second] = this.guardArray({ selected }, { min: 2 }); - - this.assertHTML(strip` - `); - - this.assert.strictEqual(selected.length, 2, 'two options are selected'); - this.assert.strictEqual( - castToBrowser(first, 'option').value, - '1', - 'first selected item is "1"' - ); - this.assert.strictEqual( - castToBrowser(second, 'option').value, - '2', - 'second selected item is "2"' - ); - } - - @test - 'HTML comments'() { - this.render('
'); - this.assertHTML('
'); - this.assertStableRerender(); - } - - @test - 'Curlies in HTML comments'() { - this.render('
', { foo: 'foo' }); - this.assertHTML('
'); - this.assertStableRerender(); - - this.rerender({ foo: 'bar' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ foo: '' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ foo: 'foo' }); - this.assertHTML('
'); - this.assertStableNodes(); - } - - @test - 'Complex Curlies in HTML comments'() { - this.render('
', { foo: 'foo' }); - this.assertHTML('
'); - this.assertStableRerender(); - - this.rerender({ foo: 'bar' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ foo: '' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ foo: 'foo' }); - this.assertHTML('
'); - this.assertStableNodes(); - } - - @test - 'HTML comments with multi-line mustaches'() { - this.render('
'); - this.assertHTML('
'); - this.assertStableRerender(); - } - - @test - 'Top level comments'() { - this.render(''); - this.assertHTML(''); - this.assertStableRerender(); - } - - @test - 'Handlebars comments'() { - this.render('
{{! Better not break! }}content
'); - this.assertHTML('
content
'); - this.assertStableRerender(); - } - - @test - 'Namespaced attribute'() { - this.render("content"); - this.assertHTML("content"); - this.assertStableRerender(); - } - - @test - 'svg href attribute with quotation marks'() { - this.render( - ``, - { iconLink: 'home' } - ); - this.assertHTML( - `` - ); - const svg = this.element.firstChild; - if (assertNodeTagName(svg, 'svg')) { - const use = svg.firstChild; - if (assertNodeTagName(use, 'use')) { - this.assert.strictEqual(use.href.baseVal, 'home'); - } - } - } - - @test - 'svg href attribute without quotation marks'() { - this.render( - ``, - { iconLink: 'home' } - ); - this.assertHTML( - `` - ); - const svg = this.element.firstChild; - if (assertNodeTagName(svg, 'svg')) { - const use = svg.firstChild; - if (assertNodeTagName(use, 'use')) { - this.assert.strictEqual(use.href.baseVal, 'home'); - } - } - } - - @test - ' tag with case-sensitive attribute'() { - this.render(''); - this.assertHTML(''); - const svg = this.element.firstChild; - if (assertNodeTagName(svg, 'svg')) { - this.assert.strictEqual(svg.namespaceURI, NS_SVG); - this.assert.strictEqual(svg.getAttribute('viewBox'), '0 0 0 0'); - } - this.assertStableRerender(); - } - - @test - 'nested element in the SVG namespace'() { - const d = 'M 0 0 L 100 100'; - this.render(``); - this.assertHTML(``); - - const svg = this.element.firstChild; - - if (assertNodeTagName(svg, 'svg')) { - this.assert.strictEqual(svg.namespaceURI, NS_SVG); - - const path = svg.firstChild; - if (assertNodeTagName(path, 'path')) { - this.assert.strictEqual( - path.namespaceURI, - NS_SVG, - 'creates the path element with a namespace' - ); - this.assert.strictEqual(path.getAttribute('d'), d); - } - } - - this.assertStableRerender(); - } - - @test - ' tag has an SVG namespace'() { - this.render('Hi'); - this.assertHTML('Hi'); - - const svg = this.element.firstChild; - - if (assertNodeTagName(svg, 'svg')) { - this.assert.strictEqual(svg.namespaceURI, NS_SVG); - - const foreignObject = svg.firstChild; - - if (assertNodeTagName(foreignObject, 'foreignObject')) { - this.assert.strictEqual( - foreignObject.namespaceURI, - NS_SVG, - 'creates the foreignObject element with a namespace' - ); - } - } - - this.assertStableRerender(); - } - - @test - 'Namespaced and non-namespaced elements as siblings'() { - this.render('
'); - this.assertHTML('
'); - - const [firstChild, secondChild, thirdChild] = this.guardArray( - { childNodes: this.element.childNodes }, - { min: 3 } - ); - - this.assert.strictEqual( - castToBrowser(unwrap(firstChild), 'SVG').namespaceURI, - NS_SVG, - 'creates the first svg element with a namespace' - ); - - this.assert.strictEqual( - castToBrowser(secondChild, 'SVG').namespaceURI, - NS_SVG, - 'creates the second svg element with a namespace' - ); - - this.assert.strictEqual( - castToBrowser(thirdChild, 'HTML').namespaceURI, - XHTML_NAMESPACE, - 'creates the div element without a namespace' - ); - - this.assertStableRerender(); - } - - @test - 'Namespaced and non-namespaced elements with nesting'() { - this.render('
'); - - const firstDiv = this.element.firstChild; - const secondDiv = this.element.lastChild; - const svg = firstDiv && firstDiv.firstChild; - - this.assertHTML('
'); - - if (assertNodeTagName(firstDiv, 'div')) { - this.assert.strictEqual( - firstDiv.namespaceURI, - XHTML_NAMESPACE, - "first div's namespace is xhtmlNamespace" - ); - } - - if (assertNodeTagName(svg, 'svg')) { - this.assert.strictEqual(svg.namespaceURI, NS_SVG, "svg's namespace is svgNamespace"); - } - - if (assertNodeTagName(secondDiv, 'div')) { - this.assert.strictEqual( - secondDiv.namespaceURI, - XHTML_NAMESPACE, - "last div's namespace is xhtmlNamespace" - ); - } - - this.assertStableRerender(); - } - - @test - 'Case-sensitive tag has capitalization preserved'() { - this.render(''); - this.assertHTML(''); - this.assertStableRerender(); - } - - @test - 'Text curlies'() { - this.render('
{{this.title}}{{this.title}}
', { title: 'hello' }); - this.assertHTML('
hellohello
'); - this.assertStableRerender(); - - this.rerender({ title: 'goodbye' }); - this.assertHTML('
goodbyegoodbye
'); - this.assertStableNodes(); - - this.rerender({ title: '' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ title: 'hello' }); - this.assertHTML('
hellohello
'); - this.assertStableNodes(); - } - - @test - 'Repaired text nodes are ensured in the right place Part 1'() { - this.render('{{this.a}} {{this.b}}', { a: 'A', b: 'B', c: 'C', d: 'D' }); - this.assertHTML('A B'); - this.assertStableRerender(); - } - - @test - 'Repaired text nodes are ensured in the right place Part 2'() { - this.render('
{{this.a}}{{this.b}}{{this.c}}wat{{this.d}}
', { - a: 'A', - b: 'B', - c: 'C', - d: 'D', - }); - this.assertHTML('
ABCwatD
'); - this.assertStableRerender(); - } - - @test - 'Repaired text nodes are ensured in the right place Part 3'() { - this.render('{{this.a}}{{this.b}}', { a: 'A', b: 'B', c: 'C', d: 'D' }); - this.assertHTML('AB'); - this.assertStableRerender(); - } - - @test - 'Path expressions'() { - this.render('
{{this.model.foo.bar}}{{this.model.foo.bar}}
', { - model: { foo: { bar: 'hello' } }, - }); - this.assertHTML('
hellohello
'); - this.assertStableRerender(); - - this.rerender({ model: { foo: { bar: 'goodbye' } } }); - this.assertHTML('
goodbyegoodbye
'); - this.assertStableNodes(); - - this.rerender({ model: { foo: { bar: '' } } }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ model: { foo: { bar: 'hello' } } }); - this.assertHTML('
hellohello
'); - this.assertStableNodes(); - } - - @test - 'Text curlies perform escaping'() { - this.render('
{{this.title}}{{this.title}}
', { - title: 'hello', - }); - this.assertHTML( - '
<strong>hello</strong><strong>hello</strong>
' - ); - this.assertStableRerender(); - - this.rerender({ title: 'goodbye' }); - this.assertHTML('
<i>goodbye</i><i>goodbye</i>
'); - this.assertStableNodes(); - - this.rerender({ title: '' }); - this.assertHTML('
'); - this.assertStableNodes(); - - this.rerender({ title: 'hello' }); - this.assertHTML( - '
<strong>hello</strong><strong>hello</strong>
' - ); - this.assertStableNodes(); - } - - @test - 'Rerender respects whitespace'() { - this.render('Hello {{ this.foo }} ', { foo: 'bar' }); - this.assertHTML('Hello bar '); - this.assertStableRerender(); - - this.rerender({ foo: 'baz' }); - this.assertHTML('Hello baz '); - this.assertStableNodes(); - - this.rerender({ foo: '' }); - this.assertHTML('Hello '); - this.assertStableNodes(); - - this.rerender({ foo: 'bar' }); - this.assertHTML('Hello bar '); - this.assertStableNodes(); - } - - @test - 'Safe HTML curlies'() { - const title = { - toHTML() { - return 'hello world'; - }, - }; - this.render('
{{this.title}}
', { title }); - this.assertHTML('
hello world
'); - this.assertStableRerender(); - } - - @test - 'Triple curlies'() { - const title = 'hello world'; - this.render('
{{{this.title}}}
', { title }); - this.assertHTML('
hello world
'); - this.assertStableRerender(); - } - - @test - 'Triple curlie helpers'() { - this.registerHelper('unescaped', ([param]) => param); - this.registerHelper('escaped', ([param]) => param); - this.render('{{{unescaped "Yolo"}}} {{escaped "Yolo"}}'); - this.assertHTML('Yolo <strong>Yolo</strong>'); - this.assertStableRerender(); - } - - @test - 'Top level triple curlies'() { - const title = 'hello world'; - this.render('{{{this.title}}}', { title }); - this.assertHTML('hello world'); - this.assertStableRerender(); - } - - @test - 'Top level unescaped tr'() { - const title = 'Yo'; - this.render('{{{this.title}}}
', { title }); - this.assertHTML('
Yo
'); - this.assertStableRerender(); - } - - @test - 'The compiler can handle top-level unescaped td inside tr contextualElement'() { - this.render('{{{this.html}}}', { html: 'Yo' }); - this.assertHTML('Yo'); - this.assertStableRerender(); - } - - @test - 'Extreme nesting'() { - this.render( - '{{this.foo}}{{this.bar}}{{this.baz}}{{this.boo}}{{this.brew}}{{this.bat}}{{this.flute}}{{this.argh}}', - { - foo: 'FOO', - bar: 'BAR', - baz: 'BAZ', - boo: 'BOO', - brew: 'BREW', - bat: 'BAT', - flute: 'FLUTE', - argh: 'ARGH', - } - ); - this.assertHTML( - 'FOOBARBAZBOOBREWBATFLUTEARGH' - ); - this.assertStableRerender(); - } - - @test - 'Simple blocks'() { - this.render('
{{#if this.admin}}

{{this.user}}

{{/if}}!
', { - admin: true, - user: 'chancancode', - }); - this.assertHTML('

chancancode

!
'); - this.assertStableRerender(); - - const p = this.element.firstChild!.firstChild!; - - this.rerender({ admin: false }); - this.assertHTML('
!
'); - this.assertStableNodes({ except: p }); - - const comment = this.element.firstChild!.firstChild!; - - this.rerender({ admin: true }); - this.assertHTML('

chancancode

!
'); - this.assertStableNodes({ except: comment }); - } - - @test - 'Nested blocks'() { - this.render( - '
{{#if this.admin}}{{#if this.access}}

{{this.user}}

{{/if}}{{/if}}!
', - { - admin: true, - access: true, - user: 'chancancode', - } - ); - this.assertHTML('

chancancode

!
'); - this.assertStableRerender(); - - let p = this.element.firstChild!.firstChild!; - - this.rerender({ admin: false }); - this.assertHTML('
!
'); - this.assertStableNodes({ except: p }); - - const comment = this.element.firstChild!.firstChild!; - - this.rerender({ admin: true }); - this.assertHTML('

chancancode

!
'); - this.assertStableNodes({ except: comment }); - - p = this.element.firstChild!.firstChild!; - - this.rerender({ access: false }); - this.assertHTML('
!
'); - this.assertStableNodes({ except: p }); - } - - @test - Loops() { - this.render( - '
{{#each this.people key="handle" as |p|}}{{p.handle}} - {{p.name}}{{/each}}
', - { - people: [ - { handle: 'tomdale', name: 'Tom Dale' }, - { handle: 'chancancode', name: 'Godfrey Chan' }, - { handle: 'wycats', name: 'Yehuda Katz' }, - ], - } - ); - - this.assertHTML( - '
tomdale - Tom Dalechancancode - Godfrey Chanwycats - Yehuda Katz
' - ); - this.assertStableRerender(); - - this.rerender({ - people: [ - { handle: 'tomdale', name: 'Thomas Dale' }, - { handle: 'wycats', name: 'Yehuda Katz' }, - ], - }); - - this.assertHTML( - '
tomdale - Thomas Dalewycats - Yehuda Katz
' - ); - } - - @test - 'Simple helpers'() { - this.registerHelper('testing', ([id]) => id); - this.render('
{{testing this.title}}
', { title: 'hello' }); - this.assertHTML('
hello
'); - this.assertStableRerender(); - } - - @test - 'Constant negative numbers can render'() { - this.registerHelper('testing', ([id]) => id); - this.render('
{{testing -123321}}
'); - this.assertHTML('
-123321
'); - this.assertStableRerender(); - } - - @test - 'Large numeric literals (Number.MAX_SAFE_INTEGER)'() { - this.registerHelper('testing', ([id]) => id); - this.render('
{{testing 9007199254740991}}
'); - this.assertHTML('
9007199254740991
'); - this.assertStableRerender(); - } - - @test - 'Integer powers of 2'() { - const ints = []; - let i = 9007199254740991; // Number.MAX_SAFE_INTEGER isn't available on IE11 - while (i > 1) { - ints.push(i); - i = Math.round(i / 2); - } - i = -9007199254740991; // Number.MIN_SAFE_INTEGER isn't available on IE11 - while (i < -1) { - ints.push(i); - i = Math.round(i / 2); - } - this.registerHelper('testing', ([id]) => id); - this.render(ints.map((i) => `{{${i}}}`).join('-')); - this.assertHTML(ints.map((i) => `${i}`).join('-')); - this.assertStableRerender(); - } - - @test - 'odd integers'() { - this.render( - '{{4294967296}} {{4294967295}} {{4294967294}} {{536870913}} {{536870912}} {{536870911}} {{268435455}}' - ); - this.assertHTML('4294967296 4294967295 4294967294 536870913 536870912 536870911 268435455'); - this.assertStableRerender(); - } - - @test - 'Constant float numbers can render'() { - this.registerHelper('testing', ([id]) => id); - this.render('
{{testing 0.123}}
'); - this.assertHTML('
0.123
'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle simple helpers with inline null parameter'() { - let value; - this.registerHelper('say-hello', function (params) { - value = params[0]; - return 'hello'; - }); - this.render('
{{say-hello null}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, null, 'is null'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle simple helpers with inline string literal null parameter'() { - let value; - this.registerHelper('say-hello', function (params) { - value = params[0]; - return 'hello'; - }); - - this.render('
{{say-hello "null"}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, 'null', 'is null string literal'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle simple helpers with inline undefined parameter'() { - let value: unknown = 'PLACEHOLDER'; - let length; - this.registerHelper('say-hello', function (params) { - length = params.length; - value = params[0]; - return 'hello'; - }); - - this.render('
{{say-hello undefined}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(length, 1); - this.assert.strictEqual(value, undefined, 'is undefined'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle simple helpers with positional parameter undefined string literal'() { - let value: unknown = 'PLACEHOLDER'; - let length; - this.registerHelper('say-hello', function (params) { - length = params.length; - value = params[0]; - return 'hello'; - }); - - this.render('
{{say-hello "undefined"}} undefined
'); - this.assertHTML('
hello undefined
'); - this.assert.strictEqual(length, 1); - this.assert.strictEqual(value, 'undefined', 'is undefined string literal'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle components with undefined named arguments'() { - let value: unknown = 'PLACEHOLDER'; - this.registerHelper('say-hello', function (_, hash) { - value = hash['foo']; - return 'hello'; - }); - - this.render('
{{say-hello foo=undefined}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, undefined, 'is undefined'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle components with undefined string literal named arguments'() { - let value: unknown = 'PLACEHOLDER'; - this.registerHelper('say-hello', function (_, hash) { - value = hash['foo']; - return 'hello'; - }); - - this.render('
{{say-hello foo="undefined"}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, 'undefined', 'is undefined string literal'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle components with null named arguments'() { - let value; - this.registerHelper('say-hello', function (_, hash) { - value = hash['foo']; - return 'hello'; - }); - - this.render('
{{say-hello foo=null}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, null, 'is null'); - this.assertStableRerender(); - } - - @test - 'GH#13999 The compiler can handle components with null string literal named arguments'() { - let value; - this.registerHelper('say-hello', function (_, hash) { - value = hash['foo']; - return 'hello'; - }); - - this.render('
{{say-hello foo="null"}}
'); - this.assertHTML('
hello
'); - this.assert.strictEqual(value, 'null', 'is null string literal'); - this.assertStableRerender(); - } - - @test - 'Null curly in attributes'() { - this.render('
hello
'); - this.assertHTML('
hello
'); - this.assertStableRerender(); - } - - @test - 'Null in primitive syntax'() { - this.render('{{#if null}}NOPE{{else}}YUP{{/if}}'); - this.assertHTML('YUP'); - this.assertStableRerender(); - } - - @test - 'Sexpr helpers'() { - this.registerHelper('testing', function (params) { - return `${params[0]}!`; - }); - - this.render('
{{testing (testing "hello")}}
'); - this.assertHTML('
hello!!
'); - this.assertStableRerender(); - } - - @test - 'The compiler can handle multiple invocations of sexprs'() { - this.registerHelper('testing', function (params) { - return `${params[0]}${params[1]}`; - }); - - this.render( - '
{{testing (testing "hello" this.foo) (testing (testing this.bar "lol") this.baz)}}
', - { - foo: 'FOO', - bar: 'BAR', - baz: 'BAZ', - } - ); - this.assertHTML('
helloFOOBARlolBAZ
'); - this.assertStableRerender(); - } - - @test - 'The compiler passes along the hash arguments'() { - this.registerHelper('testing', function (_, hash) { - return `${hash['first']}-${hash['second']}`; - }); - - this.render('
{{testing first="one" second="two"}}
'); - this.assertHTML('
one-two
'); - this.assertStableRerender(); - } - - @test - 'Attributes can be populated with helpers that generate a string'() { - this.registerHelper('testing', function (params) { - return params[0]; - }); - - this.render('linky', { url: 'linky.html' }); - this.assertHTML('linky'); - this.assertStableRerender(); - } - - @test - 'Attribute helpers take a hash'() { - this.registerHelper('testing', function (_, hash) { - return hash['path']; - }); - - this.render('linky', { url: 'linky.html' }); - this.assertHTML('linky'); - this.assertStableRerender(); - } - - @test - 'Attributes containing multiple helpers are treated like a block'() { - this.registerHelper('testing', function (params) { - return params[0]; - }); - - this.render('linky', { - foo: 'foo.com', - bar: 'bar', - }); - this.assertHTML('linky'); - this.assertStableRerender(); - } - - @test - 'Elements inside a yielded block'() { - this.render('{{#if true}}
123
{{/if}}'); - this.assertHTML('
123
'); - this.assertStableRerender(); - } - - @test - 'A simple block helper can return text'() { - this.render('{{#if true}}test{{else}}not shown{{/if}}'); - this.assertHTML('test'); - this.assertStableRerender(); - } -} - -const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/dynamic.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/dynamic.ts new file mode 100644 index 0000000000..cb583d3363 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/dynamic.ts @@ -0,0 +1,1139 @@ +import type { SimpleElement } from '@glimmer/interfaces'; +import { castToBrowser, checkNode, NS_SVG, strip, unwrap } from '@glimmer/util'; +import { + assertNodeTagName, + firstElementChild, + getElementsByTagName, + matrix, +} from '@glimmer-workspace/integration-tests'; + +import { Woops } from '../../test-helpers/error'; + +export const DynamicInitialRenderSuite = matrix('initial render (dynamic)', (spec, errors) => { + // @render + // 'HTML text content'() { + // this.render.template('content'); + // this.assertHTML('content'); + // this.assertStableRerender(); + // } + + spec('Quoted attribute null values do not disable', (ctx) => { + ctx.render.template('', { isDisabled: null }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) + // assertNodeProperty(root.firstChild, 'input', 'disabled', false); + + ctx.rerender({ isDisabled: true }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + + // TODO: ?????????? + ctx.rerender({ isDisabled: false }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + + ctx.rerender({ isDisabled: null }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + }); + + spec('Unquoted attribute null values do not disable', (ctx) => { + ctx.render.template('', { isDisabled: null }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) + // assertNodeProperty(root.firstChild, 'input', 'disabled', false); + + ctx.rerender({ isDisabled: true }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ isDisabled: false }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ isDisabled: null }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + }); + + spec('Quoted attribute string values', (ctx) => { + ctx.render.template("", { src: 'image.png' }); + ctx.assertHTML(""); + ctx.assertStableRerender(); + + ctx.rerender({ src: 'newimage.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: 'image.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + }); + + errors('Quoted attribute string values', { + template: "

{{#-try}}beforeafter{{/-try}}

", + value: 'image.png', + }); + + spec('Unquoted attribute string values', (ctx) => { + ctx.render.template('', { src: 'image.png' }); + ctx.assertHTML(""); + ctx.assertStableRerender(); + + ctx.rerender({ src: 'newimage.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: 'image.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + }); + + errors('Unquoted attribute string values', { + template: '

{{#-try}}beforeafter{{/-try}}

', + value: 'image.png', + }); + + spec('Unquoted img src attribute is not rendered when set to `null`', (ctx) => { + ctx.render.template("", { src: null }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ src: 'newimage.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: null }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + }); + + spec('Unquoted img src attribute is not rendered when set to `undefined`', (ctx) => { + ctx.render.template("", { src: undefined }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ src: 'newimage.png' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ src: undefined }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + }); + + spec('Unquoted a href attribute is not rendered when set to `null`', (ctx) => { + ctx.render.template('', { href: null }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ href: 'http://example.com' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ href: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ href: null }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + }); + + spec('Unquoted a href attribute is not rendered when set to `undefined`', (ctx) => { + ctx.render.template('', { href: undefined }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ href: 'http://example.com' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ href: '' }); + ctx.assertHTML(""); + ctx.assertStableNodes(); + + ctx.rerender({ href: undefined }); + ctx.assertHTML(''); + ctx.assertStableNodes(); + }); + + errors('Attribute expression can be followed by another attribute', { + template: "
{{#-try}}before

after{{/-try}}
", + value: 'oh my', + }); + + spec('Attribute expression can be followed by another attribute', (ctx) => { + ctx.render.template("
", { funstuff: 'oh my' }); + ctx.assertHTML("
"); + ctx.assertStableRerender(); + + ctx.rerender({ funstuff: 'oh boy' }); + ctx.assertHTML("
"); + ctx.assertStableNodes(); + + ctx.rerender({ funstuff: '' }); + ctx.assertHTML("
"); + ctx.assertStableNodes(); + + ctx.rerender({ funstuff: 'oh my' }); + ctx.assertHTML("
"); + ctx.assertStableNodes(); + }); + + spec('Dynamic selected options', (ctx) => { + ctx.render.template( + strip` + + `, + { selected: true } + ); + + ctx.assertHTML(strip` + + `); + + let selectNode = checkNode(castToBrowser(ctx.element, 'HTML').firstElementChild, 'select'); + ctx.assert.strictEqual(selectNode.selectedIndex, 1); + ctx.assertStableRerender(); + + ctx.rerender({ selected: false }); + ctx.assertHTML(strip` + + `); + + selectNode = checkNode(castToBrowser(ctx.element, 'HTML').firstElementChild, 'select'); + + ctx.assert.strictEqual(selectNode.selectedIndex, 0); + + ctx.assertStableNodes(); + + ctx.rerender({ selected: '' }); + + ctx.assertHTML(strip` + + `); + + selectNode = checkNode(castToBrowser(ctx.element, 'HTML').firstElementChild, 'select'); + + ctx.assert.strictEqual(selectNode.selectedIndex, 0); + + ctx.assertStableNodes(); + + ctx.rerender({ selected: true }); + ctx.assertHTML(strip` + + `); + + selectNode = checkNode(castToBrowser(ctx.element, 'HTML').firstElementChild, 'select'); + ctx.assert.strictEqual(selectNode.selectedIndex, 1); + ctx.assertStableNodes(); + }); + + spec('Dynamic multi-select', (ctx) => { + ctx.render.template( + strip` + `, + { + somethingTrue: true, + somethingTruthy: 'is-true', + somethingUndefined: undefined, + somethingNull: null, + somethingFalse: false, + } + ); + + const selectNode = firstElementChild(ctx.element); + ctx.assert.ok(selectNode, 'rendered select'); + if (selectNode === null) { + return; + } + const options = getElementsByTagName(selectNode, 'option'); + const selected: SimpleElement[] = []; + + for (const option of options) { + // TODO: This is a real discrepancy with SimpleDOM + if ((option as any).selected) { + selected.push(option); + } + } + + const [first, second] = ctx.guardArray({ selected }, { min: 2 }); + + ctx.assertHTML(strip` + `); + + ctx.assert.strictEqual(selected.length, 2, 'two options are selected'); + ctx.assert.strictEqual(castToBrowser(first, 'option').value, '1', 'first selected item is "1"'); + ctx.assert.strictEqual( + castToBrowser(second, 'option').value, + '2', + 'second selected item is "2"' + ); + }); + + spec('HTML comments', (ctx) => { + ctx.render.template('
'); + ctx.assertHTML('
'); + ctx.assertStableRerender(); + }); + + spec('Curlies in HTML comments', (ctx) => { + ctx.render.template('
', { foo: 'foo' }); + ctx.assertHTML('
'); + ctx.assertStableRerender(); + + ctx.rerender({ foo: 'bar' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ foo: '' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ foo: 'foo' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + }); + + spec('Complex Curlies in HTML comments', (ctx) => { + ctx.render.template('
', { foo: 'foo' }); + ctx.assertHTML('
'); + ctx.assertStableRerender(); + + ctx.rerender({ foo: 'bar' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ foo: '' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ foo: 'foo' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + }); + + spec('HTML comments with multi-line mustaches', (ctx) => { + ctx.render.template('
'); + ctx.assertHTML('
'); + ctx.assertStableRerender(); + }); + + spec('Top level comments', (ctx) => { + ctx.render.template(''); + ctx.assertHTML(''); + ctx.assertStableRerender(); + }); + + spec('Handlebars comments', (ctx) => { + ctx.render.template('
{{! Better not break! }}content
'); + ctx.assertHTML('
content
'); + ctx.assertStableRerender(); + }); + + errors('Namespaced attribute', { + template: `{{#-try}}{{/-try}}`, + value: 'home', + }); + + spec('Namespaced attribute', (ctx) => { + ctx.render.template("content"); + ctx.assertHTML("content"); + ctx.assertStableRerender(); + }); + + spec('svg href attribute with quotation marks', (ctx) => { + ctx.render.template( + ``, + { iconLink: 'home' } + ); + ctx.assertHTML( + `` + ); + const svg = ctx.element.firstChild; + if (assertNodeTagName(svg, 'svg')) { + const use = svg.firstChild; + if (assertNodeTagName(use, 'use')) { + ctx.assert.strictEqual(use.href.baseVal, 'home'); + } + } + }); + + spec('svg href attribute without quotation marks', (ctx) => { + ctx.render.template( + ``, + { iconLink: 'home' } + ); + ctx.assertHTML( + `` + ); + const svg = ctx.element.firstChild; + if (assertNodeTagName(svg, 'svg')) { + const use = svg.firstChild; + if (assertNodeTagName(use, 'use')) { + ctx.assert.strictEqual(use.href.baseVal, 'home'); + } + } + }); + + spec(' tag with case-sensitive attribute', (ctx) => { + ctx.render.template(''); + ctx.assertHTML(''); + const svg = ctx.element.firstChild; + if (assertNodeTagName(svg, 'svg')) { + ctx.assert.strictEqual(svg.namespaceURI, NS_SVG); + ctx.assert.strictEqual(svg.getAttribute('viewBox'), '0 0 0 0'); + } + ctx.assertStableRerender(); + }); + + spec('nested element in the SVG namespace', (ctx) => { + const d = 'M 0 0 L 100 100'; + ctx.render.template(``); + ctx.assertHTML(``); + + const svg = ctx.element.firstChild; + + if (assertNodeTagName(svg, 'svg')) { + ctx.assert.strictEqual(svg.namespaceURI, NS_SVG); + + const path = svg.firstChild; + if (assertNodeTagName(path, 'path')) { + ctx.assert.strictEqual( + path.namespaceURI, + NS_SVG, + 'creates the path element with a namespace' + ); + ctx.assert.strictEqual(path.getAttribute('d'), d); + } + } + + ctx.assertStableRerender(); + }); + + spec(' tag has an SVG namespace', (ctx) => { + ctx.render.template('Hi'); + ctx.assertHTML('Hi'); + + const svg = ctx.element.firstChild; + + if (assertNodeTagName(svg, 'svg')) { + ctx.assert.strictEqual(svg.namespaceURI, NS_SVG); + + const foreignObject = svg.firstChild; + + if (assertNodeTagName(foreignObject, 'foreignObject')) { + ctx.assert.strictEqual( + foreignObject.namespaceURI, + NS_SVG, + 'creates the foreignObject element with a namespace' + ); + } + } + + ctx.assertStableRerender(); + }); + + spec('Namespaced and non-namespaced elements as siblings', (ctx) => { + ctx.render.template('
'); + ctx.assertHTML('
'); + + const [firstChild, secondChild, thirdChild] = ctx.guardArray( + { childNodes: ctx.element.childNodes }, + { min: 3 } + ); + + ctx.assert.strictEqual( + castToBrowser(unwrap(firstChild), 'SVG').namespaceURI, + NS_SVG, + 'creates the first svg element with a namespace' + ); + + ctx.assert.strictEqual( + castToBrowser(secondChild, 'SVG').namespaceURI, + NS_SVG, + 'creates the second svg element with a namespace' + ); + + ctx.assert.strictEqual( + castToBrowser(thirdChild, 'HTML').namespaceURI, + XHTML_NAMESPACE, + 'creates the div element without a namespace' + ); + + ctx.assertStableRerender(); + }); + + spec('Namespaced and non-namespaced elements with nesting', (ctx) => { + ctx.render.template('
'); + + const firstDiv = ctx.element.firstChild; + const secondDiv = ctx.element.lastChild; + const svg = firstDiv && firstDiv.firstChild; + + ctx.assertHTML('
'); + + if (assertNodeTagName(firstDiv, 'div')) { + ctx.assert.strictEqual( + firstDiv.namespaceURI, + XHTML_NAMESPACE, + "first div's namespace is xhtmlNamespace" + ); + } + + if (assertNodeTagName(svg, 'svg')) { + ctx.assert.strictEqual(svg.namespaceURI, NS_SVG, "svg's namespace is svgNamespace"); + } + + if (assertNodeTagName(secondDiv, 'div')) { + ctx.assert.strictEqual( + secondDiv.namespaceURI, + XHTML_NAMESPACE, + "last div's namespace is xhtmlNamespace" + ); + } + + ctx.assertStableRerender(); + }); + + spec('Case-sensitive tag has capitalization preserved', (ctx) => { + ctx.render.template(''); + ctx.assertHTML(''); + ctx.assertStableRerender(); + }); + + errors('Text curlies', { + template: '
{{#-try}}{{value}}{{value}}{{/-try}}
', + value: 'hello', + }); + + spec('Text curlies', (ctx) => { + ctx.render.template('
{{this.title}}{{this.title}}
', { + title: 'hello', + }); + ctx.assertHTML('
hellohello
'); + ctx.assertStableRerender(); + + ctx.rerender({ title: 'goodbye' }); + ctx.assertHTML('
goodbyegoodbye
'); + ctx.assertStableNodes(); + + ctx.rerender({ title: '' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ title: 'hello' }); + ctx.assertHTML('
hellohello
'); + ctx.assertStableNodes(); + }); + + spec('Text curlies (error handling)', (ctx) => { + const woops = Woops.noop(); + + ctx.render.template( + '
{{#-try this.woops.handleError}}{{this.woops.value}}{{/-try}}{{this.title}}
', + { woops, title: 'hello' } + ); + + ctx.assertHTML('
no woopshello
'); + woops.isError = true; + + // ctx.rerender(); + // ctx.assertHTML('
hello
'); + }); + + spec('Repaired text nodes are ensured in the right place Part 1', (ctx) => { + ctx.render.template('{{this.a}} {{this.b}}', { a: 'A', b: 'B', c: 'C', d: 'D' }); + ctx.assertHTML('A B'); + ctx.assertStableRerender(); + }); + + spec('Repaired text nodes are ensured in the right place Part 2', (ctx) => { + ctx.render.template('
{{this.a}}{{this.b}}{{this.c}}wat{{this.d}}
', { + a: 'A', + b: 'B', + c: 'C', + d: 'D', + }); + ctx.assertHTML('
ABCwatD
'); + ctx.assertStableRerender(); + }); + + spec('Repaired text nodes are ensured in the right place Part 3', (ctx) => { + ctx.render.template('{{this.a}}{{this.b}}', { + a: 'A', + b: 'B', + c: 'C', + d: 'D', + }); + ctx.assertHTML('AB'); + ctx.assertStableRerender(); + }); + + spec('Path expressions', (ctx) => { + ctx.render.template('
{{this.model.foo.bar}}{{this.model.foo.bar}}
', { + model: { foo: { bar: 'hello' } }, + }); + ctx.assertHTML('
hellohello
'); + ctx.assertStableRerender(); + + ctx.rerender({ model: { foo: { bar: 'goodbye' } } }); + ctx.assertHTML('
goodbyegoodbye
'); + ctx.assertStableNodes(); + + ctx.rerender({ model: { foo: { bar: '' } } }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ model: { foo: { bar: 'hello' } } }); + ctx.assertHTML('
hellohello
'); + ctx.assertStableNodes(); + }); + + errors(`Text curlies produce text nodes (not HTML)`, { + template: `
{{#-try}}{{value}}{{value}}{{/-try}}
`, + value: `hello`, + }); + + spec('Text curlies produce text nodes (not HTML)', (ctx) => { + ctx.render.template('
{{this.title}}{{this.title}}
', { + title: 'hello', + }); + ctx.assertHTML( + '
<strong>hello</strong><strong>hello</strong>
' + ); + ctx.assertStableRerender(); + + ctx.rerender({ title: 'goodbye' }); + ctx.assertHTML('
<i>goodbye</i><i>goodbye</i>
'); + ctx.assertStableNodes(); + + ctx.rerender({ title: '' }); + ctx.assertHTML('
'); + ctx.assertStableNodes(); + + ctx.rerender({ title: 'hello' }); + ctx.assertHTML( + '
<strong>hello</strong><strong>hello</strong>
' + ); + ctx.assertStableNodes(); + }); + + errors(`whitespace`, { + template: `
{{#-try}}Hello {{value}}{{/-try}}
`, + value: `world`, + }); + + spec('Rerender respects whitespace', (ctx) => { + ctx.render.template('Hello {{ this.foo }} ', { foo: 'bar' }); + ctx.assertHTML('Hello bar '); + ctx.assertStableRerender(); + + ctx.rerender({ foo: 'baz' }); + ctx.assertHTML('Hello baz '); + ctx.assertStableNodes(); + + ctx.rerender({ foo: '' }); + ctx.assertHTML('Hello '); + ctx.assertStableNodes(); + + ctx.rerender({ foo: 'bar' }); + ctx.assertHTML('Hello bar '); + ctx.assertStableNodes(); + }); + + spec('Safe HTML curlies', (ctx) => { + const title = { + toHTML() { + return 'hello world'; + }, + }; + ctx.render.template('
{{this.title}}
', { title }); + ctx.assertHTML('
hello world
'); + ctx.assertStableRerender(); + }); + + spec('Triple curlies', (ctx) => { + const title = 'hello world'; + ctx.render.template('
{{{this.title}}}
', { title }); + ctx.assertHTML('
hello world
'); + ctx.assertStableRerender(); + }); + + spec('Triple curlie helpers', (ctx) => { + ctx.register.helper('unescaped', ([param]) => param); + ctx.register.helper('escaped', ([param]) => param); + ctx.render.template( + '{{{unescaped "Yolo"}}} {{escaped "Yolo"}}' + ); + ctx.assertHTML('Yolo <strong>Yolo</strong>'); + ctx.assertStableRerender(); + }); + + spec('Top level triple curlies', (ctx) => { + const title = 'hello world'; + ctx.render.template('{{{this.title}}}', { title }); + ctx.assertHTML('hello world'); + ctx.assertStableRerender(); + }); + + errors(`Top level triple curlies`, { + template: `{{#-try}}{{{value}}}{{/-try}}`, + value: `hello world`, + }); + + spec('Top level unescaped tr', (ctx) => { + const title = 'Yo'; + ctx.render.template('{{{this.title}}}
', { title }); + ctx.assertHTML('
Yo
'); + ctx.assertStableRerender(); + }); + + errors(`Top level unescaped tr`, { + template: `{{#-try}}{{{value}}}{{/-try}}
`, + value: `Yo`, + }); + + spec('The compiler can handle top-level unescaped td inside tr contextualElement', (ctx) => { + ctx.render.template('{{{this.html}}}', { html: 'Yo' }); + ctx.assertHTML('Yo'); + ctx.assertStableRerender(); + }); + + errors(`unescaped td inside tr`, { + template: `{{#-try}}{{{value}}}{{/-try}}`, + value: `Yo`, + }); + + spec('Extreme nesting', (ctx) => { + ctx.render.template( + '{{this.foo}}{{this.bar}}{{this.baz}}{{this.boo}}{{this.brew}}{{this.bat}}{{this.flute}}{{this.argh}}', + { + foo: 'FOO', + bar: 'BAR', + baz: 'BAZ', + boo: 'BOO', + brew: 'BREW', + bat: 'BAT', + flute: 'FLUTE', + argh: 'ARGH', + } + ); + ctx.assertHTML( + 'FOOBARBAZBOOBREWBATFLUTEARGH' + ); + ctx.assertStableRerender(); + }); + + spec('Simple blocks', (ctx) => { + ctx.render.template('
{{#if this.admin}}

{{this.user}}

{{/if}}!
', { + admin: true, + user: 'chancancode', + }); + ctx.assertHTML('

chancancode

!
'); + ctx.assertStableRerender(); + + const p = ctx.element.firstChild!.firstChild!; + + ctx.rerender({ admin: false }); + ctx.assertHTML('
!
'); + ctx.assertStableNodes({ except: p }); + + const comment = ctx.element.firstChild!.firstChild!; + + ctx.rerender({ admin: true }); + ctx.assertHTML('

chancancode

!
'); + ctx.assertStableNodes({ except: comment }); + }); + + spec('Nested blocks', (ctx) => { + ctx.render.template( + '
{{#if this.admin}}{{#if this.access}}

{{this.user}}

{{/if}}{{/if}}!
', + { + admin: true, + access: true, + user: 'chancancode', + } + ); + ctx.assertHTML('

chancancode

!
'); + ctx.assertStableRerender(); + + let p = ctx.element.firstChild!.firstChild!; + + ctx.rerender({ admin: false }); + ctx.assertHTML('
!
'); + ctx.assertStableNodes({ except: p }); + + const comment = ctx.element.firstChild!.firstChild!; + + ctx.rerender({ admin: true }); + ctx.assertHTML('

chancancode

!
'); + ctx.assertStableNodes({ except: comment }); + + p = ctx.element.firstChild!.firstChild!; + + ctx.rerender({ access: false }); + ctx.assertHTML('
!
'); + ctx.assertStableNodes({ except: p }); + }); + + spec('Loops', (ctx) => { + ctx.render.template( + '
{{#each this.people key="handle" as |p|}}{{p.handle}} - {{p.name}}{{/each}}
', + { + people: [ + { handle: 'tomdale', name: 'Tom Dale' }, + { handle: 'chancancode', name: 'Godfrey Chan' }, + { handle: 'wycats', name: 'Yehuda Katz' }, + ], + } + ); + + ctx.assertHTML( + '
tomdale - Tom Dalechancancode - Godfrey Chanwycats - Yehuda Katz
' + ); + ctx.assertStableRerender(); + + ctx.rerender({ + people: [ + { handle: 'tomdale', name: 'Thomas Dale' }, + { handle: 'wycats', name: 'Yehuda Katz' }, + ], + }); + + ctx.assertHTML( + '
tomdale - Thomas Dalewycats - Yehuda Katz
' + ); + }); + + spec('Simple helpers', (ctx) => { + ctx.register.helper('testing', ([id]) => id); + ctx.render.template('
{{testing this.title}}
', { title: 'hello' }); + ctx.assertHTML('
hello
'); + ctx.assertStableRerender(); + }); + + spec('Constant negative numbers can render', (ctx) => { + ctx.register.helper('testing', ([id]) => id); + ctx.render.template('
{{testing -123321}}
'); + ctx.assertHTML('
-123321
'); + ctx.assertStableRerender(); + }); + + spec('Large numeric literals (Number.MAX_SAFE_INTEGER)', (ctx) => { + ctx.register.helper('testing', ([id]) => id); + ctx.render.template('
{{testing 9007199254740991}}
'); + ctx.assertHTML('
9007199254740991
'); + ctx.assertStableRerender(); + }); + + spec('Integer powers of 2', (ctx) => { + const ints = []; + let i = 9007199254740991; // Number.MAX_SAFE_INTEGER isn't available on IE11 + while (i > 1) { + ints.push(i); + i = Math.round(i / 2); + } + i = -9007199254740991; // Number.MIN_SAFE_INTEGER isn't available on IE11 + while (i < -1) { + ints.push(i); + i = Math.round(i / 2); + } + ctx.register.helper('testing', ([id]) => id); + ctx.render.template(ints.map((i) => `{{${i}}}`).join('-')); + ctx.assertHTML(ints.map((i) => `${i}`).join('-')); + ctx.assertStableRerender(); + }); + + spec('odd integers', (ctx) => { + ctx.render.template( + '{{4294967296}} {{4294967295}} {{4294967294}} {{536870913}} {{536870912}} {{536870911}} {{268435455}}' + ); + ctx.assertHTML('4294967296 4294967295 4294967294 536870913 536870912 536870911 268435455'); + ctx.assertStableRerender(); + }); + + spec('Constant float numbers can render', (ctx) => { + ctx.register.helper('testing', ([id]) => id); + ctx.render.template('
{{testing 0.123}}
'); + ctx.assertHTML('
0.123
'); + ctx.assertStableRerender(); + }); + + spec('GH#13999 The compiler can handle simple helpers with inline null parameter', (ctx) => { + let value; + ctx.register.helper('say-hello', (params) => { + value = params[0]; + return 'hello'; + }); + ctx.render.template('
{{say-hello null}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, null, 'is null'); + ctx.assertStableRerender(); + }); + + spec( + 'GH#13999 The compiler can handle simple helpers with inline string literal null parameter', + (ctx) => { + let value; + ctx.register.helper('say-hello', (params) => { + value = params[0]; + return 'hello'; + }); + + ctx.render.template('
{{say-hello "null"}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, 'null', 'is null string literal'); + ctx.assertStableRerender(); + } + ); + + spec('GH#13999 The compiler can handle simple helpers with inline undefined parameter', (ctx) => { + let value: unknown = 'PLACEHOLDER'; + let length; + ctx.register.helper('say-hello', (params) => { + length = params.length; + value = params[0]; + return 'hello'; + }); + + ctx.render.template('
{{say-hello undefined}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(length, 1); + ctx.assert.strictEqual(value, undefined, 'is undefined'); + ctx.assertStableRerender(); + }); + + spec( + 'GH#13999 The compiler can handle simple helpers with positional parameter undefined string literal', + (ctx) => { + let value: unknown = 'PLACEHOLDER'; + let length; + ctx.register.helper('say-hello', (params) => { + length = params.length; + value = params[0]; + return 'hello'; + }); + + ctx.render.template('
{{say-hello "undefined"}} undefined
'); + ctx.assertHTML('
hello undefined
'); + ctx.assert.strictEqual(length, 1); + ctx.assert.strictEqual(value, 'undefined', 'is undefined string literal'); + ctx.assertStableRerender(); + } + ); + + spec('GH#13999 The compiler can handle components with undefined named arguments', (ctx) => { + let value: unknown = 'PLACEHOLDER'; + ctx.register.helper('say-hello', (_, hash) => { + value = hash['foo']; + return 'hello'; + }); + + ctx.render.template('
{{say-hello foo=undefined}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, undefined, 'is undefined'); + ctx.assertStableRerender(); + }); + + spec( + 'GH#13999 The compiler can handle components with undefined string literal named arguments', + (ctx) => { + let value: unknown = 'PLACEHOLDER'; + ctx.register.helper('say-hello', (_, hash) => { + value = hash['foo']; + return 'hello'; + }); + + ctx.render.template('
{{say-hello foo="undefined"}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, 'undefined', 'is undefined string literal'); + ctx.assertStableRerender(); + } + ); + + spec('GH#13999 The compiler can handle components with null named arguments', (ctx) => { + let value; + ctx.register.helper('say-hello', (_, hash) => { + value = hash['foo']; + return 'hello'; + }); + + ctx.render.template('
{{say-hello foo=null}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, null, 'is null'); + ctx.assertStableRerender(); + }); + + spec( + 'GH#13999 The compiler can handle components with null string literal named arguments', + (ctx) => { + let value; + ctx.register.helper('say-hello', (_, hash) => { + value = hash['foo']; + return 'hello'; + }); + + ctx.render.template('
{{say-hello foo="null"}}
'); + ctx.assertHTML('
hello
'); + ctx.assert.strictEqual(value, 'null', 'is null string literal'); + ctx.assertStableRerender(); + } + ); + + spec('Null curly in attributes', (ctx) => { + ctx.render.template('
hello
'); + ctx.assertHTML('
hello
'); + ctx.assertStableRerender(); + }); + + spec('Null in primitive syntax', (ctx) => { + ctx.render.template('{{#if null}}NOPE{{else}}YUP{{/if}}'); + ctx.assertHTML('YUP'); + ctx.assertStableRerender(); + }); + + spec('Sexpr helpers', (ctx) => { + ctx.register.helper('testing', (params) => { + return `${params[0]}!`; + }); + + ctx.render.template('
{{testing (testing "hello")}}
'); + ctx.assertHTML('
hello!!
'); + ctx.assertStableRerender(); + }); + + spec('The compiler can handle multiple invocations of sexprs', (ctx) => { + ctx.register.helper('testing', (params) => { + return `${params[0]}${params[1]}`; + }); + + ctx.render.template( + '
{{testing (testing "hello" this.foo) (testing (testing this.bar "lol") this.baz)}}
', + { + foo: 'FOO', + bar: 'BAR', + baz: 'BAZ', + } + ); + ctx.assertHTML('
helloFOOBARlolBAZ
'); + ctx.assertStableRerender(); + }); + + spec('The compiler passes along the hash arguments', (ctx) => { + ctx.register.helper('testing', (_, hash) => { + return `${hash['first']}-${hash['second']}`; + }); + + ctx.render.template('
{{testing first="one" second="two"}}
'); + ctx.assertHTML('
one-two
'); + ctx.assertStableRerender(); + }); + + spec('Attributes can be populated with helpers that generate a string', (ctx) => { + ctx.register.helper('testing', (params) => { + return params[0]; + }); + + ctx.render.template('linky', { url: 'linky.html' }); + ctx.assertHTML('linky'); + ctx.assertStableRerender(); + }); + + spec('Attribute helpers take a hash', (ctx) => { + ctx.register.helper('testing', (_, hash) => { + return hash['path']; + }); + + ctx.render.template('linky', { url: 'linky.html' }); + ctx.assertHTML('linky'); + ctx.assertStableRerender(); + }); + + spec('Attributes containing multiple helpers are treated like a block', (ctx) => { + ctx.register.helper('testing', (params) => { + return params[0]; + }); + + ctx.render.template( + 'linky', + { + foo: 'foo.com', + bar: 'bar', + } + ); + ctx.assertHTML('linky'); + ctx.assertStableRerender(); + }); + + spec('Elements inside a yielded block', (ctx) => { + ctx.render.template('{{#if true}}
123
{{/if}}'); + ctx.assertHTML('
123
'); + ctx.assertStableRerender(); + }); + + spec('A simple block helper can return text', (ctx) => { + ctx.render.template('{{#if true}}test{{else}}not shown{{/if}}'); + ctx.assertHTML('test'); + ctx.assertStableRerender(); + }); +}); + +DynamicInitialRenderSuite.client(); + +const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/index.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/index.ts new file mode 100644 index 0000000000..93db0e1555 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/index.ts @@ -0,0 +1,6 @@ +import { DynamicInitialRenderSuite } from './dynamic'; +import { InitialRenderTests } from './static'; + +InitialRenderTests.client(); + +export { DynamicInitialRenderSuite, InitialRenderTests }; diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/static.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/static.ts new file mode 100644 index 0000000000..b744c90e77 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/initial-render/static.ts @@ -0,0 +1,192 @@ +import { strip } from '@glimmer/util'; + +import { matrix } from '../../matrix'; + +export const InitialRenderTests = matrix('initial render (static)', (test) => { + test('HTML text content', (ctx) => { + ctx.render.template('content'); + ctx.assertHTML('content'); + ctx.assertStableRerender(); + }); + + test('HTML tags', (ctx) => { + ctx.render.template('

hello!

content
'); + ctx.assertHTML('

hello!

content
'); + ctx.assertStableRerender(); + }); + + test('HTML attributes', (ctx) => { + ctx.render.template("
content
"); + ctx.assertHTML("
content
"); + ctx.assertStableRerender(); + }); + + test('HTML data attributes', (ctx) => { + ctx.render.template("
content
"); + ctx.assertHTML("
content
"); + ctx.assertStableRerender(); + }); + + test('HTML checked attributes', (ctx) => { + ctx.render.template(""); + ctx.assertHTML(``); + ctx.assertStableRerender(); + }); + + test('HTML selected options', (ctx) => { + ctx.render.template(strip` + + `); + ctx.assertHTML(strip` + + `); + ctx.assertStableRerender(); + }); + + test('HTML multi-select options', (ctx) => { + ctx.render.template(strip` + + `); + ctx.assertHTML(strip` + + `); + ctx.assertStableRerender(); + }); + + test('Void Elements', (ctx) => { + const voidElements = 'area base br embed hr img input keygen link meta param source track wbr'; + voidElements.split(' ').forEach((tagName) => ctx.shouldBeVoid(tagName)); + }); + + test('Nested HTML', (ctx) => { + ctx.render.template( + "

hi!

 More content" + ); + ctx.assertHTML( + "

hi!

 More content" + ); + ctx.assertStableRerender(); + }); + + test('Custom Elements', (ctx) => { + ctx.render.template(''); + ctx.assertHTML(''); + ctx.assertStableRerender(); + }); + + test('Nested Custom Elements', (ctx) => { + ctx.render.template( + "Stuff
Here
" + ); + ctx.assertHTML( + "Stuff
Here
" + ); + ctx.assertStableRerender(); + }); + + test('Moar nested Custom Elements', (ctx) => { + ctx.render.template( + "Here" + ); + ctx.assertHTML( + "Here" + ); + ctx.assertStableRerender(); + }); + + test('Custom Elements with dynamic attributes', (ctx) => { + ctx.render.template( + "", + { someDynamicBits: 'things' } + ); + ctx.assertHTML(""); + ctx.assertStableRerender(); + }); + + test('Custom Elements with dynamic content', (ctx) => { + ctx.render.template('{{this.derp}}', { derp: 'stuff' }); + ctx.assertHTML('stuff'); + ctx.assertStableRerender(); + }); + + test('Dynamic content within single custom element', (ctx) => { + ctx.render.template('{{#if this.derp}}Content Here{{/if}}', { derp: 'stuff' }); + ctx.assertHTML('Content Here'); + ctx.assertStableRerender(); + + ctx.rerender({ derp: false }); + ctx.assertHTML(''); + ctx.assertStableRerender(); + + ctx.rerender({ derp: true }); + ctx.assertHTML('Content Here'); + ctx.assertStableRerender(); + + ctx.rerender({ derp: 'stuff' }); + ctx.assertHTML('Content Here'); + ctx.assertStableRerender(); + }); + + test('Supports quotes', (ctx) => { + ctx.render.template('
"This is a title," we\'re on a boat
'); + ctx.assertHTML('
"This is a title," we\'re on a boat
'); + ctx.assertStableRerender(); + }); + + test('Supports backslashes', (ctx) => { + ctx.render.template('
This is a backslash: \\
'); + ctx.assertHTML('
This is a backslash: \\
'); + ctx.assertStableRerender(); + }); + + test('Supports new lines', (ctx) => { + ctx.render.template('
common\n\nbro
'); + ctx.assertHTML('
common\n\nbro
'); + ctx.assertStableRerender(); + }); + + test('HTML tag with empty attribute', (ctx) => { + ctx.render.template("
content
"); + ctx.assertHTML("
content
"); + ctx.assertStableRerender(); + }); + + test('Attributes containing a helper are treated like a block', (ctx) => { + ctx.register.helper('testing', (params) => { + ctx.assert.deepEqual(params, [123]); + return 'example.com'; + }); + + ctx.render.template('linky'); + ctx.assertHTML('linky'); + ctx.assertStableRerender(); + }); + + test("HTML boolean attribute 'disabled'", (ctx) => { + ctx.render.template(''); + ctx.assertHTML(''); + + // TODO: What is the point of this test? (Note that it wouldn't work with SimpleDOM) + // assertNodeProperty(root.firstChild, 'input', 'disabled', true); + + ctx.assertStableRerender(); + }); +}); + +InitialRenderTests.client(); diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/scope.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/scope.ts index 87f8764d13..ffa7259fc9 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/scope.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/scope.ts @@ -1,13 +1,13 @@ -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; import { stripTight } from '../test-helpers/strings'; -export class ScopeSuite extends RenderTest { +export class ScopeSuite extends RenderTestContext { static suiteName = 'Scope'; - @test + @render 'correct scope - conflicting local names'() { - this.render({ + this.render.template({ layout: stripTight` {{#with @a as |item|}}{{@a}}: {{item}}, {{#with @b as |item|}} {{@b}}: {{item}}, @@ -21,9 +21,9 @@ export class ScopeSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'correct scope - conflicting block param and attr names'() { - this.render({ + this.render.template({ layout: 'Outer: {{@conflict}} {{#with @item as |conflict|}}Inner: {{@conflict}} Block: {{conflict}}{{/with}}', args: { item: '"from block"', conflict: '"from attr"' }, diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/shadowing.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/shadowing.ts index ba15fc771a..402ca3ecfc 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/shadowing.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/shadowing.ts @@ -1,12 +1,12 @@ -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; -export class ShadowingSuite extends RenderTest { +export class ShadowingSuite extends RenderTestContext { static suiteName = 'Shadowing'; - @test({ kind: 'glimmer' }) + @render('glimmer') 'normal outer attributes are reflected'() { - this.render({ + this.render.template({ layout: 'In layout - someProp: {{@someProp}}', args: { someProp: '"something here"' }, }); @@ -15,9 +15,9 @@ export class ShadowingSuite extends RenderTest { this.assertStableRerender(); } - @test({ kind: 'glimmer' }) + @render('glimmer') 'shadowing - normal outer attributes clobber inner attributes'() { - this.render({ + this.render.template({ layout: 'Hello!', layoutAttributes: { 'data-name': '"Godfrey"', 'data-foo': '"foo"' }, attributes: { 'data-name': '"Godfrey"', 'data-foo': '"bar"' }, @@ -28,9 +28,9 @@ export class ShadowingSuite extends RenderTest { this.assertStableRerender(); } - @test({ kind: 'glimmer' }) + @render('glimmer') 'outer attributes with concat are reflected'() { - this.render( + this.render.template( { layout: 'In layout - someProp: {{@someProp}}', args: { someProp: 'this.someProp' }, @@ -54,11 +54,11 @@ export class ShadowingSuite extends RenderTest { this.assertStableNodes(); } - @test({ kind: 'glimmer' }) + @render('glimmer') 'outer attributes with concat clobber inner attributes'() { - this.render( + this.render.template( { - layoutAttributes: { 'data-name': 'Godfrey', 'data-foo': 'foo' }, + layoutAttributes: { 'data-name': '"Godfrey"', 'data-foo': '"foo"' }, layout: 'Hello!', attributes: { 'data-name': '"{{this.name}}"', 'data-foo': '"{{this.foo}}-bar"' }, }, @@ -81,11 +81,11 @@ export class ShadowingSuite extends RenderTest { this.assertStableNodes(); } - @test({ kind: 'glimmer' }) + @render('glimmer') 'outer attributes clobber inner attributes with concat'() { - this.render( + this.render.template( { - layoutAttributes: { 'data-name': '{{@name}}', 'data-foo': '"{{@foo}}-bar"' }, + layoutAttributes: { 'data-name': '@name', 'data-foo': '"{{@foo}}-bar"' }, layout: 'Hello!', args: { name: 'this.name', foo: 'this.foo' }, attributes: { 'data-name': '"Godhuda"', 'data-foo': '"foo-bar"' }, diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/ssr.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/ssr.ts index 5912f77607..c1b9f3176a 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/ssr.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/ssr.ts @@ -1,74 +1,74 @@ -import { AbstractNodeTest } from '../modes/node/env'; -import { test } from '../test-decorator'; +import { NodeRenderTest } from '../modes/node/env'; +import { render } from '../test-decorator'; -export class ServerSideSuite extends AbstractNodeTest { +export class ServerSideSuite extends NodeRenderTest { static suiteName = 'Server Side Rendering'; - @test + @render 'HTML text content'() { - this.render('content'); + this.render.template('content'); this.assertHTML('content'); } - @test + @render 'HTML tags'() { - this.render('

hello!

content
'); + this.render.template('

hello!

content
'); this.assertHTML('

hello!

content
'); } - @test + @render 'HTML tags re-rendered'() { - this.render('

hello!

content
'); + this.render.template('

hello!

content
'); this.assertHTML('

hello!

content
'); this.rerender(); this.assertHTML('

hello!

content
'); } - @test + @render 'HTML attributes'() { - this.render("
content
"); + this.render.template("
content
"); this.assertHTML('
content
'); } - @test + @render 'HTML tag with empty attribute'() { - this.render("
content
"); + this.render.template("
content
"); this.assertHTML('
content
'); } - @test + @render "HTML boolean attribute 'disabled'"() { - this.render(''); + this.render.template(''); this.assertHTML(''); } - @test + @render 'Quoted attribute expression is removed when null'() { - this.render('', { isDisabled: null }); + this.render.template('', { isDisabled: null }); this.assertHTML(''); } - @test + @render 'Unquoted attribute expression with null value is not coerced'() { - this.render('', { isDisabled: null }); + this.render.template('', { isDisabled: null }); this.assertHTML(''); } - @test + @render 'Attribute expression can be followed by another attribute'() { - this.render('
', { funstuff: 'oh my' }); + this.render.template('
', { funstuff: 'oh my' }); this.assertHTML('
'); } - @test + @render 'HTML tag with data- attribute'() { - this.render("
content
"); + this.render.template("
content
"); this.assertHTML('
content
'); } - @test + @render 'The compiler can handle nesting'() { - this.render( + this.render.template( '

hi!

 More content' ); @@ -78,102 +78,103 @@ export class ServerSideSuite extends AbstractNodeTest { ); } - @test + @render 'The compiler can handle comments'() { - this.render('
'); + this.render.template('
'); this.assertHTML('
'); } - @test + @render 'The compiler can handle HTML comments with mustaches in them'() { - this.render('
', { foo: 'bar' }); + this.render.template('
', { foo: 'bar' }); this.assertHTML('
'); } - @test + @render 'The compiler can handle HTML comments with complex mustaches in them'() { - this.render('
', { foo: 'bar' }); + this.render.template('
', { foo: 'bar' }); this.assertHTML('
'); } - @test + @render 'The compiler can handle HTML comments with multi-line mustaches in them'() { - this.render('
', { foo: 'bar' }); + this.render.template('
', { foo: 'bar' }); this.assertHTML('
'); } - @test + @render 'The compiler can handle comments with no parent element'() { - this.render('', { foo: 'bar' }); + this.render.template('', { foo: 'bar' }); this.assertHTML(''); } - @test + @render 'The compiler can handle simple handlebars'() { - this.render('
{{this.title}}
', { title: 'hello' }); + this.render.template('
{{this.title}}
', { title: 'hello' }); this.assertHTML('
hello
'); } - @test + @render 'The compiler can handle escaping HTML'() { - this.render('
{{this.title}}
', { title: 'hello' }); + this.render.template('
{{this.title}}
', { title: 'hello' }); this.assertHTML('
<strong>hello</strong>
'); } - @test + @render 'The compiler can handle unescaped HTML'() { - this.render('
{{{this.title}}}
', { title: 'hello' }); + this.render.template('
{{{this.title}}}
', { title: 'hello' }); this.assertHTML('
hello
'); } - @test + @render 'Unescaped helpers render correctly'() { - this.registerHelper('testing-unescaped', (params) => params[0]); - this.render('{{{testing-unescaped "hi"}}}'); + this.register.helper('testing-unescaped', (params) => params[0] ); + this.render.template('{{{testing-unescaped "hi"}}}'); this.assertHTML('hi'); } - @test + @render 'Null literals do not have representation in DOM'() { - this.render('{{null}}'); + this.render.template('{{null}}'); this.assertHTML(''); } - @test + @render 'Attributes can be populated with helpers that generate a string'() { - this.registerHelper('testing', (params) => { - return params[0]; - }); + this.register.helper('testing', (params) => { + return params[0]; + }, + ); - this.render('linky', { url: 'linky.html' }); + this.render.template('linky', { url: 'linky.html' }); this.assertHTML('linky'); } - @test + @render 'Elements inside a yielded block'() { - this.render('{{#if true}}
123
{{/if}}'); + this.render.template('{{#if true}}
123
{{/if}}'); this.assertHTML('
123
'); } - @test + @render 'A simple block helper can return text'() { - this.render('{{#if true}}test{{else}}not shown{{/if}}'); + this.render.template('{{#if true}}test{{else}}not shown{{/if}}'); this.assertHTML('test'); } - @test + @render 'SVG: basic element'() { let template = ` `; - this.render(template); + this.render.template(template); this.assertHTML(template); } - @test + @render 'SVG: element with xlink:href'() { let template = ` @@ -183,35 +184,35 @@ export class ServerSideSuite extends AbstractNodeTest { `; - this.render(template); + this.render.template(template); this.assertHTML(template); } } -export class ServerSideComponentSuite extends AbstractNodeTest { +export class ServerSideComponentSuite extends NodeRenderTest { static suiteName = 'Server Side Components'; - @test + @render 'can render components'() { - this.render({ + this.render.template({ layout: '

Hello World!

', }); this.assertComponent('

Hello World!

'); } - @test + @render 'can render components with yield'() { - this.render({ + this.render.template({ layout: '

Hello {{yield}}!

', template: 'World', }); this.assertComponent('

Hello World!

'); } - @test + @render 'can render components with args'() { - this.render( + this.render.template( { layout: '

Hello {{@place}}!

', template: 'World', @@ -222,9 +223,9 @@ export class ServerSideComponentSuite extends AbstractNodeTest { this.assertComponent('

Hello World!

'); } - @test + @render 'can render components with block params'() { - this.render( + this.render.template( { layout: '

Hello {{yield @place}}!

', template: '{{place}}', diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/with-dynamic-vars.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/with-dynamic-vars.ts index 7608fcddf8..f7c2eab62a 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/with-dynamic-vars.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/with-dynamic-vars.ts @@ -1,11 +1,11 @@ -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render } from '../test-decorator'; -export class WithDynamicVarsSuite extends RenderTest { +export class WithDynamicVarsSuite extends RenderTestContext { static suiteName = '-with-dynamic-vars and -get-dynamic-var'; - @test + @render 'Can get and set dynamic variable'() { - this.render( + this.render.template( { layout: '{{#-with-dynamic-vars myKeyword=@value}}{{yield}}{{/-with-dynamic-vars}}', template: '{{-get-dynamic-var "myKeyword"}}', @@ -26,9 +26,9 @@ export class WithDynamicVarsSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'Can get and set dynamic variable with bound names'() { - this.render( + this.render.template( { layout: '{{#-with-dynamic-vars myKeyword=@value1 secondKeyword=@value2}}{{yield}}{{/-with-dynamic-vars}}', @@ -54,9 +54,9 @@ export class WithDynamicVarsSuite extends RenderTest { this.assertStableNodes(); } - @test + @render 'Can shadow existing dynamic variable'() { - this.render( + this.render.template( { layout: '{{#-with-dynamic-vars myKeyword=@outer}}
{{-get-dynamic-var "myKeyword"}}
{{#-with-dynamic-vars myKeyword=@inner}}{{yield}}{{/-with-dynamic-vars}}
{{-get-dynamic-var "myKeyword"}}
{{/-with-dynamic-vars}}', diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/yield.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/yield.ts index 3f6f99ebeb..ebbf008aa6 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/yield.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/yield.ts @@ -1,12 +1,11 @@ -import { RenderTest } from '../render-test'; -import { test } from '../test-decorator'; +import { RenderTestContext } from '../render-test'; +import { render, suite } from '../test-decorator'; -export class YieldSuite extends RenderTest { - static suiteName = 'yield'; - - @test +@suite('yield') +export class YieldSuite extends RenderTestContext { + @render yield() { - this.render( + this.render.template( { layout: '{{#if @predicate}}Yes:{{yield @someValue}}{{else}}No:{{yield to="inverse"}}{{/if}}', @@ -22,11 +21,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test({ - skip: 'glimmer', - }) + @render 'yield to "inverse"'() { - this.render( + this.render.template( { layout: '{{#if @predicate}}Yes:{{yield @someValue}}{{else}}No:{{yield to="inverse"}}{{/if}}', @@ -42,11 +39,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test({ - skip: 'glimmer', - }) + @render 'yield to "else"'() { - this.render( + this.render.template( { layout: '{{#if @predicate}}Yes:{{yield @someValue}}{{else}}No:{{yield to="else"}}{{/if}}', args: { predicate: 'this.activated', someValue: '42' }, @@ -61,9 +56,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding to an non-existent block'() { - this.render({ + this.render.template({ layout: 'Before-{{yield}}-After', }); @@ -71,9 +66,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding a string and rendering its length'() { - this.render({ + this.render.template({ layout: `{{yield "foo"}}-{{yield ""}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.length}}', @@ -83,12 +78,10 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test({ - skip: 'glimmer', - }) + @render 'use a non-existent block param'() { - this.render({ - layout: '{{yield this.someValue}}', + this.render.template({ + layout: '{{yield @someValue}}', args: { someValue: '42' }, blockParams: ['val1', 'val2'], template: '{{val1}} - {{val2}}', @@ -98,9 +91,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'block without properties'() { - this.render({ + this.render.template({ layout: 'In layout -- {{yield}}', template: 'In template', }); @@ -109,9 +102,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding true'() { - this.render({ + this.render.template({ layout: `{{yield true}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -121,9 +114,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding false'() { - this.render({ + this.render.template({ layout: `{{yield false}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -133,9 +126,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding null'() { - this.render({ + this.render.template({ layout: `{{yield null}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -145,9 +138,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding undefined'() { - this.render({ + this.render.template({ layout: `{{yield undefined}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -157,9 +150,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding integers'() { - this.render({ + this.render.template({ layout: `{{yield 123}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -169,9 +162,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding floats'() { - this.render({ + this.render.template({ layout: `{{yield 123.45}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -181,9 +174,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yielding strings'() { - this.render({ + this.render.template({ layout: `{{yield "hello"}}`, blockParams: ['yielded'], template: '{{yielded}}-{{yielded.foo.bar}}', @@ -193,9 +186,9 @@ export class YieldSuite extends RenderTest { this.assertStableRerender(); } - @test + @render 'yield inside a conditional on the component'() { - this.render( + this.render.template( { layout: 'In layout -- {{#if @predicate}}{{yield}}{{/if}}', template: 'In template', diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-decorator.ts b/packages/@glimmer-workspace/integration-tests/lib/test-decorator.ts index a610fa243b..6e012674d4 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/test-decorator.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/test-decorator.ts @@ -1,35 +1,170 @@ -import { keys } from '@glimmer/util'; +import { LOCAL_LOGGER } from '@glimmer/util'; -export type DeclaredComponentKind = 'glimmer' | 'curly' | 'dynamic' | 'templateOnly'; +import type { IRenderTest } from './render-test'; +import type { DeclaredComponentType, EveryComponentType } from './test-helpers/constants'; +import type { RenderTestConstructor, RenderTestState } from './test-helpers/module'; + +import { isTest, TEST_META } from './test-helpers/test'; + +type ComponentTestOptions = + | Partial> + | ComponentTestMeta['kind']; + +type NormalizedComponentTestOptions = Pick; export interface ComponentTestMeta { - kind?: DeclaredComponentKind; - skip?: boolean | DeclaredComponentKind; + type: 'component'; + kind: EveryComponentType | undefined; + // Normally, any invoking code is built using the same style as the target template. But sometimes + // you need to invoke a component with a different style for full coverage (in particular, you + // need a Glimmer invoker to pass attributes, but curly components can accept attributes). + invokeAs: DeclaredComponentType | undefined; + skip: boolean | DeclaredComponentType | undefined; } -export function test(meta: ComponentTestMeta): MethodDecorator; -export function test( - _target: Object | ComponentTestMeta, - _name?: string, - descriptor?: TypedPropertyDescriptor -): TypedPropertyDescriptor | void; -export function test(...args: any[]) { +export interface ComponentTestFunction { + (this: IRenderTest, assert: RenderTestState): void | Promise; + + readonly [TEST_META]: ComponentTestMeta; +} + +export function isComponentTest(value: unknown): value is ComponentTestFunction { + return isTest(value) && value[TEST_META].type === 'component'; +} + +export function getComponentTestMeta(value: ComponentTestFunction) { + return value[TEST_META]; +} + +export function render( + kind: DeclaredComponentType, + options: Omit +): MethodDecorator; +export function render(meta: Omit): MethodDecorator; +export function render( + target: object, + name: string, + descriptor: TypedPropertyDescriptor<() => void | Promise> +): void; +export function render( + target: object, + name: string, + descriptor: TypedPropertyDescriptor<(context: C) => void | Promise> +): void; +export function render( + ...args: + | [meta: Omit] + | [kind: DeclaredComponentType, options: Omit] + | [target: object, name: string, descriptor: PropertyDescriptor] +): MethodDecorator | PropertyDescriptor { if (args.length === 1) { - let meta: ComponentTestMeta = args[0]; - return (_target: Object, _name: string, descriptor: PropertyDescriptor) => { - let testFunction = descriptor.value; - keys(meta).forEach((key) => (testFunction[key] = meta[key])); - setTestingDescriptor(descriptor); - }; - } + const [options] = args; + return (( + _target: object, + _name: string | symbol, + descriptor: TypedPropertyDescriptor + ) => { + setTestingDescriptor(descriptor, normalizeOptions(options)); + return descriptor; + }) satisfies MethodDecorator; + } else if (args.length === 2) { + const [kind, options] = args; + return (( + _target: object, + _name: string | symbol, + descriptor: TypedPropertyDescriptor + ) => { + setTestingDescriptor(descriptor, normalizeOptions({ kind, ...options })); + return descriptor; + }) satisfies MethodDecorator; + } else { + let [, , descriptor] = args; - let descriptor = args[2]; - setTestingDescriptor(descriptor); - return descriptor; + setTestingDescriptor(descriptor); + return descriptor; + } } -function setTestingDescriptor(descriptor: PropertyDescriptor): void { +function setTestingDescriptor( + descriptor: PropertyDescriptor, + meta: NormalizedComponentTestOptions = normalizeOptions(undefined) +): void { let testFunction = descriptor.value; descriptor.enumerable = true; - testFunction['isTest'] = true; + setComponentTest(testFunction, meta); +} + +export function setComponentTest( + testFunction: Partial, + meta: NormalizedComponentTestOptions +) { + Object.defineProperty(testFunction, TEST_META, { + configurable: true, + value: { + type: 'component', + ...meta, + } satisfies ComponentTestMeta, + }); +} + +function normalizeOptions( + options: ComponentTestOptions | undefined +): NormalizedComponentTestOptions { + if (typeof options === 'string') { + return { kind: options, invokeAs: options === 'all' ? undefined : options, skip: false }; + } else { + return { + kind: options?.kind, + invokeAs: options?.invokeAs, + skip: false, + }; + } +} + +const RENDER_SUITE_META = Symbol('SUITE_META'); + +export interface RenderSuiteMeta { + readonly description: string; + readonly kind?: ComponentTestMeta['kind'] | undefined; +} + +export interface RenderSuite { + [RENDER_SUITE_META]: RenderSuiteMeta; +} + +export function isSuite(value: object): value is RenderSuite { + return RENDER_SUITE_META in value; +} + +export function getSuiteMetadata(suite: RenderSuite): RenderSuiteMeta { + return suite[RENDER_SUITE_META]; +} + +export function suite( + description: string, + options?: Partial | EveryComponentType +): >(Class: Class) => Class { + return (Class) => { + Object.defineProperty(Class, RENDER_SUITE_META, { + configurable: true, + value: { + description, + kind: typeof options === 'string' ? options : options?.kind, + } satisfies RenderSuiteMeta, + }); + + if ('suiteName' in Class) { + LOCAL_LOGGER.warn( + `Don't use 'static suiteName =' and @suite together. Please remove the static property and migrate to @suite.`, + { Class } + ); + } + + Object.defineProperty(Class, 'suiteName', { + configurable: true, + value: description, + }); + + return Class; + }; } diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/constants.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/constants.ts new file mode 100644 index 0000000000..533633ce7a --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/constants.ts @@ -0,0 +1,21 @@ +import type { ComponentKind } from '../components'; + +export const KIND_FOR = { + glimmer: 'Glimmer', + curly: 'Curly', + dynamic: 'Dynamic', + templateOnly: 'TemplateOnly', +} as const; + +export type KindFor = (typeof KIND_FOR)[K]; +export type DeclaredComponentType = 'glimmer' | 'curly' | 'dynamic' | 'templateOnly'; +export type EveryComponentType = DeclaredComponentType | 'all'; + +export const TYPE_FOR = { + Glimmer: 'glimmer', + Curly: 'curly', + Dynamic: 'dynamic', + TemplateOnly: 'templateOnly', +} as const; + +export type TypeFor = (typeof TYPE_FOR)[K]; diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts index 2a004ebe51..0db43e0127 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts @@ -1,4 +1,3 @@ -import { registerDestructor } from '@glimmer/destroyable'; import type { Arguments, HelperCapabilities, @@ -6,6 +5,7 @@ import type { ModifierManager, Owner, } from '@glimmer/interfaces'; +import { registerDestructor } from '@glimmer/destroyable'; import { helperCapabilities, modifierCapabilities, diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/error.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/error.ts new file mode 100644 index 0000000000..2f249521ae --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/error.ts @@ -0,0 +1,51 @@ +import type { UserException } from '@glimmer/interfaces'; + +import { tracked } from './tracked'; + +type Handler = (error: UserException, retry: () => void) => void; + +export class Woops { + static noop(value = `no woops`): Woops { + return new Woops(false, value); + } + + static error(value = `no woops`): Woops { + return new Woops(true, value); + } + + @tracked _value: string; + @tracked isError = false; + readonly handleError: Handler; + #retry: undefined | (() => void); + + private constructor(isError = false, value: string) { + this.isError = isError; + this.handleError = (error, retry) => { + this.#retry = retry; + }; + this._value = value; + } + + get value() { + if (this.isError) { + throw Error(`woops`); + } else { + return this._value; + } + } + + set value(value: string) { + if (this.isError) { + throw Error(`woops`); + } else { + this._value = value; + } + } + + recover() { + this.isError = false; + if (this.#retry) { + this.#retry(); + } + } +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/module.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/module.ts index 8fb0b8241c..f464c513f1 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/module.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/module.ts @@ -1,55 +1,65 @@ import type { EnvironmentDelegate } from '@glimmer/runtime'; -import { keys } from '@glimmer/util'; -import type { ComponentKind } from '../components'; -import { JitRenderDelegate } from '../modes/jit/delegate'; +import type { RenderDelegate, RenderDelegateOptions } from '../render-delegate'; +import type { IRenderTest, RenderTestContext } from '../render-test'; +import type {ComponentTestFunction, ComponentTestMeta, RenderSuiteMeta} from '../test-decorator'; +import type { DeclaredComponentType } from './constants'; + +import { ClientSideRenderDelegate,ErrorRecoveryRenderDelegate } from '../modes/jit/delegate'; import { NodeJitRenderDelegate } from '../modes/node/env'; -import type RenderDelegate from '../render-delegate'; -import type { RenderDelegateOptions } from '../render-delegate'; -import type { Count, IRenderTest, RenderTest } from '../render-test'; +import { Count } from '../render-test'; import { JitSerializationDelegate } from '../suites/custom-dom-helper'; -import type { DeclaredComponentKind } from '../test-decorator'; +import { + getComponentTestMeta, + getSuiteMetadata, + isComponentTest, + isSuite +} from '../test-decorator'; +import { RecordedEvents } from './recorded'; export interface RenderTestConstructor { - suiteName: string; - new (delegate: D): T; + suiteName?: string; + new (delegate: D, context: RenderTestState): T; } export function jitSuite( klass: RenderTestConstructor, - options?: { componentModule?: boolean; env?: EnvironmentDelegate } + options?: { componentModule?: boolean; test?: ['error-recovery']; env?: EnvironmentDelegate } ): void { - return suite(klass, JitRenderDelegate, options); + testSuite(klass, ClientSideRenderDelegate, options); + + if (options?.test?.includes('error-recovery')) { + testSuite(klass, ErrorRecoveryRenderDelegate); + } } export function nodeSuite( klass: RenderTestConstructor, options = { componentModule: false } ): void { - return suite(klass, NodeJitRenderDelegate, options); + return testSuite(klass, NodeJitRenderDelegate, options); } export function nodeComponentSuite( klass: RenderTestConstructor ): void { - return suite(klass, NodeJitRenderDelegate, { componentModule: true }); + return testSuite(klass, NodeJitRenderDelegate, { componentModule: true }); } export function jitComponentSuite( klass: RenderTestConstructor ): void { - return suite(klass, JitRenderDelegate, { componentModule: true }); + return testSuite(klass, ClientSideRenderDelegate, { componentModule: true }); } export function jitSerializeSuite( klass: RenderTestConstructor, options = { componentModule: false } ): void { - return suite(klass, JitSerializationDelegate, options); + return testSuite(klass, JitSerializationDelegate, options); } export interface RenderDelegateConstructor { - readonly isEager: boolean; readonly style: string; new (options?: RenderDelegateOptions): Delegate; } @@ -58,215 +68,269 @@ export function componentSuite( klass: RenderTestConstructor, Delegate: RenderDelegateConstructor ): void { - return suite(klass, Delegate, { componentModule: true }); + return testSuite(klass, Delegate, { componentModule: true }); } -export function suite( - klass: RenderTestConstructor, +export function testSuite( + Class: RenderTestConstructor, Delegate: RenderDelegateConstructor, options: { componentModule?: boolean; env?: EnvironmentDelegate } = {} ): void { - let suiteName = klass.suiteName; - if (options.componentModule) { - if (shouldRunTest(Delegate)) { - componentModule( - `${Delegate.style} :: Components :: ${suiteName}`, - klass as any as RenderTestConstructor, - Delegate - ); - } + componentModule( + `${Delegate.style} :: Components :: ${TestBlueprint.suiteName(Class)}`, + Class as any as RenderTestConstructor, + Delegate + ); } else { - let instance: IRenderTest | null = null; - QUnit.module(`[integration] ${Delegate.style} :: ${suiteName}`, { - beforeEach() { - instance = new klass(new Delegate({ env: options.env })); - if (instance.beforeEach) instance.beforeEach(); - }, + QUnit.module(`[integration] ${Delegate.style} :: ${TestBlueprint.suiteName(Class)}`); - afterEach() { - if (instance!.afterEach) instance!.afterEach(); - instance = null; - }, - }); + for (let prop in Class.prototype) { + const test = Class.prototype[prop]; + const blueprint = TestBlueprint.for(Class, Delegate); - for (let prop in klass.prototype) { - const test = klass.prototype[prop]; + if (isComponentTest(test)) { + const meta = getComponentTestMeta(test); - if (isTestFunction(test) && shouldRunTest(Delegate)) { - if (isSkippedTest(test)) { - // eslint-disable-next-line no-loop-func - QUnit.skip(prop, (assert) => { - test.call(instance!, assert, instance!.count); - instance!.count.assert(); - }); - } else { - // eslint-disable-next-line no-loop-func - QUnit.test(prop, (assert) => { - let result = test.call(instance!, assert, instance!.count); - instance!.count.assert(); - return result; - }); + const CASES = blueprint.filtered(meta).map((types) => { + return { + types, + test: blueprint.createTestFn(test, types), + }; + }); + + for (const { types, test: testCase } of CASES) { + if (meta.skip) { + QUnit.skip(`${formatTypes(types)}: ${prop}`, testCase); + } else { + QUnit.test(`${formatTypes(types)}: ${prop}`, testCase); + } } } } } } -function componentModule( - name: string, - klass: RenderTestConstructor, - Delegate: RenderDelegateConstructor -) { - let tests: ComponentTests = { - glimmer: [], - curly: [], - dynamic: [], - templateOnly: [], - }; +function formatTypes(types: RenderTestTypes): string { + if (types.invoker === types.template) { + return types.invoker; + } else { + return `${types.template} (invoked by ${types.invoker})`; + } +} - function createTest(prop: string, test: any, skip?: boolean) { - let shouldSkip: boolean; - if (skip === true || test.skip === true) { - shouldSkip = true; - } +export interface RenderTestState< + Template extends DeclaredComponentType = DeclaredComponentType, + Invoker extends DeclaredComponentType = Template, +> extends Assert { + readonly count: Count; + readonly events: RecordedEvents; + readonly types: { + readonly template: Template; + readonly invoker: Invoker; + }; +} - return (type: ComponentKind, klass: RenderTestConstructor) => { - if (!shouldSkip) { - - QUnit.test(prop, (assert) => { - let instance = new klass(new Delegate()); - instance.testType = type; - return test.call(instance, assert, instance.count); - }); - } - }; - } +export interface RenderTestTypes { + readonly template: DeclaredComponentType; + readonly invoker: DeclaredComponentType; +} - for (let prop in klass.prototype) { - const test = klass.prototype[prop]; - if (isTestFunction(test)) { - if (test['kind'] === undefined) { - let skip = test['skip']; - switch (skip) { - case 'glimmer': - tests.curly.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test)); - tests.glimmer.push(createTest(prop, test, true)); - break; - case 'curly': - tests.glimmer.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test)); - tests.curly.push(createTest(prop, test, true)); - break; - case 'dynamic': - tests.glimmer.push(createTest(prop, test)); - tests.curly.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test, true)); - break; - case true: - if (test['kind'] === 'templateOnly') { - tests.templateOnly.push(createTest(prop, test, true)); - } else { - ['glimmer', 'curly', 'dynamic'].forEach((kind) => { - tests[kind as DeclaredComponentKind].push(createTest(prop, test, true)); - }); - } +export function RenderTestState( + assert: Assert, + specifiedTypes: RenderTestTypes | DeclaredComponentType +): RenderTestState { + const events = new RecordedEvents(); + + const types = + typeof specifiedTypes === 'string' + ? { template: specifiedTypes, invoker: specifiedTypes } + : specifiedTypes; + + return new Proxy( + { assert, count: new Count(events), events, types }, + { + get({ assert, count, types }, prop, receiver) { + switch (prop) { + case 'count': + return count; + case 'events': + return events; + case 'types': + return types; default: - tests.glimmer.push(createTest(prop, test)); - tests.curly.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test)); + return Reflect.get(assert, prop, receiver); } - continue; - } + }, + } + ) as unknown as RenderTestState; +} - let kind = test['kind']; +type TestFn = () => void; - if (kind === 'curly') { - tests.curly.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test)); - } +export class TestBlueprint { + static for( + Class: RenderTestConstructor, + Delegate: RenderDelegateConstructor + ) { + return new TestBlueprint(Class, Delegate); + } - if (kind === 'glimmer') { - tests.glimmer.push(createTest(prop, test)); - } + static suiteName( + Class: RenderTestConstructor + ) { + if (isSuite(Class)) { + return getSuiteMetadata(Class).description; + } else if (Class.suiteName) { + return Class.suiteName; + } else { + throw Error(`Could not find suite name for ${Class.name}`); + } + } - if (kind === 'dynamic') { - tests.curly.push(createTest(prop, test)); - tests.dynamic.push(createTest(prop, test)); - } + #Class: RenderTestConstructor; + #Delegate: RenderDelegateConstructor; + #suite: RenderSuiteMeta; - if (kind === 'templateOnly') { - tests.templateOnly.push(createTest(prop, test)); - } + private constructor(Class: RenderTestConstructor, Delegate: RenderDelegateConstructor) { + this.#Class = Class; + this.#Delegate = Delegate; + + if (isSuite(Class)) { + this.#suite = getSuiteMetadata(Class); + } else { + this.#suite = { description: Class.suiteName ?? Class.name }; } } - QUnit.module(`[integration] ${name}`, () => { - nestedComponentModules(klass, tests); - }); -} -interface ComponentTests { - glimmer: Function[]; - curly: Function[]; - dynamic: Function[]; - templateOnly: Function[]; -} + get suiteName() { + return this.#suite.description; + } -function nestedComponentModules( - klass: RenderTestConstructor, - tests: ComponentTests -): void { - keys(tests).forEach((type) => { - let formattedType = upperFirst(type); + filtered(meta: ComponentTestMeta): readonly RenderTestTypes[] { + const included = EXPANSIONS[meta.kind ?? this.#suite.kind ?? 'all']; + const excluded = new Set(expandSkip(meta.skip)); - QUnit.module(`[integration] ${formattedType}`, () => { - const allTests = [...tests[type]].reverse(); + return included + .filter((kind) => !excluded.has(kind)) + .map((kind) => ({ invoker: meta.invokeAs ?? kind, template: kind })); + } - for (const t of allTests) { - t(formattedType, klass); - } + createTestFn( + test: ComponentTestFunction, + types: RenderTestTypes + ): (assert: Assert) => void | Promise { + return (assert) => { + const instance = new this.#Class(new this.#Delegate(), RenderTestState(assert, types)); + instance.beforeEach?.(); - tests[type] = []; - }); - }); -} + try { + const result = test.call(instance, instance.context); -function upperFirst( - str: T extends '' ? `upperFirst only takes (statically) non-empty strings` : T -): string { - let first = str[0] as string; - let rest = str.slice(1); + if (result === undefined) { + instance.context.count.assert(); + } else { + return result.then(() => { + instance.context.count.assert(); + }); + } + } finally { + instance.afterEach?.(); + } + }; + } - return `${first.toUpperCase()}${rest}`; -} + createTest(types: RenderTestTypes, description: string, test: unknown): TestFn | undefined { + if (!isComponentTest(test)) return; -const HAS_TYPED_ARRAYS = typeof Uint16Array !== 'undefined'; + return () => { + QUnit.test( + `${types.invoker !== types.template ? formatTypes(types) : ''} ${description}`, + this.createTestFn(test, types) + ); + }; + } +} -function shouldRunTest(Delegate: RenderDelegateConstructor) { - let isEagerDelegate = Delegate['isEager']; +class ComponentTests { + readonly #blueprint: TestBlueprint; + readonly #tests: { types: RenderTestTypes; test: TestFn }[] = []; - if (HAS_TYPED_ARRAYS) { - return true; + constructor(blueprint: TestBlueprint) { + this.#blueprint = blueprint; } - if (!HAS_TYPED_ARRAYS && !isEagerDelegate) { - return true; + *[Symbol.iterator](): IterableIterator { + for (const type of ['curly', 'glimmer', 'dynamic', 'templateOnly'] as const) { + yield [ + type, + this.#tests.filter((t) => t.types.template === type).map((t) => t.test), + ] as const; + } } - return false; + add(kinds: readonly RenderTestTypes[], { prop, test }: { prop: string; test: unknown }) { + for (const types of kinds) { + const testFn = this.#blueprint.createTest(types, prop, test); + if (testFn) { + this.#tests.push({ types, test: testFn }); + } + } + } } -interface TestFunction { - (this: IRenderTest, assert: typeof QUnit.assert, count?: Count): void; - kind?: DeclaredComponentKind; - skip?: boolean | DeclaredComponentKind; +export type ExpandType = EXPANSIONS[K][number]; + +const EXPANSIONS = { + curly: ['curly', 'dynamic'], + glimmer: ['glimmer', 'templateOnly'], + dynamic: ['dynamic'], + templateOnly: ['templateOnly'], + all: ['curly', 'glimmer', 'dynamic', 'templateOnly'], +} as const; +type EXPANSIONS = typeof EXPANSIONS; + +function expandSkip( + kind: DeclaredComponentType | boolean | undefined +): readonly DeclaredComponentType[] { + if (kind === false || kind === undefined) { + return []; + } else if (kind === true) { + return ['glimmer', 'curly', 'dynamic']; + } else { + return EXPANSIONS[kind]; + } } -function isTestFunction(value: any): value is TestFunction { - return typeof value === 'function' && value.isTest; +function componentModule( + name: string, + Class: RenderTestConstructor, + Delegate: RenderDelegateConstructor +) { + const blueprint = TestBlueprint.for(Class, Delegate); + const tests = new ComponentTests(blueprint); + + for (let prop in Class.prototype) { + const test = Class.prototype[prop]; + if (isComponentTest(test)) { + const meta = getComponentTestMeta(test); + const filtered = blueprint.filtered(meta); + tests.add(filtered, { prop, test }); + } + } + QUnit.module(`[integration] ${name}`, () => { + nestedComponentModules(tests); + }); } -function isSkippedTest(value: any): boolean { - return typeof value === 'function' && value.skip; +function nestedComponentModules( + modules: ComponentTests +): void { + for (const [type, tests] of modules) { + QUnit.module(`[integration] ${type}`, () => { + for (const test of tests) { + test(); + } + }); + } } diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/recorded.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/recorded.ts new file mode 100644 index 0000000000..b7db759df0 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/recorded.ts @@ -0,0 +1,79 @@ +export class RecordedEvents { + #events: string[] = []; + + record(event: string) { + this.#events.push(event); + } + + finalize() { + const filtered = this.#events.filter((e) => !e.includes(':')); + + if (filtered.length !== 0) { + QUnit.assert.deepEqual( + this.#events.filter((e) => !e.includes(':')), + [], + 'Expected all unprefixed events to be verified during the test.\nFull event log:\n' + + this.#events.join('\n') + ); + } + } + + get unprefixed(): { expect: (expectedEvents: string[]) => void } { + return { + expect: (expectedEvents) => { + const filteredEvents = this.#events.filter((e) => !e.includes(':')); + this.#events = []; + + if (QUnit.equiv(filteredEvents, expectedEvents)) { + QUnit.assert.deepEqual(filteredEvents, expectedEvents); + } else { + QUnit.assert.deepEqual( + filteredEvents, + expectedEvents, + `Full event log:\n${this.#events.map((e) => ` - ${e}`).join('\n')}` + ); + } + }, + }; + } + + prefixed( + prefixes: string[], + options?: { exclude: 'unprefixed' } + ): { expect: (expectedEvents: string[]) => void } { + const prefixSet = new Set(prefixes); + return { + expect: (expectedEvents) => { + const filteredEvents = this.#events.filter((e) => { + if (prefixSet.has(e.split(':')[0])) return true; + if (options?.exclude === 'unprefixed') return false; + return !e.includes(':'); + }); + this.#events = []; + + if (QUnit.equiv(filteredEvents, expectedEvents)) { + QUnit.assert.deepEqual(filteredEvents, expectedEvents); + } else { + QUnit.assert.deepEqual( + filteredEvents, + expectedEvents, + `Full event log:\n${this.#events.map((e) => ` - ${e}`).join('\n')}` + ); + } + }, + }; + } + + readonly all = { + expect: (expectedEvents: string[]) => { + const actualEvents = this.#events; + this.#events = []; + + QUnit.assert.deepEqual(actualEvents, expectedEvents); + }, + }; + + expect(expectedEvents: string[]) { + this.unprefixed.expect(expectedEvents); + } +} diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/test.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/test.ts index 29ee8a1d0f..00a4b88706 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/test.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/test.ts @@ -1,30 +1,70 @@ -import { keys } from '@glimmer/util'; +export interface TestMeta { + type: string; + skip?: boolean | undefined; +} + +export interface SimpleTestMeta extends TestMeta { + type: 'simple'; +} -import type { ComponentTestMeta } from '../test-decorator'; +export type SimpleTestOptions = Partial>; -export function test(meta: ComponentTestMeta): MethodDecorator; +export function test(meta: SimpleTestOptions): MethodDecorator; export function test( - _target: Object | ComponentTestMeta, - _name?: string, - descriptor?: TypedPropertyDescriptor -): TypedPropertyDescriptor | void; -export function test(...args: any[]) { + _target: object, + _name: string | symbol, + descriptor: TypedPropertyDescriptor +): TypedPropertyDescriptor; +export function test( + ...args: [meta: SimpleTestOptions] | Parameters +): MethodDecorator | PropertyDescriptor { if (args.length === 1) { - let meta: ComponentTestMeta = args[0]; - return (_target: Object, _name: string, descriptor: PropertyDescriptor) => { - let testFunction = descriptor.value; - keys(meta).forEach((key) => (testFunction[key] = meta[key])); - setTestingDescriptor(descriptor); - }; + let meta = args[0]; + return ((_target, _name, descriptor: PropertyDescriptor) => { + setTestingDescriptor(descriptor, meta); + }) satisfies MethodDecorator; + } else { + const [, , descriptor] = args; + setTestingDescriptor(descriptor); + return descriptor; } - - let descriptor = args[2]; - setTestingDescriptor(descriptor); - return descriptor; } -function setTestingDescriptor(descriptor: PropertyDescriptor): void { +export const TEST_META = Symbol('TEST_META'); +function setTestingDescriptor( + descriptor: PropertyDescriptor, + options?: SimpleTestOptions | undefined +): void { let testFunction = descriptor.value; descriptor.enumerable = true; - testFunction['isTest'] = true; + testFunction[TEST_META] = { + type: 'simple', + skip: options?.skip, + }; +} + +export interface TestFunction { + (this: Instance, assert: Assert): void; + readonly [TEST_META]: { + type: string; + }; +} + +export interface SimpleTestFunction extends TestFunction { + readonly [TEST_META]: { + type: 'simple'; + skip?: boolean | undefined; + }; +} + +export function isTest(value: unknown): value is TestFunction { + return typeof value === 'function' && TEST_META in value; +} + +export function getTestMeta(value: TestFunction): TestMeta { + return value[TEST_META]; +} + +export function isSimpleTest(value: unknown): value is SimpleTestFunction { + return isTest(value) && value[TEST_META].type === 'simple'; } diff --git a/packages/@glimmer-workspace/integration-tests/package.json b/packages/@glimmer-workspace/integration-tests/package.json index d488f53e7a..8667623423 100644 --- a/packages/@glimmer-workspace/integration-tests/package.json +++ b/packages/@glimmer-workspace/integration-tests/package.json @@ -2,7 +2,17 @@ "name": "@glimmer-workspace/integration-tests", "version": "0.84.3", "private": true, - "main": "index.ts", + "main": "lib/index.ts", + "types": "lib/index.ts", + "exports": { + ".": { + "types": "./lib/index.ts", + "default": "./lib/index.ts" + } + }, + "files": [ + "lib" + ], "repository": "https://github.com/glimmerjs/glimmer-vm/tree/master/packages/@glimmer-workspace/integration-tests", "dependencies": { "@glimmer/destroyable": "workspace:^", @@ -31,7 +41,7 @@ }, "devDependencies": { "@glimmer/local-debug-flags": "workspace:^", - "@types/qunit": "^2.19.7", + "@types/qunit": "workspace:^", "@types/js-reporters": "workspace:^" }, "scripts": { diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index cd686d0af8..d93afa1a4e 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -1,10 +1,16 @@ import type { SimpleElement } from '@glimmer/interfaces'; import { normalizeProperty } from '@glimmer/runtime'; import { castToBrowser, expect, NS_SVG } from '@glimmer/util'; - -import { assertingElement, hasAttribute, jitSuite, RenderTest, test, tracked } from '..'; - -export class AttributesTests extends RenderTest { +import { + assertingElement, + hasAttribute, + jitSuite, + RenderTestContext, + test, + tracked, +} from '@glimmer-workspace/integration-tests'; + +export class AttributesTests extends RenderTestContext { static suiteName = 'Attributes'; protected readDOMAttr(attr: string, element = this.element.firstChild as SimpleElement) { @@ -24,7 +30,7 @@ export class AttributesTests extends RenderTest { protected nativeValueForElementProperty< T extends keyof HTMLElementTagNameMap, - P extends keyof HTMLElementTagNameMap[T] + P extends keyof HTMLElementTagNameMap[T], >(tagName: T, property: P, value: HTMLElementTagNameMap[T][P]) { const element = document.createElement(tagName); element[property] = value; @@ -33,11 +39,11 @@ export class AttributesTests extends RenderTest { @test 'helpers shadow self'() { - this.registerHelper('foo', function () { + this.register.helper('foo', () => { return 'hello'; }); - this.render('
', { foo: 'bye' }); + this.render.template('
', { foo: 'bye' }); this.assertHTML('
'); this.assertStableRerender(); @@ -52,7 +58,7 @@ export class AttributesTests extends RenderTest { @test 'disable updates properly'() { - this.render('', { enabled: true }); + this.render.template('', { enabled: true }); this.assertHTML(''); this.assertStableRerender(); @@ -83,7 +89,7 @@ export class AttributesTests extends RenderTest { @test 'Quoted disabled is always disabled if a not-null, not-undefined value is given'() { - this.render('', { enabled: true }); + this.render.template('', { enabled: true }); this.assertHTML(''); this.assertStableRerender(); @@ -114,7 +120,7 @@ export class AttributesTests extends RenderTest { @test 'disabled without an explicit value is truthy'() { - this.render(''); + this.render.template(''); this.assertHTML(''); this.assert.ok(this.readDOMAttr('disabled')); @@ -124,7 +130,7 @@ export class AttributesTests extends RenderTest { @test 'div[href] is not marked as unsafe'() { - this.render('
', { foo: 'javascript:foo()' }); + this.render.template('
', { foo: 'javascript:foo()' }); this.assertHTML('
'); this.assertStableRerender(); @@ -139,7 +145,7 @@ export class AttributesTests extends RenderTest { @test 'triple curlies in attribute position'() { - this.render('
Hello
', { + this.render.template('
Hello
', { rawString: 'TRIPLE', }); this.assertHTML('
Hello
'); @@ -156,35 +162,37 @@ export class AttributesTests extends RenderTest { @test 'can read attributes'() { - this.render('
'); + this.render.template('
'); this.assert.strictEqual(this.readDOMAttr('data-bar'), 'bar'); this.assertStableRerender(); } @test 'can read attributes from namespace elements'() { - this.render(''); + this.render.template(''); this.assert.strictEqual(this.readDOMAttr('viewBox'), '0 0 0 0'); this.assertStableRerender(); } @test 'can read properties'() { - this.render(''); + this.render.template(''); this.assert.strictEqual(this.readDOMAttr('value'), 'gnargnar'); this.assertStableRerender(); } @test 'can read the form attribute'() { - this.render('