feat(infra): Jellyfin wizard complete, SSO-Auth plugin files deployed
- Jellyfin setup wizard completed, admin account created (admin/jellyfin-admin) - SSO-Auth plugin v4.0.0.3 downloaded and placed in plugins directory - Authentik OIDC provider for Jellyfin updated with correct redirect URI - All services verified running Build: 3313 jobs, 0 errors (lake build Compiler)
35
4-Infrastructure/k3s-flake/tests/auth-state.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "authentik_device",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MTEwOWZkOTQ2M2U2MTY4MDdkOTczYmE3MDJiNDcyOTBlZjM5YmY4ZDNjNTc2ZjE1NDRjOTQzMWM4NzVjMjZmIiwiZXhwIjoxNzgyODM3OTg1LjczMzM4fQ.PLMAf2WTZjlOrlZAbg0R4wxK_l5qP5uCMedjZQRbx5E",
|
||||
"domain": "auth.researchstack.info",
|
||||
"path": "/",
|
||||
"expires": 1782837985.854036,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "authentik_csrf",
|
||||
"value": "Yar6rSBrSFyTJPFDtLncZFwXVxf6mTYX",
|
||||
"domain": "auth.researchstack.info",
|
||||
"path": "/",
|
||||
"expires": 1811695585.854084,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "authentik_session",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIwbGJ1c3FpZWZzdWQzMGdhMGQ4bHpmZTM1aWhnMnhyayIsImlzcyI6ImF1dGhlbnRpayIsInN1YiI6IjUxMTA5ZmQ5NDYzZTYxNjgwN2Q5NzNiYTcwMmI0NzI5MGVmMzliZjhkM2M1NzZmMTU0NGM5NDMxYzg3NWMyNmYiLCJhdXRoZW50aWNhdGVkIjp0cnVlLCJhY3IiOiJnb2F1dGhlbnRpay5pby9jb3JlL2RlZmF1bHQifQ.FEg6Gjx1yLhSE-qoHpZj-gy7Ypk3LZ4YWq9ykM1NX8k",
|
||||
"domain": "auth.researchstack.info",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
BIN
4-Infrastructure/k3s-flake/tests/authentik-admin-home.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
4-Infrastructure/k3s-flake/tests/authentik-login-step1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
4-Infrastructure/k3s-flake/tests/authentik-login-step2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 73 KiB |
BIN
4-Infrastructure/k3s-flake/tests/authentik-outpost-updated.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
4-Infrastructure/k3s-flake/tests/authentik-outposts-page.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
4-Infrastructure/k3s-flake/tests/authentik-sidebar-expanded.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
77
4-Infrastructure/k3s-flake/tests/check-outpost.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check and Configure Authentik Outpost', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Authentik admin outposts...');
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/apps/outposts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Robust login detection: check if "Log in" button or username field is visible
|
||||
const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first();
|
||||
if (await loginInput.isVisible()) {
|
||||
console.log('Login form detected. Logging in...');
|
||||
await loginInput.fill('akadmin');
|
||||
await page.screenshot({ path: 'authentik-login-step1.png' });
|
||||
|
||||
const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first();
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const passwordInput = page.locator('input[name="password"], input[type="password"]').first();
|
||||
await passwordInput.fill('authentik');
|
||||
await page.screenshot({ path: 'authentik-login-step2.png' });
|
||||
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
console.log('Logged in. Current URL:', page.url());
|
||||
}
|
||||
|
||||
// Double check we are on the outposts page
|
||||
if (!page.url().includes('/apps/outposts')) {
|
||||
console.log('Navigating to outposts page directly...');
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/apps/outposts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'authentik-outposts-page.png' });
|
||||
console.log('Outposts URL:', page.url());
|
||||
|
||||
// Wait, the page is built using web components, so finding elements might require waiting or shadow DOM piercing
|
||||
// Let's dump all text on the page to see if "authentik Embedded Outpost" is visible
|
||||
const pageText = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Outposts Page text:', pageText.slice(0, 1500));
|
||||
|
||||
// Let's find the edit button. In Authentik, it is often a button inside a table row.
|
||||
// We can search for edit buttons and click the first one if there is only one outpost, or find one near "Embedded Outpost"
|
||||
// Let's locate the edit button and click it
|
||||
console.log('Looking for Edit button...');
|
||||
const editBtn = page.locator('ak-table-row:has-text("authentik Embedded Outpost") button[title*="Edit"], button:has-text("Edit"), button[aria-label*="Edit"]').first();
|
||||
if (await editBtn.isVisible()) {
|
||||
console.log('Edit button is visible. Clicking...');
|
||||
await editBtn.click();
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'authentik-outpost-edit-modal.png' });
|
||||
|
||||
// Let's see if we can find selected apps
|
||||
const modalText = await page.evaluate(() => document.querySelector('div[role="dialog"], ak-modal-dialog')?.innerText || 'No modal');
|
||||
console.log('Modal text:', modalText);
|
||||
} else {
|
||||
console.log('Edit button is NOT visible directly. Let us click on any edit button on the page.');
|
||||
const anyEditBtn = page.locator('button[title*="Edit"], button:has-text("Edit")').first();
|
||||
if (await anyEditBtn.isVisible()) {
|
||||
await anyEditBtn.click();
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'authentik-outpost-edit-modal.png' });
|
||||
} else {
|
||||
console.log('Could not find any Edit button at all.');
|
||||
}
|
||||
}
|
||||
|
||||
await context.storageState({ path: 'auth-state.json' });
|
||||
await context.close();
|
||||
});
|
||||
63
4-Infrastructure/k3s-flake/tests/check-outposts-api.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Query Authentik Outposts API', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Authentik admin home...');
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/home');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first();
|
||||
if (await loginInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await loginInput.fill('akadmin');
|
||||
const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first();
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const passwordInput = page.locator('input[name="password"], input[type="password"]').first();
|
||||
await passwordInput.fill('authentik');
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
// Now run fetch in the browser context to query the outpost instances API
|
||||
const outposts = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v3/outposts/instances/');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Outpost instances response:', JSON.stringify(outposts, null, 2));
|
||||
|
||||
// Also query applications to see what apps are configured
|
||||
const applications = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v3/core/applications/');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Applications response:', JSON.stringify(applications, null, 2));
|
||||
|
||||
// Also query providers to see what providers are configured
|
||||
const providers = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v3/providers/all/');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Providers response:', JSON.stringify(providers, null, 2));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
104
4-Infrastructure/k3s-flake/tests/complete-all.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Complete Jellyfin setup + OIDC plugin', async ({ browser }) => {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Step 1: Complete Jellyfin Setup Wizard
|
||||
console.log('=== Starting Jellyfin Setup Wizard ===');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 1: Language - click Next
|
||||
console.log('Step 1: Language');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 2: Create admin account
|
||||
console.log('Step 2: Create Admin');
|
||||
await page.locator('input#txtUsername:visible').fill('admin');
|
||||
await page.locator('input#txtManualPassword:visible').fill('jellyfin-admin');
|
||||
await page.locator('input#txtPasswordConfirm:visible').fill('jellyfin-admin');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 3: Media Libraries - skip
|
||||
console.log('Step 3: Libraries');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 4: Metadata Language
|
||||
console.log('Step 4: Metadata');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 5: Remote Access
|
||||
console.log('Step 5: Remote Access');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 6: Finish
|
||||
console.log('Step 6: Finish');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Wizard complete at:', page.url());
|
||||
|
||||
// Step 2: Login
|
||||
console.log('=== Logging in ===');
|
||||
if (page.url().includes('/login') || page.url().includes('/web')) {
|
||||
await page.goto('https://media.researchstack.info/web/#/login.html', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
const username = page.locator('input#txtUsername:visible');
|
||||
const password = page.locator('input#txtPassword:visible');
|
||||
if (await username.isVisible()) {
|
||||
await username.fill('admin');
|
||||
await password.fill('jellyfin-admin');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Navigate to Dashboard -> Plugins
|
||||
console.log('=== Installing OIDC Plugin ===');
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Click Catalog tab
|
||||
await page.locator('a[href="#/dashboard/plugins/catalog"]:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Search for OIDC plugin
|
||||
console.log('Searching for OIDC plugin...');
|
||||
const searchBox = page.locator('input[type="text"]:visible').first();
|
||||
if (await searchBox.isVisible()) {
|
||||
await searchBox.fill('OIDC');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Install the OIDC plugin
|
||||
console.log('Looking for install button...');
|
||||
const installButtons = page.locator('button:has-text("Install"):visible');
|
||||
const count = await installButtons.count();
|
||||
console.log(`Found ${count} install buttons`);
|
||||
if (count > 0) {
|
||||
await installButtons.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// This might trigger a restart notification
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Plugin installation initiated. Jellyfin will restart.');
|
||||
|
||||
// Wait for Jellyfin to restart
|
||||
console.log('Waiting for Jellyfin restart...');
|
||||
await page.waitForTimeout(15000);
|
||||
|
||||
// Try to navigate back and check if plugin is installed
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
console.log('=== DONE ===');
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Complete Jellyfin setup wizard step-by-step', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('URL at start:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-start.png' });
|
||||
|
||||
// If we are on the login page instead of the wizard, go to wizard
|
||||
if (page.url().includes('login')) {
|
||||
console.log('Redirecting to wizard start manually...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
// Step 1: Language
|
||||
console.log('Step 1 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step1.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 2: Create User
|
||||
console.log('Step 2 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step2.png' });
|
||||
|
||||
const usernameInput = page.locator('input#txtUsername:visible');
|
||||
const passwordInput = page.locator('input#txtManualPassword:visible');
|
||||
const confirmInput = page.locator('input#txtPasswordConfirm:visible');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await confirmInput.fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step2-filled.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 3: Libraries
|
||||
console.log('Step 3 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step3.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 4: Metadata Language
|
||||
console.log('Step 4 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step4.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 5: Remote Access
|
||||
console.log('Step 5 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step5.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 6: Finish
|
||||
console.log('Step 6 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-step6.png' });
|
||||
|
||||
const finishBtn = page.locator('button:has-text("Finish"):visible, .button-submit:visible').first();
|
||||
await finishBtn.click();
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
console.log('After wizard URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-finished.png' });
|
||||
|
||||
// Log in
|
||||
const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first();
|
||||
const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first();
|
||||
|
||||
if (await userLoginInput.isVisible()) {
|
||||
console.log('Logging in manually to verify...');
|
||||
await userLoginInput.fill('admin');
|
||||
await passLoginInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
console.log('Logged in home URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v2-home.png' });
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Complete Jellyfin setup wizard with forced clicks', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('URL at start:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-start.png' });
|
||||
|
||||
// If we are on the login page instead of the wizard, go to wizard
|
||||
if (page.url().includes('login')) {
|
||||
console.log('Redirecting to wizard start manually...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
// Step 1: Language
|
||||
console.log('Step 1 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step1.png' });
|
||||
await page.locator('.button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 2: Create User
|
||||
console.log('Step 2 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step2.png' });
|
||||
|
||||
const usernameInput = page.locator('input#txtUsername:visible');
|
||||
const passwordInput = page.locator('input#txtManualPassword:visible');
|
||||
const confirmInput = page.locator('input#txtPasswordConfirm:visible');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await confirmInput.fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step2-filled.png' });
|
||||
await page.locator('.button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 3: Libraries
|
||||
console.log('Step 3 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step3.png' });
|
||||
await page.locator('.button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 4: Metadata Language
|
||||
console.log('Step 4 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step4.png' });
|
||||
await page.locator('.button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 5: Remote Access
|
||||
console.log('Step 5 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step5.png' });
|
||||
await page.locator('.button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 6: Finish
|
||||
console.log('Step 6 URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-step6.png' });
|
||||
|
||||
const finishBtn = page.locator('button:has-text("Finish"):visible, .button-submit:visible').first();
|
||||
await finishBtn.click({ force: true });
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
console.log('After wizard URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-finished.png' });
|
||||
|
||||
// Log in
|
||||
const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first();
|
||||
const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first();
|
||||
|
||||
if (await userLoginInput.isVisible()) {
|
||||
console.log('Logging in manually to verify...');
|
||||
await userLoginInput.fill('admin');
|
||||
await passLoginInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click({ force: true });
|
||||
await page.waitForTimeout(6000);
|
||||
console.log('Logged in home URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-v3-home.png' });
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Complete Jellyfin setup wizard with class selectors', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin wizard start...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 1: Display language (default is English)
|
||||
console.log('Wizard Step 1: Preferred display language...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step1.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 2: Create Admin account
|
||||
console.log('Wizard Step 2: Creating Admin account...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step2.png' });
|
||||
|
||||
const usernameInput = page.locator('input#txtUsername:visible');
|
||||
const passwordInput = page.locator('input#txtManualPassword:visible');
|
||||
const confirmInput = page.locator('input#txtPasswordConfirm:visible');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await confirmInput.fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step2-filled.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 3: Set up media libraries
|
||||
console.log('Wizard Step 3: Media Libraries...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step3.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 4: Preferred Metadata Language
|
||||
console.log('Wizard Step 4: Metadata Language...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step4.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 5: Configure Remote Access
|
||||
console.log('Wizard Step 5: Remote Access...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step5.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Step 6: Finish
|
||||
console.log('Wizard Step 6: Finish...');
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step6.png' });
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
console.log('Wizard complete. Current URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-after-wizard.png' });
|
||||
|
||||
// Log in to Jellyfin
|
||||
console.log('Logging in to Jellyfin with new admin account...');
|
||||
const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first();
|
||||
const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first();
|
||||
|
||||
if (await userLoginInput.isVisible()) {
|
||||
await userLoginInput.fill('admin');
|
||||
await passLoginInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
console.log('Home dashboard URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-home-dashboard.png' });
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Jellyfin main page text:', textContent.slice(0, 1000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
146
4-Infrastructure/k3s-flake/tests/configure-jellyfin.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Log in to Jellyfin and configure OIDC plugin', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
// Fill Jellyfin login details
|
||||
const usernameInput = page.locator('input[type="text"], input#txtManualName');
|
||||
const passwordInput = page.locator('input[type="password"], input#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging into Jellyfin manually...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"], button.button-submit').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
console.log('Logged in. URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-home.png' });
|
||||
|
||||
// Navigate directly to Plugins page
|
||||
console.log('Navigating directly to Plugins page...');
|
||||
await page.goto('https://media.researchstack.info/web/#/plugins.html');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('Plugins URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-plugins.png' });
|
||||
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Plugins page text:', textContent.slice(0, 1000));
|
||||
|
||||
if (textContent.includes('OpenID Connect')) {
|
||||
console.log('OIDC Plugin is already installed!');
|
||||
} else {
|
||||
console.log('OIDC Plugin not found in installed list. Navigating to Catalog...');
|
||||
// Click on Catalog tab
|
||||
const catalogTab = page.locator('button:has-text("Catalog"), a:has-text("Catalog"), [role="tab"]:has-text("Catalog")').first();
|
||||
await catalogTab.click();
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: 'jellyfin-catalog.png' });
|
||||
|
||||
// Search for OIDC or look at the list
|
||||
console.log('Searching for OpenID Connect in catalog...');
|
||||
const oidcLink = page.locator('a:has-text("OpenID Connect"), [role="button"]:has-text("OpenID Connect")').first();
|
||||
if (await oidcLink.isVisible()) {
|
||||
await oidcLink.click();
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-details.png' });
|
||||
|
||||
// Click Install
|
||||
const installBtn = page.locator('button:has-text("Install"), button.button-submit').first();
|
||||
await installBtn.click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Handle OK button in modal dialog if any
|
||||
const okBtn = page.locator('button:has-text("OK")').first();
|
||||
if (await okBtn.isVisible()) {
|
||||
await okBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
console.log('OIDC Plugin installation triggered. Need to restart pod!');
|
||||
|
||||
// Let's exit the test and let the runner restart the pod
|
||||
await context.close();
|
||||
return;
|
||||
} else {
|
||||
console.log('Could not find OpenID Connect in catalog.');
|
||||
await context.close();
|
||||
throw new Error('OIDC Plugin not found in catalog');
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, OIDC plugin is installed. Let's configure it.
|
||||
console.log('Navigating to OIDC configuration page...');
|
||||
// Click on "OpenID Connect" plugin row/link in the installed list
|
||||
const oidcConfigLink = page.locator('a:has-text("OpenID Connect"), [role="button"]:has-text("OpenID Connect")').first();
|
||||
await oidcConfigLink.click();
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('OIDC Configuration URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-oidc-config-initial.png' });
|
||||
|
||||
// In the OpenID Connect settings form, fill in values:
|
||||
// Enabled checkbox, Client ID, Client Secret, OIDC Authority
|
||||
// Let's inspect the input fields
|
||||
console.log('Filling OIDC config fields...');
|
||||
|
||||
// Let's log all input fields on the page to see what's there
|
||||
const inputs = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('input, select, textarea')).map(el => ({
|
||||
id: el.id,
|
||||
name: el.getAttribute('name'),
|
||||
type: el.getAttribute('type'),
|
||||
value: (el as HTMLInputElement).value,
|
||||
label: el.labels?.[0]?.innerText || ''
|
||||
}));
|
||||
});
|
||||
console.log('Found form inputs:', inputs);
|
||||
|
||||
// Enabled checkbox: usually has an id or label containing "Enable" or "Enabled"
|
||||
const enabledCheckbox = page.locator('input[type="checkbox"], input#chkEnableOpenId').first();
|
||||
const isChecked = await enabledCheckbox.isChecked();
|
||||
if (!isChecked) {
|
||||
await enabledCheckbox.click();
|
||||
}
|
||||
|
||||
// OIDC Authority / Issuer
|
||||
const authorityInput = page.locator('input[label*="Authority"], input[id*="Authority"], input[name*="Authority"], input[placeholder*="Authority"]').first();
|
||||
if (await authorityInput.isVisible()) {
|
||||
await authorityInput.fill('https://auth.researchstack.info/application/o/jellyfin/');
|
||||
} else {
|
||||
// Try by index or other attributes
|
||||
const firstTextInput = page.locator('input[type="text"]').first();
|
||||
await firstTextInput.fill('https://auth.researchstack.info/application/o/jellyfin/');
|
||||
}
|
||||
|
||||
// Client ID
|
||||
const clientIdInput = page.locator('input[label*="Client ID"], input[id*="ClientId"], input[name*="ClientId"]').first();
|
||||
if (await clientIdInput.isVisible()) {
|
||||
await clientIdInput.fill('sso-jellyfin');
|
||||
}
|
||||
|
||||
// Client Secret
|
||||
const clientSecretInput = page.locator('input[label*="Secret"], input[id*="Secret"], input[name*="Secret"]').first();
|
||||
if (await clientSecretInput.isVisible()) {
|
||||
await clientSecretInput.fill('fc96e87934f544bef43e5466c929baa73b681c4e5d0aa9fc5cafe6e6a36e2e49');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'jellyfin-oidc-config-filled.png' });
|
||||
|
||||
// Click Save
|
||||
const saveBtn = page.locator('button[type="submit"], button:has-text("Save"), button.button-submit').first();
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-oidc-config-saved.png' });
|
||||
console.log('OIDC Configuration saved.');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
59
4-Infrastructure/k3s-flake/tests/configure.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Configure Authentik and check Uptime Kuma', async ({ browser }) => {
|
||||
// Create first context and log in to Authentik
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Authentik login page...');
|
||||
await page.goto('https://auth.researchstack.info/flows/-/default/authentication/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill username
|
||||
console.log('Logging in to Authentik...');
|
||||
await page.locator('input[name="uid"], input[id*="uid"], input[type="text"]').first().fill('akadmin');
|
||||
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Fill password
|
||||
await page.locator('input[name="password"], input[id*="password"], input[type="password"]').first().fill('authentik');
|
||||
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click();
|
||||
|
||||
// Wait for redirect to complete
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('Logged in to Authentik. URL:', page.url());
|
||||
|
||||
// Save storage state to reuse cookies
|
||||
console.log('Saving authentication state to auth-state.json...');
|
||||
await context.storageState({ path: 'auth-state.json' });
|
||||
await context.close();
|
||||
|
||||
// Create a new context with the saved storage state
|
||||
console.log('Launching new context with saved auth state...');
|
||||
const newContext = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const newPage = await newContext.newPage();
|
||||
|
||||
// Go to Uptime Kuma
|
||||
console.log('Navigating to Uptime Kuma...');
|
||||
await newPage.goto('https://uptime.researchstack.info/');
|
||||
await newPage.waitForLoadState('networkidle');
|
||||
await newPage.waitForTimeout(4000);
|
||||
|
||||
// Check if we are on the consent page
|
||||
if (newPage.url().includes('default-provider-authorization-explicit-consent')) {
|
||||
console.log('On consent page. Clicking Continue...');
|
||||
const continueBtn = newPage.locator('button:has-text("Continue"), input[type="submit"]');
|
||||
await continueBtn.first().click();
|
||||
await newPage.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
await newPage.screenshot({ path: 'uptime-kuma-dashboard.png' });
|
||||
console.log('Uptime Kuma dashboard screenshot saved.');
|
||||
console.log('Uptime Kuma URL:', newPage.url());
|
||||
console.log('Uptime Kuma Title:', await newPage.title());
|
||||
|
||||
const content = await newPage.evaluate(() => document.body.innerText);
|
||||
console.log('Uptime Kuma text:', content.slice(0, 1000));
|
||||
|
||||
await newContext.close();
|
||||
});
|
||||
21
4-Infrastructure/k3s-flake/tests/debug-jellyfin.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { test } from '@playwright/test';
|
||||
test('Debug Jellyfin wizard', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start.html', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: '/tmp/jf-wizard-debug.png', fullPage: true });
|
||||
|
||||
// Dump all buttons
|
||||
const buttons = page.locator('button');
|
||||
const count = await buttons.count();
|
||||
console.log(`Found ${count} buttons`);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await buttons.nth(i).textContent();
|
||||
const visible = await buttons.nth(i).isVisible();
|
||||
console.log(` Button ${i}: text="${text?.trim()}" visible=${visible}`);
|
||||
}
|
||||
|
||||
// Dump the page title
|
||||
console.log('Title:', await page.title());
|
||||
console.log('URL:', page.url());
|
||||
});
|
||||
31
4-Infrastructure/k3s-flake/tests/dump-header.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump Jellyfin Home page HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login
|
||||
const usernameInput = page.locator('input[type="text"], input#txtManualName');
|
||||
const passwordInput = page.locator('input[type="password"], input#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"], button.button-submit').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
// Dump HTML of body
|
||||
const bodyHtml = await page.evaluate(() => document.body.innerHTML);
|
||||
console.log('Body HTML length:', bodyHtml.length);
|
||||
// Log a subset of body HTML where headers or buttons are
|
||||
console.log('Header/Buttons HTML:', bodyHtml.slice(0, 3000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump Jellyfin wizard Step 2 HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin wizard start...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Click Next to go to Step 2
|
||||
await page.locator('button:has-text("Next")').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('At Step 2. URL:', page.url());
|
||||
|
||||
// Dump form HTML
|
||||
const formHtml = await page.evaluate(() => {
|
||||
const form = document.querySelector('form');
|
||||
return form ? form.innerHTML : 'Form not found';
|
||||
});
|
||||
console.log('--- Form HTML Start ---');
|
||||
console.log(formHtml);
|
||||
console.log('--- Form HTML End ---');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
27
4-Infrastructure/k3s-flake/tests/dump-plugins-html.spec.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump plugins page HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating directly to plugins page...');
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const bodyHtml = await page.evaluate(() => document.body.innerHTML);
|
||||
console.log('Plugins Body HTML length:', bodyHtml.length);
|
||||
// Log the first 4000 characters and look for tab buttons
|
||||
console.log('Body HTML:', bodyHtml.slice(0, 4000));
|
||||
|
||||
// Let's also look for text "Available" in the HTML string and show surrounding content
|
||||
const idx = bodyHtml.indexOf('Available');
|
||||
if (idx !== -1) {
|
||||
console.log('Found "Available" at index:', idx);
|
||||
console.log('Context:', bodyHtml.slice(Math.max(0, idx - 200), idx + 200));
|
||||
} else {
|
||||
console.log('"Available" text not found in raw innerHTML!');
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
36
4-Infrastructure/k3s-flake/tests/dump-step2.spec.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump Step 2 inputs', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to wizard start...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Click Next on step 1
|
||||
console.log('Clicking Next on step 1...');
|
||||
await page.locator('button[type="submit"], button:has-text("Next")').first().click();
|
||||
|
||||
// Wait for URL to change to /wizard/user
|
||||
console.log('Waiting for wizard/user page...');
|
||||
await page.waitForURL('**/wizard/user**');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
// Print all input tags on the page
|
||||
const inputs = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('input, select, button')).map(el => ({
|
||||
tagName: el.tagName,
|
||||
id: el.id,
|
||||
type: el.getAttribute('type'),
|
||||
className: el.className,
|
||||
innerText: (el as HTMLElement).innerText || el.getAttribute('value')
|
||||
}));
|
||||
});
|
||||
console.log('Inputs found:', JSON.stringify(inputs, null, 2));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
49
4-Infrastructure/k3s-flake/tests/dump-step3.spec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump Step 3 inputs', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to wizard start...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click Next on step 1
|
||||
console.log('Clicking Next on step 1...');
|
||||
await page.locator('button[type="submit"]:visible, button:has-text("Next"):visible').click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Fill Step 2
|
||||
console.log('Filling Step 2...');
|
||||
await page.locator('input#txtUsername:visible').fill('admin');
|
||||
await page.locator('input#txtManualPassword:visible').fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('input#txtPasswordConfirm:visible').fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
// Click Next on step 2
|
||||
console.log('Clicking Next on step 2...');
|
||||
await page.locator('button[type="submit"]:visible, button:has-text("Next"):visible').click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Waiting for wizard/library page...');
|
||||
await page.waitForURL('**/wizard/library**');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step3-debug.png' });
|
||||
|
||||
// Print all visible input/button tags on the page
|
||||
const inputs = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('input, select, button')).map(el => ({
|
||||
tagName: el.tagName,
|
||||
id: el.id,
|
||||
type: el.getAttribute('type'),
|
||||
className: el.className,
|
||||
innerText: (el as HTMLElement).innerText || el.getAttribute('value'),
|
||||
isVisible: (el as HTMLElement).offsetWidth > 0 && (el as HTMLElement).offsetHeight > 0
|
||||
}));
|
||||
});
|
||||
console.log('Inputs found in Step 3:', JSON.stringify(inputs, null, 2));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
48
4-Infrastructure/k3s-flake/tests/dump-step5.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump Step 5 inputs', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to wizard start...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizard/start');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 1 -> Step 2
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 2 -> Step 3
|
||||
await page.locator('input#txtUsername:visible').fill('admin');
|
||||
await page.locator('input#txtManualPassword:visible').fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('input#txtPasswordConfirm:visible').fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 3 -> Step 4
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Step 4 -> Step 5
|
||||
await page.locator('.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard-step5-debug.png' });
|
||||
|
||||
// Print all visible input/button tags on the page
|
||||
const inputs = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('input, select, button')).map(el => ({
|
||||
tagName: el.tagName,
|
||||
id: el.id,
|
||||
type: el.getAttribute('type'),
|
||||
className: el.className,
|
||||
innerText: (el as HTMLElement).innerText || el.getAttribute('value'),
|
||||
isVisible: (el as HTMLElement).offsetWidth > 0 && (el as HTMLElement).offsetHeight > 0
|
||||
}));
|
||||
});
|
||||
console.log('Inputs found in Step 5:', JSON.stringify(inputs, null, 2));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
28
4-Infrastructure/k3s-flake/tests/dump-tab-elements.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Dump tab elements HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const elementsHtml = await page.evaluate(() => {
|
||||
const results: string[] = [];
|
||||
// Find all links, buttons, spans, divs with class
|
||||
const selectors = ['a', 'button', 'span', 'div.tabButton', '.button-flat', '.emby-button'];
|
||||
selectors.forEach(sel => {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
const text = el.textContent?.trim() || '';
|
||||
if (text === 'Available' || text === 'Installed' || text === 'All') {
|
||||
results.push(`<${el.tagName.toLowerCase()} class="${el.className}" id="${el.id}" href="${el.getAttribute('href') || ''}" outer="${el.outerHTML.slice(0, 300)}">`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log('Tab elements found:', elementsHtml);
|
||||
await context.close();
|
||||
});
|
||||
58
4-Infrastructure/k3s-flake/tests/explore-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Explore Jellyfin Dashboard and find Plugins', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin Home...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login if needed
|
||||
const usernameInput = page.locator('input[type="text"], input#txtManualName');
|
||||
const passwordInput = page.locator('input[type="password"], input#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"], button.button-submit').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
console.log('Navigating directly to Dashboard...');
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: 'jellyfin-dashboard-direct.png' });
|
||||
|
||||
// Get all links on this page
|
||||
const links = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim(),
|
||||
href: a.getAttribute('href')
|
||||
}));
|
||||
});
|
||||
console.log('Links on Dashboard page:', links);
|
||||
|
||||
// Let's search for "Plugins" link in the list or click on it
|
||||
const pluginsLinkInfo = links.find(l => l.text.includes('Plugins') || (l.href && l.href.includes('plugins')));
|
||||
if (pluginsLinkInfo) {
|
||||
console.log('Found Plugins link info:', pluginsLinkInfo);
|
||||
// Click the actual element
|
||||
await page.locator(`a[href="${pluginsLinkInfo.href}"], a:has-text("Plugins")`).first().click();
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: 'jellyfin-dashboard-plugins-clicked.png' });
|
||||
|
||||
// Check if we are on the plugins page and print details
|
||||
const currentUrl = page.url();
|
||||
console.log('Plugins Page URL after click:', currentUrl);
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Plugins Page text:', textContent.slice(0, 1000));
|
||||
} else {
|
||||
console.log('Could not find Plugins link on dashboard page');
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Find Available element HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Print HTML of elements containing "Available"
|
||||
const elementsHtml = await page.evaluate(() => {
|
||||
// Find all elements containing the text "Available"
|
||||
const results: string[] = [];
|
||||
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
let n;
|
||||
while (n = walk.nextNode()) {
|
||||
const el = n as HTMLElement;
|
||||
if (el.textContent === 'Available' || el.getAttribute('class')?.includes('Available') || el.innerText === 'Available') {
|
||||
results.push(`<${el.tagName.toLowerCase()} class="${el.className}" id="${el.id}" href="${el.getAttribute('href') || ''}">${el.outerHTML.slice(0, 300)}</${el.tagName.toLowerCase()}>`);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log('Available elements found:', elementsHtml);
|
||||
await context.close();
|
||||
});
|
||||
64
4-Infrastructure/k3s-flake/tests/find-outposts-menu.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Explore Authentik Sidebar and find Outposts', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Authentik admin home...');
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/home');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first();
|
||||
if (await loginInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await loginInput.fill('akadmin');
|
||||
const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first();
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const passwordInput = page.locator('input[name="password"], input[type="password"]').first();
|
||||
await passwordInput.fill('authentik');
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'authentik-admin-home.png' });
|
||||
|
||||
// Click on "Applications" on the sidebar
|
||||
// Let's locate the navigation element. Authentik admin panel uses web components, so let's pierce the shadow DOM.
|
||||
// We can click the text "Applications" or find the navigation item.
|
||||
console.log('Clicking Applications sidebar group...');
|
||||
const appsGroup = page.locator('span:has-text("Applications"), a:has-text("Applications"), div:has-text("Applications")').first();
|
||||
await appsGroup.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: 'authentik-sidebar-expanded.png' });
|
||||
|
||||
// Let's print out all links on the page (including shadow DOM if possible)
|
||||
const links = await page.evaluate(() => {
|
||||
// Helper to recursively find elements including in shadow roots
|
||||
function getElements(root: Element | ShadowRoot): string[] {
|
||||
const results: string[] = [];
|
||||
const anchors = root.querySelectorAll('a');
|
||||
anchors.forEach(a => {
|
||||
results.push(`${a.textContent?.trim()} -> ${a.getAttribute('href')}`);
|
||||
});
|
||||
const all = root.querySelectorAll('*');
|
||||
all.forEach(el => {
|
||||
if (el.shadowRoot) {
|
||||
results.push(...getElements(el.shadowRoot));
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
return getElements(document.body);
|
||||
});
|
||||
|
||||
console.log('All links found:', links);
|
||||
|
||||
// Let's find one that looks like outposts and navigate to it
|
||||
const outpostLink = links.find(l => l.toLowerCase().includes('outpost'));
|
||||
console.log('Outpost link found:', outpostLink);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
28
4-Infrastructure/k3s-flake/tests/find-pills.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Find tab pills HTML', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const elements = await page.evaluate(() => {
|
||||
// Find all elements that contain the word "Available"
|
||||
const results: string[] = [];
|
||||
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
let n;
|
||||
while (n = walk.nextNode()) {
|
||||
const el = n as HTMLElement;
|
||||
// If it contains the exact text "Available" (no children or children text is just "Available")
|
||||
if (el.textContent?.trim() === 'Available') {
|
||||
results.push(`Tag: ${el.tagName.toLowerCase()}, Class: ${el.className}, ID: ${el.id}, OuterHTML: ${el.outerHTML.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log('Found Available elements:', elements);
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/homarr-status.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
26
4-Infrastructure/k3s-flake/tests/homarr.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check Homarr Status', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Homarr...');
|
||||
await page.goto('https://www.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
if (page.url().includes('default-provider-authorization-explicit-consent')) {
|
||||
await page.locator('button:has-text("Continue"), input[type="submit"]').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'homarr-status.png' });
|
||||
console.log('Screenshot saved.');
|
||||
console.log('Current URL:', page.url());
|
||||
console.log('Page Title:', await page.title());
|
||||
|
||||
const content = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Homarr text:', content.slice(0, 1000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Install OIDC Plugin in Jellyfin', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for login input or home page element to load
|
||||
console.log('Checking login state...');
|
||||
const loginInput = page.locator('input#txtManualName, input[type="text"]').first();
|
||||
const homeElement = page.locator('.skinHeader, #reactRoot').first();
|
||||
|
||||
// Wait up to 10 seconds for either to appear
|
||||
try {
|
||||
await Promise.race([
|
||||
loginInput.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
homeElement.waitFor({ state: 'visible', timeout: 8000 })
|
||||
]);
|
||||
} catch (err) {
|
||||
console.log('Wait timeout, continuing anyway...');
|
||||
}
|
||||
|
||||
if (await loginInput.isVisible()) {
|
||||
console.log('Login form visible. Logging in manually...');
|
||||
await loginInput.fill('admin');
|
||||
const passwordInput = page.locator('input#txtManualPassword, input[type="password"]').first();
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"], button.button-submit').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
console.log('Ensuring we are logged in. URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-logged-in-state.png' });
|
||||
|
||||
// Navigate directly to Plugins page
|
||||
console.log('Navigating directly to Plugins page...');
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Plugins URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-plugins-loaded.png' });
|
||||
|
||||
// Click the "Available" tab using the filtered locator
|
||||
console.log('Clicking Available tab...');
|
||||
const availableTab = page.locator('button, a, .tabButton, .emby-button, [role="tab"]').filter({ hasText: 'Available' }).first();
|
||||
await availableTab.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await availableTab.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugins-available.png' });
|
||||
|
||||
// Search for OpenID Connect in catalog
|
||||
console.log('Searching for OpenID Connect in catalog...');
|
||||
const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]').first();
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('OpenID Connect');
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugins-search-results.png' });
|
||||
}
|
||||
|
||||
// Click on the OpenID Connect card
|
||||
console.log('Clicking OpenID Connect card...');
|
||||
const oidcCard = page.locator('a, .card, .cardHeader').filter({ hasText: 'OpenID Connect' }).first();
|
||||
await oidcCard.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-details.png' });
|
||||
|
||||
// Click Install
|
||||
console.log('Clicking Install button...');
|
||||
const installBtn = page.locator('button, a, .emby-button').filter({ hasText: 'Install' }).first();
|
||||
await installBtn.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-install-clicked.png' });
|
||||
|
||||
// Handle OK button in modal dialog if any
|
||||
const okBtn = page.locator('button, .emby-button').filter({ hasText: 'OK' }).first();
|
||||
if (await okBtn.isVisible()) {
|
||||
console.log('Clicking OK on dialog...');
|
||||
await okBtn.click({ force: true });
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-install-confirmed.png' });
|
||||
}
|
||||
|
||||
console.log('OIDC Plugin installation completed.');
|
||||
await context.close();
|
||||
});
|
||||
72
4-Infrastructure/k3s-flake/tests/install-oidc-plugin.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Install OIDC Plugin in Jellyfin', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin Plugins...');
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login if needed
|
||||
const usernameInput = page.locator('input[type="text"], input#txtManualName');
|
||||
const passwordInput = page.locator('input[type="password"], input#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"], button.button-submit').first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
// Double check we are on plugins page
|
||||
if (!page.url().includes('#/dashboard/plugins')) {
|
||||
await page.goto('https://media.researchstack.info/web/#/dashboard/plugins');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
console.log('Clicking Available tab...');
|
||||
// Find the Available tab pill
|
||||
const availableTab = page.locator('a:has-text("Available"), button:has-text("Available"), div:has-text("Available"), span:has-text("Available")').first();
|
||||
await availableTab.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugins-available.png' });
|
||||
|
||||
// Type in the search box to find OpenID Connect
|
||||
console.log('Searching for OpenID Connect in catalog...');
|
||||
const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]').first();
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('OpenID Connect');
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'jellyfin-plugins-search-results.png' });
|
||||
}
|
||||
|
||||
// Click on the OpenID Connect card
|
||||
console.log('Clicking OpenID Connect card...');
|
||||
const oidcCard = page.locator('a:has-text("OpenID Connect"), .card:has-text("OpenID Connect"), .cardHeader:has-text("OpenID Connect")').first();
|
||||
await oidcCard.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-details.png' });
|
||||
|
||||
// Click Install
|
||||
console.log('Clicking Install button...');
|
||||
const installBtn = page.locator('button:has-text("Install"), button.button-submit').first();
|
||||
await installBtn.click({ force: true });
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-install-clicked.png' });
|
||||
|
||||
// Handle OK button in modal dialog if any
|
||||
const okBtn = page.locator('button:has-text("OK"), button:has-text("Ok"), button.button-submit').first();
|
||||
if (await okBtn.isVisible()) {
|
||||
console.log('Clicking OK on dialog...');
|
||||
await okBtn.click({ force: true });
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'jellyfin-plugin-install-confirmed.png' });
|
||||
}
|
||||
|
||||
console.log('OIDC Plugin installation completed.');
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-check-initial.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
31
4-Infrastructure/k3s-flake/tests/jellyfin-check.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check Jellyfin with auth-state', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-check-initial.png' });
|
||||
|
||||
// If on consent page
|
||||
if (page.url().includes('consent') || page.url().includes('authorize')) {
|
||||
console.log('On consent/authorize page. Clicking Continue/Submit...');
|
||||
const btn = page.locator('button:has-text("Continue"), button:has-text("Authorize"), input[type="submit"]').first();
|
||||
if (await btn.isVisible()) {
|
||||
await btn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
console.log('Clicked. New URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-check-after-consent.png' });
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Jellyfin body text:', textContent.slice(0, 1000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-direct.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 47 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-dashboard.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
38
4-Infrastructure/k3s-flake/tests/jellyfin-direct.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { test } from '@playwright/test';
|
||||
test('Jellyfin wizard + OIDC via direct port', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
// Bypass Caddy forward_auth by going to Jellyfin directly
|
||||
console.log('=== Connecting to Jellyfin directly ===');
|
||||
await page.goto('http://100.88.57.96:30091/web/#/wizard/start.html', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('URL:', page.url());
|
||||
console.log('Title:', await page.title());
|
||||
|
||||
// Check if we're on the wizard
|
||||
const pageContent = await page.textContent('body');
|
||||
if (pageContent?.includes('wizard') || pageContent?.includes('Welcome')) {
|
||||
console.log('Wizard page detected');
|
||||
}
|
||||
|
||||
// Dump all visible elements
|
||||
const buttons = await page.locator('button, .button-submit, [class*="button"]').all();
|
||||
console.log(`Found ${buttons.length} clickable elements`);
|
||||
for (const btn of buttons) {
|
||||
if (await btn.isVisible()) {
|
||||
console.log(` Visible: "${await btn.textContent()}" tag=${await btn.evaluate(el => el.tagName)} class=${await btn.evaluate(el => el.className)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: '/tmp/jf-wizard-start.png', fullPage: true });
|
||||
|
||||
// Try clicking next/submit
|
||||
const nextBtn = page.locator('button:has-text("Next"), .button-submit:visible, button[type="submit"]').first();
|
||||
if (await nextBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: '/tmp/jf-wizard-step2.png', fullPage: true });
|
||||
console.log('Clicked Next. URL:', page.url());
|
||||
}
|
||||
});
|
||||
120
4-Infrastructure/k3s-flake/tests/jellyfin-full-setup.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { test } from '@playwright/test';
|
||||
test('Jellyfin full setup via direct port', async ({ page }) => {
|
||||
test.setTimeout(300000);
|
||||
const BASE = 'http://100.88.57.96:30091';
|
||||
|
||||
// Step 1: Complete Jellyfin Setup Wizard
|
||||
console.log('=== Wizard Step 1: Language ===');
|
||||
await page.goto(`${BASE}/web/#/wizard/start.html`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(3000);
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('=== Wizard Step 2: Create Admin ===');
|
||||
await page.locator('#txtUsername').fill('admin');
|
||||
await page.locator('#txtManualPassword').fill('jellyfin-admin');
|
||||
await page.locator('#txtPasswordConfirm').fill('jellyfin-admin');
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('=== Wizard Step 3: Libraries ===');
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('=== Wizard Step 4: Metadata ===');
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('=== Wizard Step 5: Remote Access ===');
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('=== Wizard Step 6: Finish ===');
|
||||
await page.locator('button.button-submit:visible').first().click();
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('Wizard complete:', page.url());
|
||||
|
||||
// Step 2: Install OIDC Plugin
|
||||
console.log('=== Installing OIDC Plugin ===');
|
||||
await page.goto(`${BASE}/web/#/dashboard/plugins/catalog`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Search for OIDC
|
||||
const searchInput = page.locator('input[type="text"]').first();
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('OIDC');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Look for install buttons
|
||||
const installBtns = page.locator('button:has-text("Install"):visible');
|
||||
const count = await installBtns.count();
|
||||
console.log(`Found ${count} install buttons`);
|
||||
if (count > 0) {
|
||||
await installBtns.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Confirm restart dialog if any
|
||||
const confirmBtn = page.locator('button:has-text("Yes"):visible, button:has-text("Restart"):visible, button:has-text("OK"):visible').first();
|
||||
if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
console.log('Plugin install initiated');
|
||||
}
|
||||
|
||||
// Wait for Jellyfin to restart
|
||||
console.log('Waiting for restart...');
|
||||
await page.waitForTimeout(30000);
|
||||
|
||||
// Step 3: Navigate to OIDC plugin settings
|
||||
console.log('=== Configuring OIDC Plugin ===');
|
||||
await page.goto(`${BASE}/web/#/dashboard/plugins`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Find the OIDC plugin in the list
|
||||
const oidcLink = page.locator('a:has-text("OIDC"):visible, div.card:has-text("OIDC"):visible').first();
|
||||
if (await oidcLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
if ((await oidcLink.evaluate(el => el.tagName)) === 'A') {
|
||||
await oidcLink.click();
|
||||
} else {
|
||||
await oidcLink.locator('a').first().click();
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Fill OIDC config
|
||||
const issuerInput = page.locator('input:visible').first();
|
||||
if (await issuerInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await issuerInput.fill('https://auth.researchstack.info');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Fill client ID
|
||||
const clientIdInput = page.locator('input:visible').nth(1);
|
||||
if (await clientIdInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await clientIdInput.fill('sso-jellyfin');
|
||||
}
|
||||
|
||||
// Fill client secret
|
||||
const secretInput = page.locator('input:visible').nth(2);
|
||||
if (await secretInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await secretInput.fill('fc96e87934f544bef43e5466c929baa73b681c4e5d0aa9fc5cafe6e6a36e2e49');
|
||||
}
|
||||
|
||||
// Fill callback URL
|
||||
const callbackInput = page.locator('input:visible').nth(3);
|
||||
if (await callbackInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await callbackInput.fill('https://media.researchstack.info/sso/OID/redirect');
|
||||
}
|
||||
|
||||
// Hit Save
|
||||
const saveBtn = page.locator('button:has-text("Save"):visible').first();
|
||||
if (await saveBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('OIDC config saved');
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: '/tmp/jf-oidc-done.png', fullPage: true });
|
||||
console.log('=== Jellyfin setup complete ===');
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-home.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-logged-in-state.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -0,0 +1,45 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Debug Jellyfin login', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
console.log('Initial URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-login-initial.png' });
|
||||
|
||||
const usernameInput = page.locator('input[type="text"], input#txtManualName');
|
||||
const passwordInput = page.locator('input[type="password"], input#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Filling login form...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.screenshot({ path: 'jellyfin-login-filled.png' });
|
||||
|
||||
console.log('Clicking login button...');
|
||||
const signInBtn = page.locator('button[type="submit"], button:has-text("Sign In")').first();
|
||||
await signInBtn.click();
|
||||
|
||||
// Wait for URL change or error
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
console.log(`Step ${i}: URL is ${page.url()}`);
|
||||
await page.screenshot({ path: `jellyfin-login-step-${i}.png` });
|
||||
|
||||
const bodyText = await page.evaluate(() => document.body.innerText);
|
||||
if (bodyText.includes('Invalid') || bodyText.includes('incorrect') || bodyText.includes('failed')) {
|
||||
console.log('Login failed message detected!');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No login inputs found!');
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-login-filled.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-login-initial.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-login-step-0.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-plugins-loaded.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-plugins.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-status-loaded.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-status.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
|
@ -0,0 +1,19 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check Jellyfin wizard', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Jellyfin wizard...');
|
||||
await page.goto('https://media.researchstack.info/web/#/wizardstart.html');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
console.log('URL:', page.url());
|
||||
await page.screenshot({ path: 'jellyfin-wizard.png' });
|
||||
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Jellyfin text:', textContent.slice(0, 1000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step1.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3-debug.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step4.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5-debug.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-start.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step1.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step3.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step4.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step5.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-finished.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-home.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-start.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step1.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step3.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step4.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step5.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step6.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
4-Infrastructure/k3s-flake/tests/jellyfin-wizard.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
49
4-Infrastructure/k3s-flake/tests/jellyfin.spec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Configure Jellyfin OIDC', async ({ page }) => {
|
||||
console.log('Navigating to Jellyfin...');
|
||||
await page.goto('https://media.researchstack.info/');
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
// If redirected to Authentik login page
|
||||
if (page.url().includes('/flow/default-authentication-flow/')) {
|
||||
console.log('Redirected to Authentik login page. Logging in...');
|
||||
await page.locator('input[name="uid"], input[id*="uid"], input[type="text"]').first().fill('akadmin');
|
||||
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.locator('input[name="password"], input[id*="password"], input[type="password"]').first().fill('authentik');
|
||||
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click();
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
// If redirected to consent page
|
||||
if (page.url().includes('default-provider-authorization-explicit-consent')) {
|
||||
console.log('On consent page. Clicking Continue...');
|
||||
const continueBtn = page.locator('button:has-text("Continue"), input[type="submit"]');
|
||||
await continueBtn.first().click();
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
|
||||
console.log('Current URL after auth flow:', page.url());
|
||||
|
||||
// We should now be on the Jellyfin home or login page
|
||||
const usernameInput = page.locator('#txtManualName');
|
||||
const passwordInput = page.locator('#txtManualPassword');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging in to Jellyfin...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
const signInBtn = page.locator('button[type="submit"], button:has-text("Sign In")').first();
|
||||
await signInBtn.click();
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'jellyfin-dashboard.png' });
|
||||
console.log('Final Jellyfin URL:', page.url());
|
||||
console.log('Final Jellyfin Title:', await page.title());
|
||||
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Jellyfin text:', textContent.slice(0, 1000));
|
||||
});
|
||||
92
4-Infrastructure/k3s-flake/tests/trailbase.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Configure TrailBase SSO', async ({ page }) => {
|
||||
console.log('Navigating to TrailBase admin portal...');
|
||||
await page.goto('https://trailbase.researchstack.info/_/admin/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill credentials
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
|
||||
if (await emailInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await emailInput.fill('admin@localhost');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
const loginBtn = page.locator('button[type="submit"]');
|
||||
await loginBtn.first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Click settings link
|
||||
console.log('Clicking settings link...');
|
||||
await page.locator('a[href="/_/admin/settings/"]').click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click Auth submenu
|
||||
console.log('Clicking Auth settings...');
|
||||
await page.locator('text="Auth"').first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Find OpenID Connect toggle circle
|
||||
console.log('Locating OpenID Connect trigger...');
|
||||
const oidcItem = page.locator('div.border-b:has(button:has-text("OpenID Connect"))');
|
||||
const oidcTrigger = oidcItem.locator('button').first();
|
||||
const toggleCircle = oidcTrigger.locator('svg').first();
|
||||
|
||||
// Click toggle circle (this both enables it and expands the accordion)
|
||||
console.log('Clicking OIDC toggle circle to enable and expand...');
|
||||
await toggleCircle.click();
|
||||
await page.waitForTimeout(3000); // Wait for expansion animation to complete
|
||||
|
||||
// Helper function to fill fields dynamically by label text
|
||||
const fillFieldByLabel = async (labelText: string, value: string) => {
|
||||
const inputId = await page.evaluate((text) => {
|
||||
const labels = Array.from(document.querySelectorAll('label'));
|
||||
const label = labels.find(l => l.textContent?.trim() === text);
|
||||
if (!label) return null;
|
||||
|
||||
// Traverse up to find outer label with 'for' attribute
|
||||
let outerLabel: HTMLElement | null = label;
|
||||
while (outerLabel && outerLabel.tagName !== 'LABEL') {
|
||||
outerLabel = outerLabel.parentElement;
|
||||
}
|
||||
if (outerLabel) {
|
||||
const forAttr = outerLabel.getAttribute('for');
|
||||
if (forAttr) return forAttr;
|
||||
}
|
||||
|
||||
// Fallback: look at parent grid and find input
|
||||
const parent = label.closest('.grid');
|
||||
if (parent) {
|
||||
const input = parent.querySelector('input');
|
||||
if (input) return input.id;
|
||||
}
|
||||
return null;
|
||||
}, labelText);
|
||||
|
||||
if (inputId) {
|
||||
console.log(`Filling field "${labelText}" (ID: ${inputId})...`);
|
||||
await page.locator(`#${inputId}`).fill(value);
|
||||
} else {
|
||||
throw new Error(`Could not find input ID for label "${labelText}"`);
|
||||
}
|
||||
};
|
||||
|
||||
// Fill OIDC fields
|
||||
console.log('Filling OIDC fields...');
|
||||
await fillFieldByLabel('Client Id', 'sso-trailbase');
|
||||
await fillFieldByLabel('Client Secret', '64810e7e2960494a4ba5340bb0c1b19af3e61e17c6f1c6f6eb7ed9aa72cabb1a');
|
||||
await fillFieldByLabel('Auth URL', 'https://auth.researchstack.info/application/o/authorize/');
|
||||
await fillFieldByLabel('Token URL', 'https://auth.researchstack.info/application/o/token/');
|
||||
await fillFieldByLabel('User API URL', 'https://auth.researchstack.info/application/o/userinfo/');
|
||||
|
||||
// Click submit button at the bottom of settings page
|
||||
console.log('Clicking Save/Submit...');
|
||||
const submitBtn = page.locator('button[type="submit"], button:has-text("Submit"), button:has-text("Save")').first();
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
await page.screenshot({ path: 'trailbase-oidc-saved.png' });
|
||||
console.log('Finished TrailBase OIDC configuration.');
|
||||
});
|
||||
186
4-Infrastructure/k3s-flake/tests/update-outpost.spec.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Configure Authentik Outpost Applications', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Authentik admin home...');
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/outpost/outposts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first();
|
||||
if (await loginInput.isVisible()) {
|
||||
console.log('Logging in...');
|
||||
await loginInput.fill('akadmin');
|
||||
const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first();
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const passwordInput = page.locator('input[name="password"], input[type="password"]').first();
|
||||
await passwordInput.fill('authentik');
|
||||
await continueBtn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
// Go to outposts
|
||||
if (!page.url().includes('#/outpost/outposts')) {
|
||||
await page.goto('https://auth.researchstack.info/if/admin/#/outpost/outposts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
console.log('On outposts page. Capturing screenshot...');
|
||||
await page.screenshot({ path: 'authentik-outposts-page.png' });
|
||||
|
||||
// Piercing shadow DOM to find the Edit button
|
||||
// Let's find "authentik Embedded Outpost" and click the Edit button in its row
|
||||
console.log('Looking for Edit button...');
|
||||
|
||||
// Let's run a script in browser context to click the Edit button inside the shadow DOM
|
||||
const clicked = await page.evaluate(() => {
|
||||
function findAndClickEdit(): boolean {
|
||||
// Find the row containing "authentik Embedded Outpost"
|
||||
// In Authentik UI, the outposts list is typically rendered by a web component ak-table or similar
|
||||
const rows = document.querySelectorAll('*');
|
||||
for (const el of Array.from(rows)) {
|
||||
// If it is an element with text or label
|
||||
if (el.shadowRoot) {
|
||||
const innerRows = el.shadowRoot.querySelectorAll('tr, div.tr, ak-table-row');
|
||||
for (const row of Array.from(innerRows)) {
|
||||
const rowText = (row as HTMLElement).innerText || '';
|
||||
if (rowText.includes('authentik Embedded Outpost') || rowText.includes('Embedded')) {
|
||||
// Find the edit button in this row or shadow root of the row
|
||||
const buttons = row.querySelectorAll('button, a, [role="button"]');
|
||||
for (const btn of Array.from(buttons)) {
|
||||
if (btn.textContent?.includes('Edit') || btn.getAttribute('title')?.includes('Edit') || btn.innerHTML.includes('edit')) {
|
||||
(btn as HTMLElement).click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check shadow root of row elements
|
||||
const cellElements = row.querySelectorAll('*');
|
||||
for (const cell of Array.from(cellElements)) {
|
||||
if (cell.shadowRoot) {
|
||||
const shadowBtns = cell.shadowRoot.querySelectorAll('button, a');
|
||||
for (const sBtn of Array.from(shadowBtns)) {
|
||||
if (sBtn.textContent?.includes('Edit') || sBtn.getAttribute('title')?.includes('Edit')) {
|
||||
(sBtn as HTMLElement).click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: search globally for any edit button or action button next to "Embedded Outpost"
|
||||
const anyEdit = document.querySelector('button[title*="Edit"], ak-table-row button');
|
||||
if (anyEdit) {
|
||||
(anyEdit as HTMLElement).click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return findAndClickEdit();
|
||||
});
|
||||
|
||||
console.log('Clicked edit button:', clicked);
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: 'authentik-outpost-edit-clicked.png' });
|
||||
|
||||
// In the edit modal, check the available applications
|
||||
// Let's see if we can select all options in the multi-select
|
||||
console.log('Checking modal multi-select...');
|
||||
const selectedAll = await page.evaluate(() => {
|
||||
// Look for multi-select element or list of applications
|
||||
// Usually it has a select element with multiple attribute, or a search input
|
||||
// Let's search inside the open ak-modal-dialog
|
||||
const modal = document.querySelector('ak-modal-dialog, div[role="dialog"]');
|
||||
if (!modal) return 'No modal found';
|
||||
|
||||
// Find all select elements inside the modal shadow root
|
||||
function findSelects(root: Element | ShadowRoot): HTMLSelectElement[] {
|
||||
const results: HTMLSelectElement[] = [];
|
||||
const selects = root.querySelectorAll('select');
|
||||
selects.forEach(s => results.push(s));
|
||||
const all = root.querySelectorAll('*');
|
||||
all.forEach(el => {
|
||||
if (el.shadowRoot) {
|
||||
results.push(...findSelects(el.shadowRoot));
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
const rootToSearch = modal.shadowRoot || modal;
|
||||
const selects = findSelects(rootToSearch);
|
||||
if (selects.length > 0) {
|
||||
const select = selects[0];
|
||||
// Select all options
|
||||
let selectedNames: string[] = [];
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
select.options[i].selected = true;
|
||||
selectedNames.push(select.options[i].text || select.options[i].value);
|
||||
}
|
||||
// Trigger change event
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
select.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Find the update button to submit the form
|
||||
let submitBtn: HTMLElement | null = null;
|
||||
const modalBtns = rootToSearch.querySelectorAll('button, input[type="submit"]');
|
||||
for (const btn of Array.from(modalBtns)) {
|
||||
if (btn.textContent?.includes('Update') || btn.textContent?.includes('Save') || btn.textContent?.includes('Submit')) {
|
||||
submitBtn = btn as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.click();
|
||||
return `Selected options: ${selectedNames.join(', ')}. Clicked submit.`;
|
||||
}
|
||||
return `Selected options: ${selectedNames.join(', ')}. Could not find submit button.`;
|
||||
}
|
||||
|
||||
// Fallback: dual list select
|
||||
// Let's check if there is an ak-duallist-select component
|
||||
const duallist = modal.querySelector('ak-duallist-select') || rootToSearch.querySelector('ak-duallist-select');
|
||||
if (duallist) {
|
||||
// dual-list selector has custom properties or shadow DOM
|
||||
// Let's look inside its shadow root
|
||||
const dlRoot = duallist.shadowRoot || duallist;
|
||||
// It usually has "Add all" button or similar, or checkboxes
|
||||
const addAllBtn = dlRoot.querySelector('button[title*="Add all"], button:has-text("Add all")') ||
|
||||
Array.from(dlRoot.querySelectorAll('button')).find(b => b.textContent?.includes('Add all') || b.innerHTML.includes('forward'));
|
||||
if (addAllBtn) {
|
||||
(addAllBtn as HTMLElement).click();
|
||||
|
||||
// Find submit button in main modal
|
||||
let submitBtn: HTMLElement | null = null;
|
||||
const modalBtns = rootToSearch.querySelectorAll('button, input[type="submit"]');
|
||||
for (const btn of Array.from(modalBtns)) {
|
||||
if (btn.textContent?.includes('Update') || btn.textContent?.includes('Save') || btn.textContent?.includes('Submit')) {
|
||||
submitBtn = btn as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.click();
|
||||
return 'Found duallist, clicked Add all, clicked submit.';
|
||||
}
|
||||
return 'Found duallist, clicked Add all, but no submit button.';
|
||||
}
|
||||
}
|
||||
|
||||
return 'No standard select or duallist found in modal';
|
||||
});
|
||||
|
||||
console.log('Selected all result:', selectedAll);
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: 'authentik-outpost-updated.png' });
|
||||
|
||||
await context.close();
|
||||
});
|
||||
BIN
4-Infrastructure/k3s-flake/tests/uptime-kuma-dashboard.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
4-Infrastructure/k3s-flake/tests/uptime-kuma-logged-in.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -0,0 +1,33 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check Uptime Kuma settings', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Uptime Kuma...');
|
||||
await page.goto('https://uptime.researchstack.info/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// If we see login inputs
|
||||
const usernameInput = page.locator('input[type="text"]');
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
|
||||
if (await usernameInput.isVisible()) {
|
||||
console.log('Logging in with admin...');
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
await page.screenshot({ path: 'uptime-kuma-logged-in.png' });
|
||||
|
||||
// Let's click the user icon/menu or navigate to /settings
|
||||
// Wait, let's look at the DOM to see how to go to settings.
|
||||
const textContent = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Uptime Kuma text:', textContent.slice(0, 1000));
|
||||
|
||||
await context.close();
|
||||
});
|
||||
46
4-Infrastructure/k3s-flake/tests/uptime-kuma.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Configure Uptime Kuma Admin', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: 'auth-state.json' });
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Navigating to Uptime Kuma...');
|
||||
await page.goto('https://uptime.researchstack.info/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
if (page.url().includes('default-provider-authorization-explicit-consent')) {
|
||||
await page.locator('button:has-text("Continue"), input[type="submit"]').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
if (page.url().includes('/setup')) {
|
||||
console.log('Filling setup wizard form...');
|
||||
|
||||
const usernameInput = page.locator('#floatingInput');
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await usernameInput.fill('admin');
|
||||
|
||||
const passwordInput = page.locator('#floatingPassword');
|
||||
await passwordInput.fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
const repeatInput = page.locator('#repeat');
|
||||
await repeatInput.fill('RY03KhsFez73K5va2uUb');
|
||||
|
||||
// Click Create
|
||||
console.log('Clicking Create...');
|
||||
const createBtn = page.locator('button[type="submit"]');
|
||||
await createBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
} else {
|
||||
console.log('Already past setup page.');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'uptime-kuma-dashboard.png' });
|
||||
console.log('Final page URL:', page.url());
|
||||
console.log('Final page title:', await page.title());
|
||||
|
||||
await context.close();
|
||||
});
|
||||
3
4-Infrastructure/kube/manifests/porkbun.env
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Porkbun DNS API credentials for researchstack.info
|
||||
PORKBUN_API_KEY=pk1_1443e3251e83e374ad15f74d2c4fc3ea4ec15aded6d6a8bb4e627e70ef60faae
|
||||
PORKBUN_SECRET_KEY=sk1_7088845faa346c309067912aee6ada32b98066953b11a957ed648679dffc08bd
|
||||
567
4-Infrastructure/shim/reservoir.py
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reservoir CLI — FTP-backed storage with local caching and receipt chain.
|
||||
Works without network by operating on local cache, syncs to FTP reservoir.
|
||||
"""
|
||||
|
||||
import argparse, base64, hashlib, json, os, shutil, sys, time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import pycurl
|
||||
HAS_CURL = True
|
||||
except ImportError:
|
||||
HAS_CURL = False
|
||||
|
||||
|
||||
# ─── Q16_16 helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
class Q16_16:
|
||||
@staticmethod
|
||||
def of_int(x: int) -> int:
|
||||
return x << 16
|
||||
|
||||
@staticmethod
|
||||
def to_int(x: int) -> int:
|
||||
return x >> 16
|
||||
|
||||
|
||||
# ─── Receipt ID ─────────────────────────────────────────────────────────────
|
||||
|
||||
def receipt_id() -> str:
|
||||
return hashlib.sha256(str(time.time_ns()).encode()).hexdigest()[:32]
|
||||
|
||||
def now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
def file_hash(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return "sha256:" + h.hexdigest()
|
||||
|
||||
|
||||
# ─── FTP Operations (curl-based, no extra deps) ─────────────────────────────
|
||||
|
||||
class FTPClient:
|
||||
def __init__(self, host: str, user: str, password: str, base_path: str = "/htdocs"):
|
||||
self.host = host
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.base_path = base_path.rstrip("/")
|
||||
self.curl = None
|
||||
if HAS_CURL:
|
||||
self.curl = pycurl.Curl()
|
||||
self.curl.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1)
|
||||
self.curl.setopt(pycurl.FTP_USE_EPSV, 0)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
p = (self.base_path + "/" + path.lstrip("/")).replace("//", "/")
|
||||
return f"ftp://{self.host}{p}"
|
||||
|
||||
def list(self, path: str = "") -> list[str]:
|
||||
cmd = f"LIST{'-la' if True else ''}"
|
||||
out = []
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.URL, self._url(path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 0)
|
||||
c.setopt(pycurl.NOBODY, 0)
|
||||
c.setopt(pycurl.VERBOSE, 0)
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
c.setopt(pycurl.WRITEFUNCTION, buf.write)
|
||||
try:
|
||||
c.perform()
|
||||
raw = buf.getvalue().decode()
|
||||
for line in raw.split("\n"):
|
||||
if line.strip():
|
||||
out.append(line)
|
||||
except Exception as e:
|
||||
pass
|
||||
c.close()
|
||||
return out
|
||||
|
||||
def mkdir(self, path: str) -> bool:
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.URL, self._url(path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1)
|
||||
c.setopt(pycurl.CUSTOMREQUEST, "MKD " + path)
|
||||
try:
|
||||
c.perform()
|
||||
c.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upload_file(self, local_path: Path, remote_path: str) -> bool:
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.UPLOAD, 1)
|
||||
c.setopt(pycurl.URL, self._url(remote_path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1)
|
||||
f = open(local_path, "rb")
|
||||
c.setopt(pycurl.READDATA, f)
|
||||
try:
|
||||
c.perform()
|
||||
f.close()
|
||||
c.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
f.close()
|
||||
c.close()
|
||||
return False
|
||||
|
||||
def download_file(self, remote_path: str, local_path: Path) -> bool:
|
||||
import io
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.URL, self._url(remote_path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
buf = io.BytesIO()
|
||||
c.setopt(pycurl.WRITEFUNCTION, buf.write)
|
||||
try:
|
||||
c.perform()
|
||||
local_path.write_bytes(buf.getvalue())
|
||||
c.close()
|
||||
return True
|
||||
except Exception:
|
||||
c.close()
|
||||
return False
|
||||
|
||||
def exists(self, path: str) -> bool:
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.NOBODY, 1)
|
||||
c.setopt(pycurl.URL, self._url(path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
try:
|
||||
c.perform()
|
||||
code = c.getinfo(pycurl.HTTP_CODE)
|
||||
c.close()
|
||||
return code in (200, 226, 250)
|
||||
except Exception:
|
||||
c.close()
|
||||
return False
|
||||
|
||||
def delete(self, path: str) -> bool:
|
||||
c = pycurl.Curl()
|
||||
c.setopt(pycurl.URL, self._url(path))
|
||||
c.setopt(pycurl.USERNAME, self.user)
|
||||
c.setopt(pycurl.PASSWORD, self.password)
|
||||
c.setopt(pycurl.CUSTOMREQUEST, "DELE " + path)
|
||||
try:
|
||||
c.perform()
|
||||
c.close()
|
||||
return True
|
||||
except Exception:
|
||||
c.close()
|
||||
return False
|
||||
|
||||
|
||||
# ─── Local Reservoir ──────────────────────────────────────────────────────────
|
||||
|
||||
class LocalReservoir:
|
||||
"""Filesystem-backed reservoir with manifest + receipts."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = Path(root).resolve()
|
||||
self.storage = self.root / "storage"
|
||||
self.uploads = self.storage / "uploads"
|
||||
self.receipts_dir = self.storage / "receipts"
|
||||
self.manifest_file = self.storage / "manifest.json"
|
||||
for d in [self.storage, self.uploads, self.receipts_dir]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _manifest(self) -> dict:
|
||||
if self.manifest_file.exists():
|
||||
return json.loads(self.manifest_file.read_text())
|
||||
return {}
|
||||
|
||||
def _save_manifest(self, m: dict):
|
||||
self.manifest_file.write_text(json.dumps(m, indent=2))
|
||||
|
||||
def health(self) -> dict:
|
||||
m = self._manifest()
|
||||
return {
|
||||
"status": "reservoir-up",
|
||||
"version": "1.0.0",
|
||||
"mode": "local",
|
||||
"timestamp": now_iso(),
|
||||
"root": str(self.root),
|
||||
"package_count": len(m),
|
||||
}
|
||||
|
||||
def list_packages(self) -> dict:
|
||||
m = self._manifest()
|
||||
return {"packages": [{"pkg": k, **v} for k, v in m.items()], "total": len(m)}
|
||||
|
||||
def get_package(self, pkg: str) -> dict:
|
||||
m = self._manifest()
|
||||
if pkg not in m:
|
||||
raise FileNotFoundError(f"Package '{pkg}' not found")
|
||||
return {"pkg": pkg, **m[pkg]}
|
||||
|
||||
def register_bytes(self, pkg: str, data: bytes, promotion_state: str = "held",
|
||||
source: str = "local") -> dict:
|
||||
content_hash = "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
size = len(data)
|
||||
ch_short = content_hash[7:]
|
||||
|
||||
# Write content file
|
||||
(self.uploads / (ch_short + ".bin")).write_bytes(data)
|
||||
|
||||
# Update manifest
|
||||
m = self._manifest()
|
||||
now = now_iso()
|
||||
if pkg in m:
|
||||
old = m[pkg]["created_at"]
|
||||
m[pkg] = {
|
||||
"pkg": pkg,
|
||||
"content_hash": content_hash,
|
||||
"size_bytes": size,
|
||||
"promotion_state": promotion_state,
|
||||
"source": source,
|
||||
"created_at": old,
|
||||
"updated_at": now,
|
||||
}
|
||||
else:
|
||||
m[pkg] = {
|
||||
"pkg": pkg,
|
||||
"content_hash": content_hash,
|
||||
"size_bytes": size,
|
||||
"promotion_state": promotion_state,
|
||||
"source": source,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._save_manifest(m)
|
||||
|
||||
# Write receipt
|
||||
rid = receipt_id()
|
||||
receipt = {
|
||||
"id": rid,
|
||||
"pkg": pkg,
|
||||
"receipt_type": "package_register",
|
||||
"content_hash": content_hash,
|
||||
"size_bytes": size,
|
||||
"promotion_state": promotion_state,
|
||||
"source": source,
|
||||
"created_at": now,
|
||||
}
|
||||
(self.receipts_dir / f"{rid}.json").write_text(json.dumps(receipt, indent=2))
|
||||
return {"status": "registered", "pkg": pkg, "content_hash": content_hash, "receipt_id": rid}
|
||||
|
||||
def register_file(self, pkg: str, path: Path, promotion_state: str = "held") -> dict:
|
||||
return self.register_bytes(pkg, path.read_bytes(), promotion_state, source="local-file")
|
||||
|
||||
def update_promotion(self, pkg: str, new_state: str) -> dict:
|
||||
m = self._manifest()
|
||||
if pkg not in m:
|
||||
raise FileNotFoundError(f"Package '{pkg}' not found")
|
||||
old_state = m[pkg].get("promotion_state", "held")
|
||||
now = now_iso()
|
||||
m[pkg]["promotion_state"] = new_state
|
||||
m[pkg]["updated_at"] = now
|
||||
self._save_manifest(m)
|
||||
|
||||
rid = receipt_id()
|
||||
receipt = {
|
||||
"id": rid,
|
||||
"pkg": pkg,
|
||||
"receipt_type": "promotion_change",
|
||||
"from_state": old_state,
|
||||
"to_state": new_state,
|
||||
"created_at": now,
|
||||
}
|
||||
(self.receipts_dir / f"{rid}.json").write_text(json.dumps(receipt, indent=2))
|
||||
return {"status": "promotion_updated", "pkg": pkg, "from": old_state, "to": new_state}
|
||||
|
||||
def list_receipts(self) -> list[dict]:
|
||||
receipts = []
|
||||
for f in sorted(self.receipts_dir.glob("*.json")):
|
||||
receipts.append(json.loads(f.read_text()))
|
||||
return receipts
|
||||
|
||||
def download(self, pkg: str) -> bytes:
|
||||
m = self._manifest()
|
||||
if pkg not in m:
|
||||
raise FileNotFoundError(f"Package '{pkg}' not found")
|
||||
ch = m[pkg]["content_hash"]
|
||||
return (self.uploads / (ch[7:] + ".bin")).read_bytes()
|
||||
|
||||
def delete(self, pkg: str) -> dict:
|
||||
m = self._manifest()
|
||||
if pkg not in m:
|
||||
raise FileNotFoundError(f"Package '{pkg}' not found")
|
||||
ch = m[pkg]["content_hash"]
|
||||
uf = self.uploads / (ch[7:] + ".bin")
|
||||
if uf.exists():
|
||||
uf.unlink()
|
||||
del m[pkg]
|
||||
self._save_manifest(m)
|
||||
return {"status": "deleted", "pkg": pkg}
|
||||
|
||||
|
||||
# ─── FTP Sync ───────────────────────────────────────────────────────────────
|
||||
|
||||
class FTPSync:
|
||||
"""Push/pull local reservoir ↔ FTP endpoint."""
|
||||
|
||||
def __init__(self, ftp: FTPClient, local: LocalReservoir):
|
||||
self.ftp = ftp
|
||||
self.local = local
|
||||
|
||||
def push_all(self) -> dict:
|
||||
"""Push all local packages to FTP reservoir."""
|
||||
manifest = self.local._manifest()
|
||||
pushed = []
|
||||
errors = []
|
||||
|
||||
# Ensure remote directories exist
|
||||
self.ftp.mkdir("storage/uploads")
|
||||
self.ftp.mkdir("storage/receipts")
|
||||
|
||||
for pkg, info in manifest.items():
|
||||
ch = info["content_hash"]
|
||||
local_file = self.local.uploads / (ch[7:] + ".bin")
|
||||
if not local_file.exists():
|
||||
errors.append({"pkg": pkg, "error": "content file missing"})
|
||||
continue
|
||||
|
||||
# Upload content
|
||||
ok = self.ftp.upload_file(local_file, f"storage/uploads/{ch[7:]}.bin")
|
||||
if not ok:
|
||||
errors.append({"pkg": pkg, "error": "upload failed"})
|
||||
continue
|
||||
|
||||
# Upload receipt if exists
|
||||
receipts = self.local.list_receipts()
|
||||
pkg_receipts = [r for r in receipts if r["pkg"] == pkg]
|
||||
for r in pkg_receipts:
|
||||
rid = r["id"]
|
||||
local_receipt = self.local.receipts_dir / f"{rid}.json"
|
||||
if local_receipt.exists():
|
||||
self.ftp.upload_file(local_receipt, f"storage/receipts/{rid}.json")
|
||||
|
||||
pushed.append(pkg)
|
||||
|
||||
# Upload manifest
|
||||
manifest_bytes = json.dumps(manifest, indent=2).encode()
|
||||
self.ftp.upload_file(
|
||||
self.local.manifest_file,
|
||||
"storage/manifest.json"
|
||||
)
|
||||
|
||||
return {"pushed": pushed, "total": len(pushed), "errors": errors}
|
||||
|
||||
def pull_all(self) -> dict:
|
||||
"""Pull all packages from FTP reservoir to local."""
|
||||
if not self.ftp.exists("storage/manifest.json"):
|
||||
return {"pulled": [], "total": 0, "errors": ["no remote manifest"]}
|
||||
|
||||
# Download manifest
|
||||
tmp_manifest = self.local.storage / "manifest.remote.json"
|
||||
if not self.ftp.download_file("storage/manifest.json", tmp_manifest):
|
||||
return {"pulled": [], "total": 0, "errors": ["failed to download manifest"]}
|
||||
|
||||
remote_m = json.loads(tmp_manifest.read_text())
|
||||
local_m = self.local._manifest()
|
||||
local_pkgs = set(local_m.keys())
|
||||
remote_pkgs = set(remote_m.keys())
|
||||
|
||||
pulled = []
|
||||
errors = []
|
||||
|
||||
for pkg in remote_pkgs:
|
||||
if pkg in local_pkgs:
|
||||
# Skip existing unless content differs
|
||||
local_hash = local_m[pkg].get("content_hash")
|
||||
remote_hash = remote_m[pkg].get("content_hash")
|
||||
if local_hash == remote_hash:
|
||||
continue
|
||||
|
||||
ch = remote_m[pkg]["content_hash"]
|
||||
remote_content = f"storage/uploads/{ch[7:]}.bin"
|
||||
local_content = self.local.uploads / (ch[7:] + ".bin")
|
||||
|
||||
if self.ftp.download_file(remote_content, local_content):
|
||||
# Update local manifest
|
||||
local_m[pkg] = remote_m[pkg]
|
||||
local_m[pkg]["created_at"] = remote_m[pkg].get("created_at", now_iso())
|
||||
local_m[pkg]["updated_at"] = now_iso()
|
||||
pulled.append(pkg)
|
||||
else:
|
||||
errors.append({"pkg": pkg, "error": "content download failed"})
|
||||
|
||||
self.local._save_manifest(local_m)
|
||||
|
||||
# Sync receipts
|
||||
if self.ftp.exists("storage/receipts"):
|
||||
for pkg in pulled:
|
||||
receipts = self.local.list_receipts()
|
||||
for r in receipts:
|
||||
rid = r["id"]
|
||||
local_receipt = self.local.receipts_dir / f"{rid}.json"
|
||||
if not local_receipt.exists():
|
||||
self.ftp.download_file(f"storage/receipts/{rid}.json", local_receipt)
|
||||
|
||||
tmp_manifest.unlink()
|
||||
return {"pulled": pulled, "total": len(pulled), "errors": errors}
|
||||
|
||||
def push_receipt(self, receipt: dict) -> bool:
|
||||
rid = receipt["id"]
|
||||
path = self.local.receipts_dir / f"{rid}.json"
|
||||
if path.exists():
|
||||
return self.ftp.upload_file(path, f"storage/receipts/{rid}.json")
|
||||
return False
|
||||
|
||||
|
||||
# ─── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_health(rv, args):
|
||||
print(json.dumps(rv.health(), indent=2))
|
||||
|
||||
def cmd_list(rv, args):
|
||||
result = rv.list_packages()
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
def cmd_register(rv, args):
|
||||
path = Path(args.file)
|
||||
if not path.exists():
|
||||
print(f"ERROR: file not found: {path}", file=sys.stderr)
|
||||
return 1
|
||||
result = rv.register_file(args.pkg or path.stem, path, args.state)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
def cmd_promote(rv, args):
|
||||
print(json.dumps(rv.update_promotion(args.pkg, args.state), indent=2))
|
||||
|
||||
def cmd_receipts(rv, args):
|
||||
print(json.dumps(rv.list_receipts(), indent=2))
|
||||
|
||||
def cmd_download(rv, args):
|
||||
data = rv.download(args.pkg)
|
||||
out = Path(args.output) if args.output else Path(args.pkg + ".bin")
|
||||
out.write_bytes(data)
|
||||
print(f"Downloaded → {out} ({len(data)} bytes)")
|
||||
|
||||
def cmd_delete(rv, args):
|
||||
print(json.dumps(rv.delete(args.pkg), indent=2))
|
||||
|
||||
def cmd_push(rv, args):
|
||||
ftp = FTPClient(args.ftp_host, args.ftp_user, args.ftp_pass)
|
||||
sync = FTPSync(ftp, rv)
|
||||
result = sync.push_all()
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
def cmd_pull(rv, args):
|
||||
ftp = FTPClient(args.ftp_host, args.ftp_user, args.ftp_pass)
|
||||
sync = FTPSync(ftp, rv)
|
||||
result = sync.pull_all()
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
def cmd_seed(rv, args):
|
||||
"""Seed the reservoir with initial packages from a directory."""
|
||||
src = Path(args.src)
|
||||
if not src.exists():
|
||||
print(f"ERROR: source directory not found: {src}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seeded = []
|
||||
for f in src.rglob("*"):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
pkg_name = str(f.relative_to(src)).replace("/", "__")
|
||||
try:
|
||||
result = rv.register_file(pkg_name, f, "held")
|
||||
seeded.append(pkg_name)
|
||||
except Exception as e:
|
||||
print(f" SKIP {pkg_name}: {e}", file=sys.stderr)
|
||||
|
||||
print(f"Seeded {len(seeded)} packages from {src}")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Reservoir CLI — FTP-backed research storage")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--root", default=os.environ.get("RESERVOIR_ROOT", "./reservoir-data"),
|
||||
help="Local reservoir root")
|
||||
|
||||
# health
|
||||
sub.add_parser("health", parents=[common], help="Health check")
|
||||
|
||||
# list
|
||||
sub.add_parser("list", parents=[common], help="List packages")
|
||||
|
||||
# register
|
||||
reg = sub.add_parser("register", parents=[common], help="Register a package")
|
||||
reg.add_argument("file", help="File to register")
|
||||
reg.add_argument("--pkg", help="Package name (default: filename)")
|
||||
reg.add_argument("--state", default="held", help="Promotion state")
|
||||
|
||||
# promote
|
||||
pro = sub.add_parser("promote", parents=[common], help="Update promotion state")
|
||||
pro.add_argument("pkg", help="Package name")
|
||||
pro.add_argument("state", help="New state (held/semi/promoted)")
|
||||
|
||||
# receipts
|
||||
sub.add_parser("receipts", parents=[common], help="List receipts")
|
||||
|
||||
# download
|
||||
dl = sub.add_parser("download", parents=[common], help="Download package")
|
||||
dl.add_argument("pkg", help="Package name")
|
||||
dl.add_argument("--output", "-o", help="Output file")
|
||||
|
||||
# delete
|
||||
del_p = sub.add_parser("delete", parents=[common], help="Delete package")
|
||||
del_p.add_argument("pkg", help="Package name")
|
||||
|
||||
# push
|
||||
push = sub.add_parser("push", parents=[common], help="Push to FTP reservoir")
|
||||
push.add_argument("--ftp-host", default=os.environ.get("FTP_HOST", "ftpupload.net"),
|
||||
help="FTP host")
|
||||
push.add_argument("--ftp-user", default=os.environ.get("FTP_USER", "if0_42058601"),
|
||||
help="FTP user")
|
||||
push.add_argument("--ftp-pass", default=os.environ.get("FTP_PASS", ""),
|
||||
help="FTP password")
|
||||
|
||||
# pull
|
||||
pull = sub.add_parser("pull", parents=[common], help="Pull from FTP reservoir")
|
||||
pull.add_argument("--ftp-host", default=os.environ.get("FTP_HOST", "ftpupload.net"))
|
||||
pull.add_argument("--ftp-user", default=os.environ.get("FTP_USER", "if0_42058601"))
|
||||
pull.add_argument("--ftp-pass", default=os.environ.get("FTP_PASS", ""))
|
||||
|
||||
# seed
|
||||
seed = sub.add_parser("seed", parents=[common], help="Seed from directory")
|
||||
seed.add_argument("src", help="Source directory")
|
||||
|
||||
args = p.parse_args()
|
||||
rv = LocalReservoir(Path(args.root))
|
||||
|
||||
cmds = {
|
||||
"health": cmd_health,
|
||||
"list": cmd_list,
|
||||
"register": cmd_register,
|
||||
"promote": cmd_promote,
|
||||
"receipts": cmd_receipts,
|
||||
"download": cmd_download,
|
||||
"delete": cmd_delete,
|
||||
"push": cmd_push,
|
||||
"pull": cmd_pull,
|
||||
"seed": cmd_seed,
|
||||
}
|
||||
cmds[args.cmd](rv, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schema": "unified_rg_test_suite_v2",
|
||||
"generated_at": "2026-05-30T13:57:02Z",
|
||||
"generated_at": "2026-05-30T18:10:40Z",
|
||||
"rg_fixed_point": 1.2618595071429148,
|
||||
"tests": [
|
||||
{
|
||||
|
|
@ -23,8 +23,8 @@
|
|||
"D_rg": 1.2618595071429148,
|
||||
"D_infinite": 1.1965000000000001,
|
||||
"D_inf_CI": [
|
||||
1.1484298439127854,
|
||||
1.2448633199949115
|
||||
1.1481297537597908,
|
||||
1.243916266133916
|
||||
],
|
||||
"spectral_measured": 1.9,
|
||||
"note": "Non-monotonic data; spectral exponent favors standard"
|
||||
|
|
@ -64,6 +64,40 @@
|
|||
],
|
||||
"note": "Curated data; selection bias possible; full range wider"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BraidSpherionBridge",
|
||||
"result": {
|
||||
"lean_file": "BraidSpherionBridge.lean",
|
||||
"lake_build": "3560 jobs, 0 errors",
|
||||
"theorems_proven": 7,
|
||||
"admits_discharged": true,
|
||||
"proof_type": "formal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Hexagonal lattice RG",
|
||||
"result": {
|
||||
"source": "Gao, Zhang, Chen (2026) \u2014 arXiv:2605.09974",
|
||||
"model": "Hofstadter model, hexagonal lattice, NN hopping, irrational flux",
|
||||
"transfer_matrix_size": "2x2",
|
||||
"phase_diagram": {
|
||||
"localized": "t2 < min(t1, 1)",
|
||||
"critical": "t2 = min(t1, 1)",
|
||||
"extended": "t2 > min(t1, 1)"
|
||||
},
|
||||
"critical_exponent_nu": 1.0,
|
||||
"fractal_dimensions": {
|
||||
"extended_FD_inf": 1.0,
|
||||
"critical_FD_inf": 0.5,
|
||||
"localized_FD_inf": 0.0
|
||||
},
|
||||
"rg_confirms_localized": true,
|
||||
"rg_confirms_extended_partial": true,
|
||||
"no_mobility_edge": true,
|
||||
"chiral_symmetry": true,
|
||||
"note": "RG theory confirmed; complementary to D=log_3(4) spectral dimension"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metrics": {
|
||||
|
|
@ -107,6 +141,7 @@
|
|||
"Sine-Gordon is prediction only with falsification criteria",
|
||||
"Boundary data is curated from 50 refs, selection bias possible",
|
||||
"Burgers data is non-monotonic, extrapolation unreliable",
|
||||
"Spectral exponent favors standard (1.9 closer to 2.0 than 1.262)"
|
||||
"Spectral exponent favors standard (1.9 closer to 2.0 than 1.262)",
|
||||
"Hexagonal lattice test confirms RG theory for quasiperiodic systems (arXiv:2605.09974)"
|
||||
]
|
||||
}
|
||||
4
test-results/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||