diff --git a/OpenJibo/README.md b/OpenJibo/README.md index bf55565..7f90b43 100644 --- a/OpenJibo/README.md +++ b/OpenJibo/README.md @@ -14,6 +14,16 @@ We are rebuilding the hosted cloud first, then using that foundation for OTA, Op Current release truth lives in [docs/development-plan.md](docs/development-plan.md). The current cloud release constant is `1.0.19`. +## Running Locally + +For local setup, use [docs/local-cloud-quickstart.md](docs/local-cloud-quickstart.md). + +It covers: + +- the current `.NET` OpenJibo cloud +- the legacy Node protocol oracle +- the Playground direct-to-Jibo ASR/TTS demo + ## Roadmap The long-range plan is summarized in [docs/roadmap.md](docs/roadmap.md). In short: @@ -94,6 +104,7 @@ OpenJibo/ Use these when you want the active technical truth: - [Development plan](docs/development-plan.md) +- [Local cloud quickstart](docs/local-cloud-quickstart.md) - [Feature backlog](docs/feature-backlog.md) - [Release 1.0.19 plan](docs/release-1.0.19-plan.md) - [Support tiers](docs/support-tiers.md) diff --git a/OpenJibo/docs/local-cloud-quickstart.md b/OpenJibo/docs/local-cloud-quickstart.md new file mode 100644 index 0000000..e80cab2 --- /dev/null +++ b/OpenJibo/docs/local-cloud-quickstart.md @@ -0,0 +1,153 @@ +# Local OpenJibo Cloud Quickstart + +## Purpose + +This guide is for people who want to run OpenJibo locally. + +There are three different local paths: + +- `.NET cloud`: the current OpenJibo cloud implementation and the path we are actively shipping. +- `Node cloud`: the legacy protocol oracle and reverse-engineering server. It is still useful and fun to run, but it is not the production direction. +- `Playground`: a direct local Jibo ASR/TTS demo. It talks to Jibo on local ports and does not replace the cloud. + +For a physical Jibo, local cloud testing still assumes a controlled network, DNS/host routing, and certificate setup. See [device-bootstrap.md](device-bootstrap.md) for the device side. + +## Prerequisites + +Install: + +- .NET SDK for the repo target framework +- Node.js and npm, for the Node oracle only +- PowerShell, for the Windows helper scripts +- `openssl`, for Linux live testing on port `443` with PEM certificate material + +Optional for real audio experiments: + +- `ffmpeg` +- `whisper.cpp` + +## Run The .NET Cloud + +From the repo root: + +```powershell +.\scripts\cloud\Start-OpenJiboDotNet.ps1 +``` + +By default, this starts: + +- HTTPS: `https://localhost:24604` +- HTTP: `http://localhost:24605` +- health check: `http://localhost:24605/health` +- websocket captures: `captures/websocket` +- HTTP captures: `captures/http` + +Smoke check: + +```powershell +.\scripts\cloud\Invoke-CloudSmoke.ps1 -BaseUrl http://localhost:24605 +``` + +Run with the Azure Blob sample launch profile: + +```powershell +.\scripts\cloud\Start-OpenJiboDotNet.ps1 -UseAzureBlobProfile +``` + +Run directly without a launch profile, useful when you want to supply all URLs and certificate settings by environment: + +```powershell +$env:ASPNETCORE_URLS = "http://0.0.0.0:24605" +.\scripts\cloud\Start-OpenJiboDotNet.ps1 -NoLaunchProfile +``` + +For a Linux live-device run on port `443`, reuse the existing PEM certificate material: + +```bash +CERT_PEM=/path/to/cert.pem \ +KEY_PEM=/path/to/key.pem \ +ASPNETCORE_URLS="https://0.0.0.0:443;http://0.0.0.0:24605" \ +./scripts/cloud/start-dotnet-with-node-cert.sh +``` + +Then run: + +```bash +./scripts/cloud/invoke-live-jibo-prep.sh +``` + +## Run The Node Cloud + +The Node cloud lives at `src/Jibo.Cloud/node`. + +From the repo root: + +```powershell +.\scripts\cloud\Start-OpenJiboNode.ps1 -Install +``` + +After dependencies are installed once, you can usually run: + +```powershell +.\scripts\cloud\Start-OpenJiboNode.ps1 +``` + +Important details: + +- The Node server binds HTTPS on port `443`. +- It expects `cert.pem` and `key.pem` in `src/Jibo.Cloud/node`. +- Use the same certificate material that your controlled Jibo routing already trusts. +- On Windows or Linux, binding port `443` may require an elevated shell. +- Stop the .NET cloud first if it is also using port `443`. + +Manual equivalent: + +```powershell +cd src\Jibo.Cloud\node +npm install +node .\open-jibo-link.js +``` + +The Node server writes discovery logs under `src/Jibo.Cloud/node/logs`. + +## Run Playground + +Playground is not a cloud server. It connects straight to a Jibo on your LAN: + +- ASR HTTP: `http://JIBO_IP:8088/asr_simple_interface` +- ASR websocket: `ws://JIBO_IP:8088/simple_port` +- TTS HTTP: `http://JIBO_IP:8089/tts_speak` + +From the repo root: + +```powershell +.\scripts\cloud\Start-OpenJiboPlayground.ps1 +``` + +When prompted, enter the Jibo IP address. + +Use Playground when you want to test the local ASR/TTS client behavior directly. Use the `.NET` or Node cloud when you want Jibo to boot and talk through the cloud-shaped protocol path. + +## Which One Should I Use? + +Use `.NET cloud` if you want the current OpenJibo behavior, release testing, captures, or anything close to the hosted future. + +Use `Node cloud` if you want the original prototype/oracle, protocol discovery, or a quick comparison against older behavior. + +Use `Playground` if you already know the robot IP and just want a local microphone-to-ASR-to-TTS loop through Jibo's local client interfaces. + +## Common Issues + +If `/health` fails, confirm the .NET cloud is running and use `http://localhost:24605/health` for local checks. + +If the Node server fails with a certificate error, add `cert.pem` and `key.pem` to `src/Jibo.Cloud/node`. + +If port `443` is busy, stop the other cloud server first or run the .NET cloud on the local dev ports. + +If a physical Jibo does not connect, confirm DNS/host routing for: + +- `api.jibo.com` +- `api-socket.jibo.com` +- `neo-hub.jibo.com` + +Then compare with the live runbook in [live-jibo-test-runbook.md](live-jibo-test-runbook.md). diff --git a/OpenJibo/scripts/cloud/README.md b/OpenJibo/scripts/cloud/README.md index 909128b..7425fca 100644 --- a/OpenJibo/scripts/cloud/README.md +++ b/OpenJibo/scripts/cloud/README.md @@ -2,6 +2,12 @@ These scripts help exercise the new .NET hosted cloud locally. +- `Start-OpenJiboDotNet.ps1` + Starts the current `.NET` cloud with local capture directories configured. +- `Start-OpenJiboNode.ps1` + Starts the legacy Node protocol oracle from `src/Jibo.Cloud/node`. +- `Start-OpenJiboPlayground.ps1` + Starts the direct local Jibo ASR/TTS Playground demo. - `Invoke-CloudSmoke.ps1` Runs a few quick HTTP checks against a local OpenJibo cloud instance. - `Invoke-ProtocolFixture.ps1` @@ -26,3 +32,5 @@ These scripts help exercise the new .NET hosted cloud locally. Bash summary helper for captured websocket telemetry and exported fixtures. - `import-websocket-capture-fixture.py` Cross-platform import/sanitization helper for exported websocket fixtures. + +See [docs/local-cloud-quickstart.md](../../docs/local-cloud-quickstart.md) for the full local setup guide. diff --git a/OpenJibo/scripts/cloud/Start-OpenJiboDotNet.ps1 b/OpenJibo/scripts/cloud/Start-OpenJiboDotNet.ps1 new file mode 100644 index 0000000..4288d6c --- /dev/null +++ b/OpenJibo/scripts/cloud/Start-OpenJiboDotNet.ps1 @@ -0,0 +1,41 @@ +param( + [string]$ProjectPath = "src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj", + [string]$LaunchProfile = "Jibo.Cloud.Api", + [string]$CaptureRoot = "captures", + [switch]$UseAzureBlobProfile, + [switch]$NoLaunchProfile +) + +$ErrorActionPreference = "Stop" + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$resolvedProjectPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ProjectPath)) +$resolvedCaptureRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $CaptureRoot)) +$webSocketCaptureDirectory = Join-Path $resolvedCaptureRoot "websocket" +$httpCaptureDirectory = Join-Path $resolvedCaptureRoot "http" + +if (-not (Test-Path -LiteralPath $resolvedProjectPath)) { + throw "Could not find .NET API project at $resolvedProjectPath" +} + +New-Item -ItemType Directory -Force -Path $webSocketCaptureDirectory | Out-Null +New-Item -ItemType Directory -Force -Path $httpCaptureDirectory | Out-Null + +$env:OpenJibo__Telemetry__DirectoryPath = $webSocketCaptureDirectory +$env:OpenJibo__ProtocolTelemetry__DirectoryPath = $httpCaptureDirectory + +if ($UseAzureBlobProfile) { + $LaunchProfile = "Jibo.Cloud.Api.AzureBlob" +} + +Write-Host "Starting OpenJibo .NET cloud" +Write-Host " - project: $resolvedProjectPath" +Write-Host " - websocket captures: $webSocketCaptureDirectory" +Write-Host " - http captures: $httpCaptureDirectory" +if ($NoLaunchProfile) { + Write-Host " - launch profile: disabled" + dotnet run --project $resolvedProjectPath --no-launch-profile +} else { + Write-Host " - launch profile: $LaunchProfile" + dotnet run --project $resolvedProjectPath --launch-profile $LaunchProfile +} diff --git a/OpenJibo/scripts/cloud/Start-OpenJiboNode.ps1 b/OpenJibo/scripts/cloud/Start-OpenJiboNode.ps1 new file mode 100644 index 0000000..9816dd4 --- /dev/null +++ b/OpenJibo/scripts/cloud/Start-OpenJiboNode.ps1 @@ -0,0 +1,45 @@ +param( + [string]$NodeDirectory = "src/Jibo.Cloud/node", + [switch]$Install +) + +$ErrorActionPreference = "Stop" + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$resolvedNodeDirectory = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $NodeDirectory)) +$serverPath = Join-Path $resolvedNodeDirectory "open-jibo-link.js" +$packagePath = Join-Path $resolvedNodeDirectory "package.json" +$certPath = Join-Path $resolvedNodeDirectory "cert.pem" +$keyPath = Join-Path $resolvedNodeDirectory "key.pem" + +if (-not (Test-Path -LiteralPath $serverPath)) { + throw "Could not find Node server at $serverPath" +} + +if (-not (Test-Path -LiteralPath $packagePath)) { + throw "Could not find package.json at $packagePath" +} + +if ($Install -or -not (Test-Path -LiteralPath (Join-Path $resolvedNodeDirectory "node_modules"))) { + Write-Host "Installing Node dependencies" + npm install --prefix $resolvedNodeDirectory +} + +if (-not (Test-Path -LiteralPath $certPath) -or -not (Test-Path -LiteralPath $keyPath)) { + Write-Warning "cert.pem and key.pem are not present in $resolvedNodeDirectory." + Write-Warning "The Node oracle expects those files because it binds HTTPS on port 443." + Write-Warning "Use the same dev certificate material that your controlled Jibo routing already trusts." +} + +Write-Host "Starting OpenJibo Node protocol oracle" +Write-Host " - directory: $resolvedNodeDirectory" +Write-Host " - server: $serverPath" +Write-Host " - port: 443" + +Push-Location $resolvedNodeDirectory +try { + node .\open-jibo-link.js +} +finally { + Pop-Location +} diff --git a/OpenJibo/scripts/cloud/Start-OpenJiboPlayground.ps1 b/OpenJibo/scripts/cloud/Start-OpenJiboPlayground.ps1 new file mode 100644 index 0000000..c79ebba --- /dev/null +++ b/OpenJibo/scripts/cloud/Start-OpenJiboPlayground.ps1 @@ -0,0 +1,20 @@ +param( + [string]$ProjectPath = "src/Playground/Playground.csproj" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$resolvedProjectPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ProjectPath)) + +if (-not (Test-Path -LiteralPath $resolvedProjectPath)) { + throw "Could not find Playground project at $resolvedProjectPath" +} + +Write-Host "Starting OpenJibo Playground" +Write-Host " - project: $resolvedProjectPath" +Write-Host " - mode: direct local Jibo ASR/TTS client" +Write-Host "" +Write-Host "When prompted, enter the Jibo IP address on your local network." + +dotnet run --project $resolvedProjectPath diff --git a/OpenJibo/src/Jibo.Cloud/README.md b/OpenJibo/src/Jibo.Cloud/README.md index d6b3551..029268b 100644 --- a/OpenJibo/src/Jibo.Cloud/README.md +++ b/OpenJibo/src/Jibo.Cloud/README.md @@ -70,6 +70,10 @@ For a real storage account, swap `UseDevelopmentStorage=true` with your Azure St ## Local Startup Note +For the practical local run guide, including `.NET`, Node, and Playground, start with: + +- [Local OpenJibo Cloud Quickstart](../../docs/local-cloud-quickstart.md) + To run the API with the Blob-backed sample config in Visual Studio or `dotnet run`, choose the `Jibo.Cloud.Api.AzureBlob` launch profile. diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/README.md b/OpenJibo/src/Jibo.Cloud/dotnet/README.md index 75c5de2..5be1bf4 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/README.md +++ b/OpenJibo/src/Jibo.Cloud/dotnet/README.md @@ -8,6 +8,15 @@ This is the production-oriented path for restoring device connectivity and creat Current spoken cloud version: `Cloud version 1.0.19.` +Local startup: + +```powershell +.\scripts\cloud\Start-OpenJiboDotNet.ps1 +``` + +Run that from the repo root. For the full local guide, including Node and Playground, see +[local-cloud-quickstart.md](../../../docs/local-cloud-quickstart.md). + Release hygiene reminder: - bump [OpenJiboCloudBuildInfo.cs](/C:/Projects/JiboExperiments/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/OpenJiboCloudBuildInfo.cs) whenever we ship a meaningful hosted-cloud update diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs index 337d58f..2e82671 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs @@ -239,4 +239,6 @@ static CloudSession ResolveSession(JiboWebSocketService webSocketService, WebSoc return webSocketService.GetOrCreateSession(envelope); } -internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer); \ No newline at end of file +internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer); + +public partial class Program; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs index 512ee53..6941c3d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs @@ -123,7 +123,7 @@ public sealed class CloudStateCommuteReportProvider(ICloudStateStore cloudStateS return true; } - private static string? ResolveLoopId(TurnContext turn) + private static string ResolveLoopId(TurnContext turn) { if (turn.Attributes.TryGetValue("loopId", out var loopValue) && loopValue is not null && @@ -147,4 +147,4 @@ public sealed class CloudStateCommuteReportProvider(ICloudStateStore cloudStateS return null; } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs index a9612d4..ef5c6eb 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs @@ -25,6 +25,22 @@ internal sealed class JsonFileSnapshotStore(string? persistencePath, JsonSeriali var directory = Path.GetDirectoryName(persistencePath); if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); - File.WriteAllText(persistencePath, JsonSerializer.Serialize(snapshot, options)); + var tempPath = Path.Combine( + string.IsNullOrWhiteSpace(directory) ? Directory.GetCurrentDirectory() : directory, + $".{Path.GetFileName(persistencePath)}.{Guid.NewGuid():N}.tmp"); + + try + { + File.WriteAllText(tempPath, JsonSerializer.Serialize(snapshot, options)); + + if (File.Exists(persistencePath)) + File.Replace(tempPath, persistencePath, null); + else + File.Move(tempPath, persistencePath); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/node/README.md b/OpenJibo/src/Jibo.Cloud/node/README.md index 07ad0e5..2268428 100644 --- a/OpenJibo/src/Jibo.Cloud/node/README.md +++ b/OpenJibo/src/Jibo.Cloud/node/README.md @@ -13,6 +13,28 @@ The Node server is still the best place to: It is no longer the intended production runtime. +## Local Startup + +From the repo root: + +```powershell +.\scripts\cloud\Start-OpenJiboNode.ps1 -Install +``` + +Manual equivalent: + +```powershell +cd src\Jibo.Cloud\node +npm install +npm start +``` + +The Node oracle binds HTTPS on port `443` and expects `cert.pem` and `key.pem` in this folder. +Use the same certificate material that your controlled Jibo routing already trusts. + +For the full local guide, including the current `.NET` cloud and Playground, see +[local-cloud-quickstart.md](../../../docs/local-cloud-quickstart.md). + ## What Stays Here - reverse-engineering work diff --git a/OpenJibo/src/Jibo.Cloud/node/package.json b/OpenJibo/src/Jibo.Cloud/node/package.json index c4888b0..e1994e1 100644 --- a/OpenJibo/src/Jibo.Cloud/node/package.json +++ b/OpenJibo/src/Jibo.Cloud/node/package.json @@ -4,6 +4,7 @@ "description": "", "main": "open-jibo-link.js", "scripts": { + "start": "node open-jibo-link.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Api/JiboCloudApiIntegrationTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Api/JiboCloudApiIntegrationTests.cs new file mode 100644 index 0000000..454de5c --- /dev/null +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Api/JiboCloudApiIntegrationTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Json; +using System.Net.WebSockets; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Jibo.Cloud.Tests.Api; + +public sealed class JiboCloudApiIntegrationTests +{ + [Fact] + public async Task Health_ReturnsCurrentVersion() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/health"); + var body = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.True(body!.Ok); + Assert.Equal("OpenJibo Cloud Api", body.Service); + Assert.Equal("1.0.19", body.Version); + } + + [Fact] + public async Task HttpProtocolDispatch_HandlesCreateHubTokenTarget() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/") + { + Content = JsonContent.Create(new { }) + }; + request.Headers.TryAddWithoutValidation("X-Amz-Target", "Account_20160715.CreateHubToken"); + request.Headers.Host = "api.jibo.com"; + + var response = await client.SendAsync(request); + var payload = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(payload); + Assert.False(string.IsNullOrWhiteSpace(payload!.Token)); + } + + [Fact] + public async Task WebSocket_MissingTokenOnNeoHubListen_ReturnsUnauthorized() + { + await using var factory = CreateFactory(); + var client = factory.Server.CreateWebSocketClient(); + + var exception = await Assert.ThrowsAsync(() => + client.ConnectAsync(new Uri("ws://neo-hub.jibo.com/"), CancellationToken.None)); + + Assert.Contains("401", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task WebSocket_TokenPathOnNeoHubListen_Connects() + { + await using var factory = CreateFactory(); + var client = factory.Server.CreateWebSocketClient(); + + using var socket = await client.ConnectAsync(new Uri("ws://neo-hub.jibo.com/test-token"), CancellationToken.None); + + Assert.Equal(WebSocketState.Open, socket.State); + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "test-complete", CancellationToken.None); + } + + private static WebApplicationFactory CreateFactory() + { + var root = Path.Combine(Path.GetTempPath(), $"openjibo-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseSetting("OpenJibo:Telemetry:DirectoryPath", Path.Combine(root, "websocket")); + builder.UseSetting("OpenJibo:ProtocolTelemetry:DirectoryPath", Path.Combine(root, "http")); + builder.UseSetting("OpenJibo:TurnTelemetry:DirectoryPath", Path.Combine(root, "turn")); + builder.UseSetting("OpenJibo:State:PersistencePath", Path.Combine(root, "cloud-state.json")); + builder.UseSetting("OpenJibo:PersonalMemory:PersistencePath", Path.Combine(root, "personal-memory.json")); + builder.UseSetting("OpenJibo:Media:DirectoryPath", Path.Combine(root, "media")); + }); + } + + private sealed record HealthResponse(bool Ok, string Service, string Version); + + private sealed record CreateHubTokenResponse(string Token); +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs index 412fc3d..cdb493a 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -190,6 +190,34 @@ public sealed class PersistenceStoreTests } } + [Fact] + public void PersonalMemoryStore_IgnoresCorruptSnapshotAndOverwritesWithValidJson() + { + var persistenceDirectory = Path.Combine(Path.GetTempPath(), $"openjibo-corrupt-memory-{Guid.NewGuid():N}"); + var persistencePath = Path.Combine(persistenceDirectory, "memory.json"); + + try + { + Directory.CreateDirectory(persistenceDirectory); + File.WriteAllText(persistencePath, "{ not valid json"); + + var scope = new PersonalMemoryTenantScope("acct-corrupt", "loop-corrupt", "device-corrupt"); + var store = new InMemoryPersonalMemoryStore(persistencePath); + Assert.Null(store.GetName(scope)); + + store.SetName(scope, "Recovered"); + + var reloaded = new InMemoryPersonalMemoryStore(persistencePath); + Assert.Equal("Recovered", reloaded.GetName(scope)); + Assert.DoesNotContain(Directory.GetFiles(persistenceDirectory), + path => Path.GetFileName(path).Contains(".tmp", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (Directory.Exists(persistenceDirectory)) Directory.Delete(persistenceDirectory, recursive: true); + } + } + private sealed class RecordingSnapshotStore : ISnapshotStore { public List Saves { get; } = []; diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj b/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj index 5974d0b..ba4e408 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj @@ -9,12 +9,14 @@ + + diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 421c881..fb00bf6 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -333,7 +333,7 @@ public sealed class JiboInteractionServiceTests ["messageType"] = "TRIGGER", ["triggerSource"] = "PRESENCE", ["context"] = - """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + """{"runtime":{"location":{"iso":"2026-05-21T15:00:00-05:00"},"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" } }); @@ -364,7 +364,7 @@ public sealed class JiboInteractionServiceTests ["messageType"] = "TRIGGER", ["triggerSource"] = "PRESENCE", ["context"] = - """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"},{"id":"person-2"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"},{"id":"person-2","firstName":"sam"}]}}}""" + """{"runtime":{"location":{"iso":"2026-05-21T15:00:00-05:00"},"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"},{"id":"person-2"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"},{"id":"person-2","firstName":"sam"}]}}}""" } }); @@ -513,6 +513,58 @@ public sealed class JiboInteractionServiceTests Assert.Equal("ProactiveHolidayGreeting", decision.ContextUpdates![GreetingRouteKey]); } + [Fact] + public async Task BuildDecisionAsync_TriggerUsesHolidayGreetingOnlyOnMatchingFixedDate() + { + var cloudStateStore = new InMemoryCloudStateStore(); + cloudStateStore.UpsertHoliday(new HolidayRecord + { + LoopId = "loop-fixed-holiday", + Name = "Test Holiday", + Category = "holiday", + Date = new DateOnly(2026, 8, 13), + IsEnabled = true, + Source = "manual", + CountryCode = "US" + }); + var service = CreateService(cloudStateStore: cloudStateStore); + + var ordinaryDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-fixed-holiday", + ["loopId"] = "loop-fixed-holiday", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-08-12T09:00:00-05:00"},"perception":{"speaker":"person-8","peoplePresent":[{"id":"person-8"}]},"loop":{"users":[{"id":"person-8","firstName":"jake"}]}}}""" + } + }); + + var holidayDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-fixed-holiday", + ["loopId"] = "loop-fixed-holiday", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-08-13T09:00:00-05:00"},"perception":{"speaker":"person-9","peoplePresent":[{"id":"person-9"}]},"loop":{"users":[{"id":"person-9","firstName":"sam"}]}}}""" + } + }); + + Assert.Equal("proactive_greeting", ordinaryDecision.IntentName); + Assert.Equal("ProactiveGreeting", ordinaryDecision.ContextUpdates![GreetingRouteKey]); + Assert.Equal("proactive_holiday_greeting", holidayDecision.IntentName); + Assert.Equal("ProactiveHolidayGreeting", holidayDecision.ContextUpdates![GreetingRouteKey]); + } + [Fact] public async Task BuildDecisionAsync_TriggerWithKnownIdentity_SuppressesRepeatGreetingFromCloudHistory() { @@ -2255,7 +2307,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("personal_report_opt_in", decision.IntentName); Assert.Equal("Would you like your personal report now?", decision.ReplyText); - Assert.Equal("shared/yes_no", ((IReadOnlyList)decision.SkillPayload!["listen_contexts"])[0]); + Assert.NotNull(decision.SkillPayload); + var listenContexts = Assert.IsAssignableFrom>(decision.SkillPayload["listen_contexts"]); + Assert.Equal("shared/yes_no", listenContexts[0]); Assert.NotNull(decision.ContextUpdates); Assert.Equal("awaiting_opt_in", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal(true, decision.ContextUpdates[PersonalReportWeatherEnabledKey]); @@ -2286,7 +2340,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("personal_report_verify_user", decision.IntentName); Assert.Equal("I think this is alex. Is that right?", decision.ReplyText); - Assert.Equal("shared/yes_no", ((IReadOnlyList)decision.SkillPayload!["listen_contexts"])[0]); + Assert.NotNull(decision.SkillPayload); + var listenContexts = Assert.IsAssignableFrom>(decision.SkillPayload["listen_contexts"]); + Assert.Equal("shared/yes_no", listenContexts[0]); Assert.NotNull(decision.ContextUpdates); Assert.Equal("awaiting_identity_confirmation", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal("alex", decision.ContextUpdates[PersonalReportUserNameKey]);