Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- **A page from a compiled-in module now gets its own styles.** Pages under
`modules/<Name>/resources/js/Pages` are compiled into the bundle when they are present at
build time, but Tailwind never scanned them: it takes what to scan from `.gitignore`, which
lists `modules/`. Such a page rendered with every utility class core happens to use and
none of its own. The build now writes one `@source` per module that ships pages
(`openmesModuleSources()` in `@openmes/module`), because an ignored directory is only
scanned when it is named outright — a glob across it is skipped all the same.
- **WebP uploads work in the Docker image.** The image built GD without WebP, so re-encoding
an uploaded `image/webp` failed with an undefined `imagewebp()`. The test suite did not
catch it because it runs on the CI runner's PHP, not in the image; the build now fails
outright if GD is missing a format the application accepts.
- **`tenants:prune` is scheduled only in demo mode.** It was written for the public demo,
where an account is meant to disappear, and ran every minute on every installation. Nothing
outside the demo sets a tenant's `expires_at`, so it found nothing — but a tenant given an
expiry by hand would have been deleted, with everything cascading from it, within a minute.
The command can still be run by hand.
- **A failing module no longer takes the whole application down.** Providers were registered
inside a try/catch, but Laravel calls `boot()` later, outside it — so a module throwing
there took every route with it, including the admin screen needed to disable the module.
Expand Down
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
/vendor
# Generated on every Vite build by openmesRuntime() from packages/module/contract.js.
/resources/js/runtime.generated.js
# Generated on every Vite build by openmesModuleSources(): which module
# directories Tailwind should scan.
/resources/css/modules.generated.css
Homestead.json
Homestead.yaml
Thumbs.db
Expand Down
11 changes: 9 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ RUN apk add --no-cache \
libzip-dev \
libpng-dev \
libjpeg-turbo-dev \
libwebp-dev \
freetype-dev \
zip \
unzip \
Expand All @@ -16,8 +17,9 @@ RUN apk add --no-cache \
nodejs \
npm

# Configure GD extension
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Configure GD extension. WebP too: uploads of image/webp are accepted and
# re-encoded through GD, which without it has no imagewebp() to do that with.
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp

# Install PHP extensions
RUN docker-php-ext-install \
Expand Down Expand Up @@ -64,6 +66,11 @@ RUN set -eux; \
echo "ionCube: no musl loader for $arch, skipping"; \
fi

# Fail the build, not an upload, if GD came out without a format the
# application accepts. The test suite runs on the CI runner's PHP rather than
# in this image, so nothing else would notice.
RUN php -r 'foreach (["imagejpeg", "imagepng", "imagewebp"] as $f) { if (! function_exists($f)) { fwrite(STDERR, "GD is missing $f()\n"); exit(1); } }'

# PHP upload / memory limits
COPY backend/docker-php-uploads.ini /usr/local/etc/php/conf.d/uploads.ini

Expand Down
5 changes: 5 additions & 0 deletions backend/resources/css/app.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
@import 'tailwindcss';
/* Where the pages of installed modules are. Generated by openmesModuleSources()
in vite.config.js: Tailwind leaves modules/ out of its own scan because
.gitignore lists it, and an ignored directory is only read when it is named
outright. */
@import './modules.generated.css';

/* Class-based dark mode: toggle .dark on <html> */
@custom-variant dark (&:where(.dark, .dark *));
Expand Down
49 changes: 49 additions & 0 deletions backend/resources/js/lib/moduleSources.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { moduleSourcesCss } from '@openmes/module';

// Tailwind skips modules/ because .gitignore lists it, and only reads an
// ignored directory that is named outright -- so the build writes one @source
// per module that ships pages. This is the part that decides which.
describe('module style sources', () => {
let root;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'openmes-modules-'));
mkdirSync(join(root, 'resources/css'), { recursive: true });
});

afterEach(() => rmSync(root, { recursive: true, force: true }));

const css = () => moduleSourcesCss(join(root, 'modules'), join(root, 'resources/css'));
const sources = () => css().split('\n').filter((line) => line.startsWith('@source'));

it('names each module that ships pages, relative to the stylesheet', () => {
mkdirSync(join(root, 'modules/Packaging/resources/js/Pages'), { recursive: true });
mkdirSync(join(root, 'modules/Billing/resources/js/Pages'), { recursive: true });

expect(sources()).toEqual([
'@source "../../modules/Billing/resources/js";',
'@source "../../modules/Packaging/resources/js";',
]);
});

it('leaves out a module with no frontend', () => {
mkdirSync(join(root, 'modules/OrderPinger/Providers'), { recursive: true });
writeFileSync(join(root, 'modules/README.md'), '');

expect(sources()).toEqual([]);
});

it('is still a valid stylesheet when nothing is installed', () => {
// app.css imports the file unconditionally, so it has to exist and
// parse even on an installation without a modules directory at all.
expect(sources()).toEqual([]);
expect(css().trim().startsWith('/*')).toBe(true);
expect(css().trim().endsWith('*/')).toBe(true);
});
});
8 changes: 7 additions & 1 deletion backend/routes/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('tenants:prune')->everyMinute();
// Demo-only: a tenant with an `expires_at` is a throwaway demo account, and this
// deletes it once the time has passed. Scheduled only when DEMO_MODE=true. On
// any other installation nothing sets an expiry, so the run found nothing --
// but it ran every minute all the same, and a tenant given an expiry by hand
// would lose its registry row and everything cascading from it within sixty
// seconds. The command can still be run by hand.
Schedule::command('tenants:prune')->everyMinute()->when(fn () => (bool) config('openmmes.demo_mode'));
// Demo-only: roll OEE/production forward to today so the report never shows N/A
// on a long-running demo. No-op unless DEMO_MODE=true (the command self-guards).
// Runs before oee:calculate so today's production exists first.
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/Feature/PruneExpiredTenantsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,26 @@ public function test_retained_counter_state_rolls_back_user_deletion_and_other_t
$this->assertDatabaseHas('machine_counters', ['id' => $counter->id]);
$this->assertDatabaseMissing('tenants', ['id' => $other->id]);
}

/**
* The command exists for the public demo, where an account is meant to
* disappear. Anywhere else nothing sets an expiry, and a job that deletes a
* tenant within a minute of somebody setting one by hand is not something
* to leave running.
*/
public function test_it_is_only_scheduled_in_demo_mode(): void
{
Artisan::call('schedule:list');

$event = collect(app(\Illuminate\Console\Scheduling\Schedule::class)->events())
->first(fn ($event) => str_contains((string) $event->command, 'tenants:prune'));

$this->assertNotNull($event, 'tenants:prune is no longer on the schedule');

config(['openmmes.demo_mode' => false]);
$this->assertFalse($event->filtersPass($this->app));

config(['openmmes.demo_mode' => true]);
$this->assertTrue($event->filtersPass($this->app));
}
}
6 changes: 5 additions & 1 deletion backend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { openmesRuntime, RUNTIME_ENTRY } from '@openmes/module';
import { openmesModuleSources, openmesRuntime, RUNTIME_ENTRY } from '@openmes/module';

export default defineConfig({
plugins: [
Expand All @@ -21,6 +21,10 @@ export default defineConfig({
tailwindcss(),
react(),
openmesRuntime(),
// Writes resources/css/modules.generated.css, which app.css imports:
// Tailwind scans the pages of installed modules only when told where
// each one is.
openmesModuleSources(),
],
// The dev-overlay watcher (`npm run watch`) sets WATCH_POLL=1: poll for
// changes because inotify misses newly-created files across the bind mount.
Expand Down
1 change: 1 addition & 0 deletions packages/module/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { openmesRuntime, RUNTIME_ENTRY } from './core.js';
export { openmesModule } from './module.js';
export { openmesModuleSources, moduleSourcesCss, MODULE_SOURCES_ENTRY } from './styles.js';
export { CONTRACT, CONTRACT_VERSION, ALLOWED_IMPORTS, PAGES_GLOBAL, RUNTIME_GLOBAL } from './contract.js';
3 changes: 2 additions & 1 deletion packages/module/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"index.js",
"contract.js",
"core.js",
"module.js"
"module.js",
"styles.js"
],
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
Expand Down
93 changes: 93 additions & 0 deletions packages/module/styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Core-side Vite plugin: tells Tailwind where the pages of installed modules
* are, so the utility classes they use end up in the stylesheet.
*
* Pages under modules/<Name>/resources/js/Pages are compiled into the bundle
* when they are present at build time (see the glob in app.jsx). Their classes
* were not: Tailwind decides what to scan from .gitignore, and modules/ is
* listed there because installed modules do not belong to this repository. A
* module page therefore rendered with every class core happens to use
* somewhere and none of its own -- an arbitrary value, a breakpoint variant
* nobody else needed -- which looks like a styling bug in the module.
*
* An ignored directory is scanned only when it is named outright. A glob that
* covers every module at once, or the modules directory itself, is skipped all
* the same, so the list cannot be written once in app.css; it is generated
* here, one line per module that ships pages, and imported from there.
*/

import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve, sep } from 'node:path';

/** Default location of the generated stylesheet, relative to the Vite root. */
export const MODULE_SOURCES_ENTRY = 'resources/css/modules.generated.css';

const HEADER = [
'/* GENERATED by @openmes/module — do not edit.',
' Regenerated on every Vite build: one @source per installed module that',
' ships pages, because Tailwind does not scan modules/ on its own. */',
];

/**
* The stylesheet's content for the modules found under `modulesDir`.
*
* Paths are written relative to the stylesheet, which is how Tailwind reads an
* @source, and with forward slashes whatever the platform.
*
* @param {string} modulesDir absolute path of the modules directory
* @param {string} cssDir absolute path of the directory the stylesheet is written to
*/
export function moduleSourcesCss(modulesDir, cssDir) {
const names = existsSync(modulesDir)
? readdirSync(modulesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => entry.name)
.filter((name) => existsSync(resolve(modulesDir, name, 'resources/js')))
.sort()
: [];

const sources = names.map((name) => {
const path = relative(cssDir, resolve(modulesDir, name, 'resources/js')).split(sep).join('/');

return `@source ${JSON.stringify(path)};`;
});

return `${[...HEADER, ...sources].join('\n')}\n`;
}

/**
* @param {{ entry?: string, modules?: string }} options
* entry — where to write the stylesheet, relative to the Vite root. Must
* match the @import in app.css.
* modules — the modules directory, relative to the Vite root.
*/
export function openmesModuleSources(options = {}) {
const entry = options.entry ?? MODULE_SOURCES_ENTRY;
const modules = options.modules ?? 'modules';

return {
name: 'openmes-module-sources',

// `config` runs before any stylesheet is transformed, so the file
// app.css imports is on disk by the time Tailwind follows the import.
config(config) {
const root = config.root ?? process.cwd();
const target = resolve(root, entry);
const source = moduleSourcesCss(resolve(root, modules), dirname(target));

// Only write when the content changed, for the same reason the
// runtime entry does: a touched input restarts the watcher.
let current = null;
try {
current = readFileSync(target, 'utf8');
} catch {
// Not there yet — first build in a fresh checkout.
}

if (current !== source) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, source, 'utf8');
}
},
};
}
Loading