diff --git a/app/assets/javascripts/discourse-common/addon/config/environment.js b/app/assets/javascripts/discourse-common/addon/config/environment.js
index 5208c1f7568..2b2fba3de6a 100644
--- a/app/assets/javascripts/discourse-common/addon/config/environment.js
+++ b/app/assets/javascripts/discourse-common/addon/config/environment.js
@@ -12,8 +12,18 @@ export function setEnvironment(e) {
}
}
+/**
+ * Returns true if running in the qunit test harness
+ */
export function isTesting() {
- return environment === "testing";
+ return environment === "qunit-testing";
+}
+
+/**
+ * Returns true is RAILS_ENV=test (e.g. for system specs)
+ */
+export function isRailsTesting() {
+ return environment === "test";
}
// Generally means "before we migrated to Ember CLI"
diff --git a/app/assets/javascripts/discourse-i18n/src/index.js b/app/assets/javascripts/discourse-i18n/src/index.js
index c36bf344a76..fe5189cc2e2 100644
--- a/app/assets/javascripts/discourse-i18n/src/index.js
+++ b/app/assets/javascripts/discourse-i18n/src/index.js
@@ -61,6 +61,11 @@ export class I18n {
return "Verbose localization is enabled. Close the browser tab to turn it off. Reload the page to see the translation keys.";
}
+ disableVerboseLocalizationSession() {
+ sessionStorage.removeItem("verbose_localization");
+ return "Verbose localization disabled. Reload the page.";
+ }
+
_translate(scope, options) {
options = this.prepareOptions(options);
options.needsPluralization = typeof options.count === "number";
diff --git a/app/assets/javascripts/discourse/app/initializers/dev-tools.js b/app/assets/javascripts/discourse/app/initializers/dev-tools.js
new file mode 100644
index 00000000000..4be9a7c3902
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/dev-tools.js
@@ -0,0 +1,68 @@
+import { DEBUG } from "@glimmer/env";
+import { isDevelopment } from "discourse-common/config/environment";
+
+const KEY = "discourse__dev_tools";
+
+function parseStoredValue() {
+ const val = window.localStorage.getItem(KEY);
+ if (val === "true") {
+ return true;
+ } else if (val === "false") {
+ return false;
+ } else {
+ return null;
+ }
+}
+
+export default {
+ after: ["discourse-bootstrap"],
+
+ initialize(app) {
+ let defaultEnabled = false;
+
+ if (DEBUG && isDevelopment()) {
+ defaultEnabled = true;
+ }
+
+ function storeValue(value) {
+ if (value === defaultEnabled) {
+ window.localStorage.removeItem(KEY);
+ } else {
+ window.localStorage.setItem(KEY, value);
+ }
+ }
+
+ window.enableDevTools = () => {
+ storeValue(true);
+ window.location.reload();
+ };
+
+ window.disableDevTools = () => {
+ storeValue(false);
+ window.location.reload();
+ };
+
+ if (parseStoredValue() ?? defaultEnabled) {
+ // eslint-disable-next-line no-console
+ console.log("Loading Discourse dev tools...");
+
+ app.deferReadiness();
+
+ import("discourse/static/dev-tools/entrypoint").then((devTools) => {
+ devTools.init();
+
+ // eslint-disable-next-line no-console
+ console.log(
+ "Loaded Discourse dev tools. Run `disableDevTools()` in console to disable."
+ );
+
+ app.advanceReadiness();
+ });
+ } else if (DEBUG && isDevelopment()) {
+ // eslint-disable-next-line no-console
+ console.log(
+ "Discourse dev tools are disabled. Run `enableDevTools()` in console to enable."
+ );
+ }
+ },
+};
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-connectors.js b/app/assets/javascripts/discourse/app/lib/plugin-connectors.js
index dcd0f7be676..0196cf57494 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-connectors.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-connectors.js
@@ -14,6 +14,7 @@ let _connectorCache;
let _rawConnectorCache;
let _extraConnectorClasses = {};
let _extraConnectorComponents = {};
+let debugOutletCallback;
export function resetExtraClasses() {
_extraConnectorClasses = {};
@@ -214,13 +215,16 @@ export function connectorsExist(outletName) {
if (!_connectorCache) {
buildConnectorCache();
}
- return Boolean(_connectorCache[outletName]);
+ return Boolean(_connectorCache[outletName] || debugOutletCallback);
}
export function connectorsFor(outletName) {
if (!_connectorCache) {
buildConnectorCache();
}
+ if (debugOutletCallback) {
+ return debugOutletCallback(outletName, _connectorCache[outletName]);
+ }
return _connectorCache[outletName] || [];
}
@@ -302,3 +306,7 @@ export function deprecatedArgumentValue(deprecatedArg, options) {
return deprecatedArg.value;
});
}
+
+export function _setOutletDebugCallback(callback) {
+ debugOutletCallback = callback;
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/entrypoint.js b/app/assets/javascripts/discourse/app/static/dev-tools/entrypoint.js
new file mode 100644
index 00000000000..0c97be96e7e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/entrypoint.js
@@ -0,0 +1,12 @@
+import "./styles.css";
+import { withPluginApi } from "discourse/lib/plugin-api";
+import { patchConnectors } from "./plugin-outlet-debug/patch";
+import Toolbar from "./toolbar";
+
+export function init() {
+ patchConnectors();
+
+ withPluginApi("0.8", (api) => {
+ api.renderInOutlet("above-site-header", Toolbar);
+ });
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/args-table.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/args-table.gjs
new file mode 100644
index 00000000000..03168b2dff9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/args-table.gjs
@@ -0,0 +1,69 @@
+import Component from "@glimmer/component";
+import { fn } from "@ember/helper";
+import { on } from "@ember/modifier";
+import icon from "discourse-common/helpers/d-icon";
+
+let globalI = 1;
+
+function stringifyValue(value) {
+ if (value === undefined) {
+ return "undefined";
+ } else if (value === null) {
+ return "null";
+ } else if (["string", "number"].includes(typeof value)) {
+ return JSON.stringify(value);
+ } else if (typeof value === "boolean") {
+ return value.toString();
+ } else if (Array.isArray(value)) {
+ return `Array (${value.length} items)`;
+ } else if (value.toString().startsWith("class ")) {
+ return `class ${value.name} {}`;
+ } else if (value.constructor.name === "function") {
+ return `ƒ ${value.name || "function"}(...)`;
+ } else if (value.id) {
+ return `${value.constructor.name} { id: ${value.id} }`;
+ } else {
+ return `${value.constructor.name} {}`;
+ }
+}
+
+export default class ArgsTable extends Component {
+ get renderArgs() {
+ return Object.entries(this.args.outletArgs).map(([key, value]) => {
+ return {
+ key,
+ value: stringifyValue(value),
+ originalValue: value,
+ };
+ });
+ }
+
+ writeToConsole(key, value, event) {
+ event.preventDefault();
+ window[`arg${globalI}`] = value;
+ /* eslint-disable no-console */
+ console.log(
+ `[plugin outlet debug] \`@outletArgs.${key}\` saved to global \`arg${globalI}\`, and logged below:`
+ );
+ console.log(value);
+ /* eslint-enable no-console */
+
+ globalI++;
+ }
+
+
+ {{#each this.renderArgs as |arg|}}
+ {{arg.key}}:
+
+ {{else}}
+ (no arguments)
+ {{/each}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/button.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/button.gjs
new file mode 100644
index 00000000000..54ee5ff477b
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/button.gjs
@@ -0,0 +1,26 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import concatClass from "discourse/helpers/concat-class";
+import icon from "discourse-common/helpers/d-icon";
+import devToolsState from "../state";
+
+export default class PluginOutletDebugButton extends Component {
+ @action
+ togglePluginOutlets() {
+ devToolsState.pluginOutletDebug = !devToolsState.pluginOutletDebug;
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/outlet-info.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/outlet-info.gjs
new file mode 100644
index 00000000000..642ab4cb545
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/outlet-info.gjs
@@ -0,0 +1,136 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { array, hash } from "@ember/helper";
+import { action } from "@ember/object";
+import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import concatClass from "discourse/helpers/concat-class";
+import icon from "discourse-common/helpers/d-icon";
+import DTooltip from "float-kit/components/d-tooltip";
+import devToolsState from "../state";
+import ArgsTable from "./args-table";
+
+// Outlets matching these patterns will be displayed with an icon only.
+// Feel free to add more if it improves the layout.
+const SMALL_OUTLETS = [
+ /^topic-list-/,
+ "before-topic-list-body",
+ "after-topic-status",
+ /^header-contents/,
+ "after-header-panel",
+ /^bread-crumbs/,
+ /^user-dropdown-notifications/,
+ /^user-dropdown-button/,
+ "after-breadcrumbs",
+];
+
+export default class OutletInfoComponent extends Component {
+ static shouldRender() {
+ return devToolsState.pluginOutletDebug;
+ }
+
+ @tracked partOfWrapper;
+
+ get isBeforeOrAfter() {
+ return this.isBefore || this.isAfter;
+ }
+
+ get isBefore() {
+ return this.args.outletName.includes("__before");
+ }
+
+ get isAfter() {
+ return this.args.outletName.includes("__after");
+ }
+
+ get baseName() {
+ return this.args.outletName.split("__")[0];
+ }
+
+ get displayName() {
+ return this.partOfWrapper ? this.baseName : this.args.outletName;
+ }
+
+ @action
+ checkIsWrapper(element) {
+ const parent = element.parentElement;
+ this.partOfWrapper = [
+ this.baseName,
+ `${this.baseName}__before`,
+ `${this.baseName}__after`,
+ ].every((name) =>
+ parent.querySelector(`:scope > [data-outlet-name="${name}"]`)
+ );
+ }
+
+ get isWrapper() {
+ return this.partOfWrapper && !this.isBeforeOrAfter;
+ }
+
+ get isHidden() {
+ return this.isWrapper && !this.isBeforeOrAfter;
+ }
+
+ get showName() {
+ return !SMALL_OUTLETS.some((pattern) =>
+ pattern.test ? pattern.test(this.baseName) : pattern === this.baseName
+ );
+ }
+
+
+
+
+ <:trigger>
+
+ {{#if this.partOfWrapper}}
+ <{{if this.isAfter "/"}}{{if
+ this.showName
+ this.displayName
+ }}>
+ {{else}}
+ {{icon "plug"}}
+ {{if this.showName this.displayName}}
+ {{/if}}
+
+
+ <:content>
+
+
+
+ {{icon "plug"}}
+ {{this.displayName}}
+ {{#if this.partOfWrapper}}
+ (wrapper)
+ {{/if}}
+
+
{{icon "fab-github"}}
+
+
+
+
+
+
+ {{yield}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/patch.js b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/patch.js
new file mode 100644
index 00000000000..f052b5df4c6
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/plugin-outlet-debug/patch.js
@@ -0,0 +1,31 @@
+import curryComponent from "ember-curry-component";
+import { _setOutletDebugCallback } from "discourse/lib/plugin-connectors";
+import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
+import devToolsState from "../state";
+import OutletInfoComponent from "./outlet-info";
+
+const SKIP_EXISTING_FOR_OUTLETS = [
+ "home-logo-wrapper", // Wrapper outlet used by chat, so very likely to be present
+];
+
+export function patchConnectors() {
+ _setOutletDebugCallback((outletName, existing) => {
+ existing ||= [];
+
+ if (!devToolsState.pluginOutletDebug) {
+ return existing;
+ }
+
+ if (SKIP_EXISTING_FOR_OUTLETS.includes(outletName)) {
+ existing = [];
+ }
+
+ const componentClass = curryComponent(
+ OutletInfoComponent,
+ { outletName },
+ getOwnerWithFallback()
+ );
+
+ return [{ componentClass }, ...existing];
+ });
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/safe-mode/button.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/safe-mode/button.gjs
new file mode 100644
index 00000000000..1c02e90f8ce
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/safe-mode/button.gjs
@@ -0,0 +1,35 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import concatClass from "discourse/helpers/concat-class";
+import icon from "discourse-common/helpers/d-icon";
+
+export default class PluginOutletDebugButton extends Component {
+ get safeModeActive() {
+ return new URLSearchParams(window.location.search).has("safe_mode");
+ }
+
+ @action
+ toggleSafeMode() {
+ const urlParams = new URLSearchParams(window.location.search);
+ if (urlParams.has("safe_mode")) {
+ urlParams.delete("safe_mode");
+ } else {
+ urlParams.set("safe_mode", "no_themes,no_plugins");
+ }
+ window.location.search = urlParams.toString();
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/state.js b/app/assets/javascripts/discourse/app/static/dev-tools/state.js
new file mode 100644
index 00000000000..45f35458368
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/state.js
@@ -0,0 +1,10 @@
+import { tracked } from "@glimmer/tracking";
+
+class DevToolsState {
+ @tracked pluginOutletDebug = false;
+}
+
+const state = new DevToolsState();
+Object.preventExtensions(state);
+
+export default state;
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/styles.css b/app/assets/javascripts/discourse/app/static/dev-tools/styles.css
new file mode 100644
index 00000000000..314a5fef293
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/styles.css
@@ -0,0 +1,125 @@
+/**
+ This CSS file is loaded dynamically when loadDevTools() is run in the console.
+ It is not part of our normal CSS build process, so SCSS variables are not available.
+ Native CSS nesting can be used safely, because developers who use this tool are expected to have modern browsers.
+*/
+.plugin-outlet-info {
+ --plugin-outlet-info-border-color: #080;
+ --plugin-outlet-info-background-color: #0c0;
+
+ margin: 1px;
+ border: 1px solid var(--plugin-outlet-info-border-color);
+ display: inline-block;
+
+ background-color: var(--plugin-outlet-info-background-color);
+ color: white;
+
+ text-align: center;
+ font-size: 14px !important;
+ font-weight: normal;
+ padding: 1px 5px;
+ display: inline-flex;
+ align-items: center;
+
+ .d-icon {
+ color: white !important;
+ font-size: 14px !important;
+ width: 14px !important;
+ }
+
+ &.--wrapper {
+ --plugin-outlet-info-border-color: #00c;
+ --plugin-outlet-info-background-color: #88f;
+ }
+}
+
+.plugin-outlet-info__wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.plugin-outlet-info__heading {
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 10px;
+
+ display: flex;
+ gap: 5px;
+
+ .title {
+ flex-grow: 1;
+ }
+
+ .github-link {
+ color: var(--primary-medium);
+ }
+}
+
+.plugin-outlet-info__content {
+ display: grid;
+ grid-template-columns: min-content 1fr;
+ font-size: 14px;
+ grid-gap: 10px;
+ min-width: 300px;
+ max-width: 100%;
+ width: 100%;
+ overflow: hidden;
+
+ & > div {
+ min-width: 0;
+ }
+
+ .fw {
+ font-family: "Courier New", "Courier", "Lucida Console", "Monaco", monospace;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ background: var(--primary-very-low);
+ }
+
+ .value {
+ display: flex;
+ gap: 5px;
+ }
+
+ .no-arguments {
+ grid-column: span 2;
+ }
+
+ a {
+ color: var(--primary-medium);
+ }
+}
+
+.dev-tools-toolbar {
+ position: fixed;
+ z-index: 999999;
+
+ display: flex;
+ flex-direction: column;
+
+ background-color: var(--primary-low);
+ border-radius: 0px 5px 5px 0px;
+
+ button {
+ background: none;
+ border: none;
+ padding: 5px;
+ color: var(--primary-medium);
+
+ &:hover:not(.gripper) {
+ background-color: var(--primary-very-low);
+ }
+
+ &.gripper {
+ cursor: grab;
+ padding-bottom: 0;
+ padding-top: 0;
+ color: var(--primary-400);
+ }
+
+ &.--active {
+ color: var(--success);
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/toolbar.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/toolbar.gjs
new file mode 100644
index 00000000000..5f33fd09058
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/toolbar.gjs
@@ -0,0 +1,85 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { htmlSafe } from "@ember/template";
+import draggable from "discourse/modifiers/draggable";
+import onResize from "discourse/modifiers/on-resize";
+import icon from "discourse-common/helpers/d-icon";
+import I18n from "discourse-i18n";
+import PluginOutletDebugButton from "./plugin-outlet-debug/button";
+import SafeModeButton from "./safe-mode/button";
+import VerboseLocalizationButton from "./verbose-localization/button";
+
+export default class Toolbar extends Component {
+ @tracked top = 250;
+ @tracked ownSize = 0;
+
+ activeDragOffset;
+
+ get style() {
+ const clampedTop = Math.max(this.top, 0);
+ return htmlSafe(`top: min(100dvh - ${this.ownSize}px, ${clampedTop}px);`);
+ }
+
+ @action
+ disableDevTools() {
+ I18n.disableVerboseLocalizationSession();
+ window.disableDevTools();
+ }
+
+ @action
+ didStartDrag(event) {
+ const realTop = event.target
+ .closest(".dev-tools-toolbar")
+ .getBoundingClientRect().top;
+ const dragStartedAtY = event.pageY || event.touches[0].pageY;
+ this.activeDragOffset = dragStartedAtY - realTop;
+ }
+
+ @action
+ didEndDrag() {
+ this.activeDragOffset = null;
+ }
+
+ @action
+ dragMove(event) {
+ const dragY = event.pageY || event.touches[0].pageY;
+ this.top = dragY - this.activeDragOffset;
+ }
+
+ @action
+ onResize(entries) {
+ this.ownSize = entries[0].contentRect.height;
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/static/dev-tools/verbose-localization/button.gjs b/app/assets/javascripts/discourse/app/static/dev-tools/verbose-localization/button.gjs
new file mode 100644
index 00000000000..d71a9fd4a0e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/static/dev-tools/verbose-localization/button.gjs
@@ -0,0 +1,31 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import concatClass from "discourse/helpers/concat-class";
+import icon from "discourse-common/helpers/d-icon";
+import I18n from "discourse-i18n";
+
+export default class VerboseLocalizationButton extends Component {
+ @action
+ toggleVerboseLocalization() {
+ if (I18n.verbose) {
+ I18n.disableVerboseLocalizationSession();
+ } else {
+ I18n.enableVerboseLocalizationSession();
+ }
+ window.location.reload();
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json
index f52780a0170..711ee664808 100644
--- a/app/assets/javascripts/discourse/package.json
+++ b/app/assets/javascripts/discourse/package.json
@@ -25,6 +25,7 @@
"decorator-transforms": "^2.3.0",
"discourse-hbr": "workspace:1.0.0",
"discourse-widget-hbs": "workspace:1.0.0",
+ "ember-curry-component": "^0.1.0",
"ember-route-template": "^1.0.3",
"ember-tracked-storage-polyfill": "^1.0.0",
"handlebars": "^4.7.8",
diff --git a/app/assets/javascripts/discourse/public/assets/scripts/discourse-test-listen-boot.js b/app/assets/javascripts/discourse/public/assets/scripts/discourse-test-listen-boot.js
index e777a0b5fc2..4a704502c61 100644
--- a/app/assets/javascripts/discourse/public/assets/scripts/discourse-test-listen-boot.js
+++ b/app/assets/javascripts/discourse/public/assets/scripts/discourse-test-listen-boot.js
@@ -1,2 +1,2 @@
-require("discourse-common/config/environment").setEnvironment("testing");
+require("discourse-common/config/environment").setEnvironment("qunit-testing");
require("discourse/tests/test-boot-ember-cli");
diff --git a/package.json b/package.json
index b29ce894641..e7a5f777ee3 100644
--- a/package.json
+++ b/package.json
@@ -47,8 +47,8 @@
"lint:js:fix": "eslint --fix ./app/assets/javascripts $(script/list_bundled_plugins) --no-error-on-unmatched-pattern",
"lint:hbs": "ember-template-lint 'app/assets/javascripts/**/*.{gjs,hbs}' 'plugins/*/assets/javascripts/**/*.{gjs,hbs}' 'plugins/*/admin/assets/javascripts/**/*.{gjs,hbs}'",
"lint:hbs:fix": "ember-template-lint 'app/assets/javascripts/**/*.{gjs,hbs}' 'plugins/*/assets/javascripts/**/*.{gjs,hbs}' 'plugins/*/admin/assets/javascripts/**/*.{gjs,hbs}' --fix",
- "lint:prettier": "pnpm pprettier --list-different 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs}' $(script/list_bundled_plugins '/assets/stylesheets/**/*.scss') $(script/list_bundled_plugins '/{assets,admin/assets,test}/javascripts/**/*.{js,gjs,hbs}')",
- "lint:prettier:fix": "pnpm prettier -w 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs}' $(script/list_bundled_plugins '/assets/stylesheets/**/*.scss') $(script/list_bundled_plugins '/{assets,admin/assets,test}/javascripts/**/*.{js,gjs,hbs}')",
+ "lint:prettier": "pnpm pprettier --list-different 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs,css}' $(script/list_bundled_plugins '/assets/stylesheets/**/*.scss') $(script/list_bundled_plugins '/{assets,admin/assets,test}/javascripts/**/*.{js,gjs,hbs}')",
+ "lint:prettier:fix": "pnpm prettier -w 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs,css}' $(script/list_bundled_plugins '/assets/stylesheets/**/*.scss') $(script/list_bundled_plugins '/{assets,admin/assets,test}/javascripts/**/*.{js,gjs,hbs}')",
"lttf:ignore": "lint-to-the-future ignore",
"lttf:output": "lint-to-the-future output -o ./lint-progress/",
"lint-progress": "pnpm lttf:output && npx html-pages ./lint-progress --no-cache",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e7e26e5efe8..d57d5832d58 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -296,6 +296,9 @@ importers:
discourse-widget-hbs:
specifier: workspace:1.0.0
version: link:../discourse-widget-hbs
+ ember-curry-component:
+ specifier: ^0.1.0
+ version: 0.1.0(@babel/core@7.26.0)
ember-route-template:
specifier: ^1.0.3
version: 1.0.3
@@ -4341,6 +4344,9 @@ packages:
resolution: {integrity: sha512-2UBUa5SAuPg8/kRVaiOfTwlXdeVweal1zdNPibwItrhR0IvPrXpaqwJDlEZnWKEoB+h33V0JIfiWleSG6hGkkA==}
engines: {node: 10.* || >= 12.*}
+ ember-curry-component@0.1.0:
+ resolution: {integrity: sha512-gHvhO1NlH8ypOGcGfiignkIV4PHSuP5yKlBz1pkf7TjVHsdBBmHOJrvakFXqbXJjM+68DMYSobNg1/Vq0GIt+w==}
+
ember-decorators@6.1.1:
resolution: {integrity: sha512-63vZPntPn1aqMyeNRLoYjJD+8A8obd+c2iZkJflswpDRNVIsp2m7aQdSCtPt4G0U/TEq2251g+N10maHX3rnJQ==}
engines: {node: '>= 8.*'}
@@ -12602,6 +12608,14 @@ snapshots:
- '@babel/core'
- supports-color
+ ember-curry-component@0.1.0(@babel/core@7.26.0):
+ dependencies:
+ '@embroider/addon-shim': 1.9.0
+ decorator-transforms: 2.3.0(@babel/core@7.26.0)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - supports-color
+
ember-decorators@6.1.1:
dependencies:
'@ember-decorators/component': 6.1.1
diff --git a/spec/system/dev_tools_spec.rb b/spec/system/dev_tools_spec.rb
new file mode 100644
index 00000000000..eaf11d2ec7c
--- /dev/null
+++ b/spec/system/dev_tools_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+describe "Discourse dev tools", type: :system do
+ it "works" do
+ # Open site and check it loads successfully, with no dev-tools
+ visit("/latest")
+ expect(page).to have_css("#site-logo")
+ expect(page).not_to have_css(".dev-tools-toolbar")
+
+ # Enable dev tools, and wait for page to reload
+ page.evaluate_script("enableDevTools()")
+ expect(page).to have_css(".dev-tools-toolbar")
+
+ # Turn on plugin outlet debugging, and check they appear
+ find(".dev-tools-toolbar .toggle-plugin-outlets").click
+ expect(page).to have_css(".plugin-outlet-info", minimum: 10)
+
+ # Open a tooltip
+ find(".plugin-outlet-info[data-outlet-name=home-logo-contents__before]").hover
+ expect(page).to have_css(".plugin-outlet-info__wrapper")
+
+ # Check the outletArgs are shown
+ expect(page).to have_css(".plugin-outlet-info__wrapper .key", text: "title")
+ expect(page).to have_css(
+ ".plugin-outlet-info__wrapper .value",
+ text: "\"#{SiteSetting.title}\"",
+ )
+
+ # Turn off plugin outlet debugging, and check they disappeared
+ find(".dev-tools-toolbar .toggle-plugin-outlets").click
+ expect(page).not_to have_css(".plugin-outlet-info")
+
+ # Disable dev tools
+ find(".dev-tools-toolbar .disable-dev-tools").click
+
+ # Check reloaded successfully
+ expect(page).not_to have_css(".dev-tools-toolbar")
+ expect(page).to have_css("#site-logo")
+ expect(page).not_to have_css(".dev-tools-toolbar")
+ end
+end