Przeglądaj źródła

* Test insight client

Michaël Van Canneyt 1 rok temu
rodzic
commit
952f6ebf23

+ 1 - 0
packages/fpmake_add.inc

@@ -155,4 +155,5 @@
   add_gitlab(ADirectory+IncludeTrailingPathDelimiter('gitlab'));
   add_fcl_css(ADirectory+IncludeTrailingPathDelimiter('fcl-css'));
   add_gstreamer(ADirectory+IncludeTrailingPathDelimiter('gstreamer'));
+  add_testinsight(ADirectory+IncludeTrailingPathDelimiter('testinsight'));
   

+ 2 - 0
packages/fpmake_proc.inc

@@ -876,5 +876,7 @@ begin
 {$include gstreamer/fpmake.pp}
 end;
 
+{$include testinsight/fpmake.pp}
+
 {$include ide/fpmake.pp}
 {$include gitlab/fpmake.pp}

+ 22 - 0
packages/testinsight/example/TestInsightSettings.ini

@@ -0,0 +1,22 @@
+[Config]
+ExecuteTests=1
+ShowProgress=1
+BaseUrl=http://localhost:6789/tests
+
+[Tests]
+All Tests.Checked=1
+All Tests.Expanded=1
+Suite1.Checked=1
+Suite1.Expanded=1
+Suite1.Test1.Checked=1
+Suite1.Test1.Expanded=1
+Suite1.Test2.Checked=1
+Suite1.Test2.Expanded=0
+Suite2.Checked=1
+Suite2.Expanded=1
+Suite2.Test1.Checked=1
+Suite2.Test1.Expanded=0
+Suite2.Test2.Checked=1
+Suite2.Test2.Expanded=0
+Suite2.Test3.Checked=1
+Suite2.Test3.Expanded=1

+ 85 - 0
packages/testinsight/example/clienttest.lpi

@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<CONFIG>
+  <ProjectOptions>
+    <Version Value="12"/>
+    <General>
+      <Flags>
+        <SaveOnlyProjectUnits Value="True"/>
+        <MainUnitHasCreateFormStatements Value="False"/>
+        <MainUnitHasTitleStatement Value="False"/>
+        <MainUnitHasScaledStatement Value="False"/>
+      </Flags>
+      <SessionStorage Value="InProjectDir"/>
+      <Title Value="clienttest"/>
+      <UseAppBundle Value="False"/>
+      <ResourceType Value="res"/>
+    </General>
+    <BuildModes>
+      <Item Name="Default" Default="True"/>
+    </BuildModes>
+    <PublishOptions>
+      <Version Value="2"/>
+      <UseFileFilters Value="True"/>
+    </PublishOptions>
+    <RunParams>
+      <FormatVersion Value="2"/>
+    </RunParams>
+    <RequiredPackages>
+      <Item>
+        <PackageName Value="FCL"/>
+      </Item>
+    </RequiredPackages>
+    <Units>
+      <Unit>
+        <Filename Value="clienttest.lpr"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="tctests.pas"/>
+        <IsPartOfProject Value="True"/>
+        <UnitName Value="tcTests"/>
+      </Unit>
+      <Unit>
+        <Filename Value="../fpcunittestinsight.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="../testinsightclient.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="../testinsightprotocol.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+    </Units>
+  </ProjectOptions>
+  <CompilerOptions>
+    <Version Value="11"/>
+    <Target>
+      <Filename Value="clienttest"/>
+    </Target>
+    <SearchPaths>
+      <IncludeFiles Value="$(ProjOutDir)"/>
+      <OtherUnitFiles Value=".."/>
+      <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/>
+    </SearchPaths>
+    <Linking>
+      <Debugging>
+        <UseHeaptrc Value="True"/>
+      </Debugging>
+    </Linking>
+  </CompilerOptions>
+  <Debugging>
+    <Exceptions>
+      <Item>
+        <Name Value="EAbort"/>
+      </Item>
+      <Item>
+        <Name Value="ECodetoolError"/>
+      </Item>
+      <Item>
+        <Name Value="EFOpenError"/>
+      </Item>
+    </Exceptions>
+  </Debugging>
+</CONFIG>

+ 32 - 0
packages/testinsight/example/clienttest.lpr

@@ -0,0 +1,32 @@
+program clienttest;
+
+{$mode objfpc}{$H+}
+
+uses
+  Classes, jsonparser, consoletestrunner, tcTests, fpcunittestinsight, testinsightclient,
+  testinsightprotocol;
+
+type
+
+  { TMyTestRunner }
+
+  TMyTestRunner = class(TTestRunner)
+  protected
+  // override the protected methods of TTestRunner to customize its behavior
+  end;
+
+var
+  Application: TMyTestRunner;
+
+begin
+  if IsTestinsightListening() then
+    RunRegisteredTests('','')
+  else
+    begin
+    Application := TMyTestRunner.Create(nil);
+    Application.Initialize;
+    Application.Title := 'FPCUnit Console test runner';
+    Application.Run;
+    Application.Free;
+    end;
+end.

+ 61 - 0
packages/testinsight/example/tctests.pas

@@ -0,0 +1,61 @@
+unit tcTests;
+
+{$mode objfpc}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, fpcunit, testutils, testregistry;
+
+type
+  Suite1 = class(TTestCase)
+  published
+    procedure Test1;
+    procedure Test2;
+  end;
+
+  { Suite2 }
+
+  Suite2 = class(TTestCase)
+  published
+    procedure Test1;
+    procedure Test2;
+    procedure Test3;
+  end;
+
+implementation
+
+{ Suite2 }
+
+procedure Suite2.Test1;
+begin
+  Fail('Test 1 fails');
+end;
+
+procedure Suite2.Test2;
+begin
+  AssertTrue('Test 2 OK',True);
+end;
+
+procedure Suite2.Test3;
+begin
+  Raise Exception.Create('test 3 errors');
+end;
+
+
+procedure Suite1.Test1;
+begin
+  Fail('Test 1 fails');
+end;
+
+procedure Suite1.Test2;
+begin
+  AssertTrue('Test 2 OK',True);
+end;
+
+
+
+initialization
+  RegisterTests([Suite1,Suite2]);
+end.
+

+ 61 - 0
packages/testinsight/fpmake.pp

@@ -0,0 +1,61 @@
+{$ifndef ALLPACKAGES}
+{$mode objfpc}{$H+}
+program fpmake;
+
+uses {$ifdef unix}cthreads,{$endif} fpmkunit;
+
+{$endif ALLPACKAGES}
+
+procedure add_testinsight(const aDirectory: string);
+
+Const
+  // All oses that have TFPHTTPClient
+  
+  WebOses = [aix,beos,haiku,linux,freebsd,darwin,iphonesim,ios,netbsd,openbsd,solaris,win32,win64,wince,android,dragonfly];
+
+Var
+  T : TTarget;
+  P : TPackage;
+begin
+  With Installer do
+    begin
+    P:=AddPackage('testinsight');
+    P.ShortName:='tinsight';
+    P.Directory:=aDirectory;
+    P.Version:='3.3.1';
+    
+    P.OSes := WebOSes;
+    if Defaults.CPU=jvm then
+      P.OSes := P.OSes - [java,android];
+
+    P.Dependencies.Add('fcl-web');
+    P.Dependencies.Add('fcl-fpcunit');
+    P.Dependencies.Add('fcl-json');
+    P.Author := 'FreePascal development team';
+    P.License := 'LGPL with modification, ';
+    P.HomepageURL := 'www.freepascal.org';
+    P.Email := '';
+    P.Description := 'Send FPCUnit test results to a webserver (e.g. embedded in Lazarus IDE).';
+    P.NeedLibC:= false;
+    P.SourcePath.Add('src');
+
+    T:=P.Targets.AddUnit('testinsightprotocol.pp');
+    
+    T:=P.Targets.AddUnit('testinsightclient.pp');
+    T.Dependencies.AddUnit('testinsightprotocol');
+    
+    T:=P.Targets.AddUnit('fpcunittestinsight.pp');
+    T.Dependencies.AddUnit('testinsightclient');
+    T.Dependencies.AddUnit('testinsightprotocol');
+    
+    P.NamespaceMap:='namespaces.lst';
+    end;
+      
+end;
+    
+{$ifndef ALLPACKAGES}
+begin
+  add_testinsight('');
+  Installer.Run;
+end.
+{$endif ALLPACKAGES}

+ 306 - 0
packages/testinsight/src/fpcunittestinsight.pp

@@ -0,0 +1,306 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2023 by Michael Van Canneyt
+
+    Test Insight FPCUnit test listener.
+    
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+unit fpcunittestinsight;
+
+{$mode ObjFPC}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, types, fpjson, fpcunit, testregistry, testinsightprotocol, testinsightclient;
+
+type
+
+  { TFPCUnitTestInsightHelper }
+
+  TFPCUnitTestInsightHelper = Class helper for TTestInsightResult
+    Procedure FromTestFailure(ATest: TTest; aFailure: TTestFailure);
+  end;
+
+  { TTestInsightListener }
+
+  TTestInsightListener = class(TInterfacedObject, ITestListener)
+  private
+    fClient: TAbstractTestInsightClient;
+    fSelectedTests: TStringDynArray;
+    FLastError : TTest;
+    FStart : TDateTime;
+    FRootTest : TTest;
+  Protected
+    procedure AddFailure(ATest: TTest; aFailure: TTestFailure); virtual;
+    procedure AddError(ATest: TTest; aError: TTestFailure); virtual;
+    procedure StartTest(ATest: TTest); virtual;
+    procedure EndTest(ATest: TTest); virtual;
+    procedure StartTestSuite(ATestSuite: TTestSuite); virtual;
+    procedure EndTestSuite(ATestSuite: TTestSuite); virtual;
+    procedure SendTestSuite; virtual;
+  public
+    constructor Create(const aClient : TAbstractTestInsightClient; aRoot: TTest);
+    Property RootTest : TTest Read FRootTest;
+  end;
+
+procedure RunRegisteredTests(const aConfig : String = ''; const baseUrl: string = DefaultUrl);
+function IsTestinsightListening(const aConfig : String = ''; const baseUrl: string = DefaultUrl) : Boolean;
+Function DefaultTestConfigFileName : String;
+Function TestSuiteToJSON(aSuite : TTest) : TJSONObject;
+Procedure TestSuiteToJSON(aSuite : TTest; aJSON : TJSONObject);
+
+implementation
+
+uses
+  DateUtils;
+
+Function DefaultTestConfigFileName : String;
+begin
+  Result:=ExtractFilePath(Paramstr(0));
+  Result:=Result+'TestInsightSettings.ini';
+end;
+
+function TestSuiteToJSON(aSuite: TTest): TJSONObject;
+begin
+  Result:=TJSONObject.Create;
+  try
+   TestSuiteToJSOn(aSuite,Result);
+  except
+    Result.Free;
+    Raise;
+  end;
+end;
+
+procedure TestSuiteToJSON(aSuite: TTest; aJSON: TJSONObject);
+
+Var
+  T : TTest;
+  I : Integer;
+  j : TJSONObject;
+
+begin
+  For I:=0 to aSuite.GetChildTestCount-1 do
+    begin
+    T:=aSuite.GetChildTest(I);
+    if T is TTestSuite then
+      begin
+      J:=TJSONObject.Create;
+      aJSON.Add(T.TestName,J);
+      TestSuiteToJSON(T as TTestSuite,J);
+      end
+    else
+      aJSON.Add(T.TestName);
+    end;
+end;
+
+
+Function CreateClient(const aConfig : String = ''; const baseUrl: string = DefaultUrl) : TAbstractTestInsightClient;
+
+Var
+  aURL,Cfg : String;
+
+begin
+  Cfg:=aConfig;
+  if (Cfg='') then
+    Cfg:=DefaultTestConfigFileName;
+  aURL:=baseURL;
+  if aURL='' then
+    aURL:=DefaultURL;
+  Result:=TTestInsightHTTPClient.Create(aURL);
+  Result.LoadConfig(cfg);
+end;
+
+function IsTestinsightListening(const aConfig : String = ''; const baseUrl: string = DefaultUrl) : Boolean;
+
+begin
+  With CreateClient(aConfig,BaseURL) do
+    try
+      GetServerOptions;
+      Result:=Not HasError;
+    finally
+      Free
+    end;
+end;
+
+Procedure AddSkips (aResult : TTestResult; aSuite : TTest; aAllowed : TTest);
+
+Var
+  I : Integer;
+  T : TTest;
+
+begin
+  if (aSuite=aAllowed) then exit;
+  for I:=0 to aSuite.GetChildTestCount-1 do
+    begin
+    T:=aSuite.GetChildTest(I);
+    if T is TTestCase then
+      aResult.AddToSkipList(T as TTestCase)
+    else
+      AddSkips(aResult,T,aAllowed)
+    end;
+end;
+
+procedure RunRegisteredTests(const aConfig : String = ''; const baseUrl: string = DefaultUrl);
+
+var
+  Suite: TTest;
+  TestResult: TTestResult;
+  Listener: TTestInsightListener;
+  Client : TAbstractTestInsightClient;
+
+begin
+  Suite := GetTestRegistry;
+  if not Assigned(Suite) then
+    Exit;
+  Client:=Nil;
+  TestResult:=Nil;
+  Listener:=Nil;
+  try
+    Client:=CreateClient(aConfig,BaseURL);
+    Listener := TTestInsightListener.Create(Client, Suite);
+    if Client.Options.ExecuteTests then
+      begin
+      TestResult := TTestResult.Create;
+      if Client.Options.TestSuite<>'' then
+        AddSkips(TestResult,Suite,Suite.FindTest(Client.Options.TestSuite));
+      TestResult.AddListener(Listener as ITestListener);
+      Suite.Run(TestResult);
+      end
+    else
+      FreeAndNil(Listener);
+  finally
+    TestResult.Free;
+    Client.Free;
+  end;
+end;
+
+{ TFPCUnitTestInsightHelper }
+
+procedure TFPCUnitTestInsightHelper.FromTestFailure(ATest: TTest; aFailure: TTestFailure);
+
+Const
+  TestStepToPhase : Array[TTestStep] of TTestPhase
+    = (tpSetUp, tpRunTest, spTearDown, tpNothing);
+
+
+begin
+  TestName:=aTest.TestSuiteName+'.'+aTest.TestName;
+  TestClassName:=aTest.ClassName;
+  TestUnitName:=aTest.UnitName;
+  TestMethodName := aTest.TestName;
+  if not Assigned(aFailure) then
+    exit;
+  TestExceptionMessage := aFailure.ExceptionMessage;
+  TestExceptionClass:= aFailure.ExceptionClassName;
+  TestIsIgnored:=aFailure.IsIgnoredTest;
+  if aFailure.IsFailure then
+    TestResult:=rtFailed
+  else
+    TestResult:=rtError;
+  TestPhase:=TestStepToPhase[aFailure.TestLastStep];
+  FailureLineNumber:=aFailure.LineNumber;
+  FailureUnitName:=aFailure.UnitName;
+  FailureMethodName:=aFailure.FailedMethodName;
+  FailureSourceUnitName:=aFailure.SourceUnitName;
+  FailureLocationInfo:=aFailure.LocationInfo;
+end;
+
+{ TTestInsightTestListener }
+
+constructor TTestInsightListener.Create(const aClient : TAbstractTestInsightClient; aRoot : TTest);
+
+begin
+  inherited Create;
+  fClient := aClient;
+  fSelectedTests := fClient.GetTests;
+  FRootTest:=aRoot;
+  SendTestSuite;
+end;
+
+procedure TTestInsightListener.AddError(ATest: TTest; aError: TTestFailure);
+
+var
+  testResult: TTestInsightResult;
+begin
+  testResult := TTestInsightResult.Create;
+  testResult.FromTestFailure(aTest,aError);
+  testResult.TestResult := rtError;
+  fClient.PostResult(testResult,false);
+end;
+
+procedure TTestInsightListener.AddFailure(ATest: TTest; aFailure: TTestFailure);
+var
+  testResult: TTestInsightResult;
+begin
+  testResult := TTestInsightResult.Create;
+  testResult.FromTestFailure(aTest,aFailure);
+  if aFailure.ExceptionMessage = SAssertNotCalled then
+    testResult.TestResult := rtWarning
+  else
+    testResult.TestResult := rtFailed;
+  fClient.PostResult(testResult,False);
+  FLastError:=aTest;
+end;
+
+
+procedure TTestInsightListener.EndTestSuite(ATestSuite: TTestSuite);
+begin
+  if (aTestSuite=FRootTest) then
+    fClient.FinishedTesting;
+end;
+
+procedure TTestInsightListener.StartTestSuite(ATestSuite: TTestSuite);
+begin
+  if (aTestSuite=FRootTest) then
+    fClient.StartedTesting(FRootTest.CountTestCases);
+end;
+
+procedure TTestInsightListener.StartTest(ATest: TTest);
+
+begin
+  FStart:=Now;
+end;
+
+procedure TTestInsightListener.EndTest(ATest: TTest);
+
+var
+  testResult: TTestInsightResult;
+
+begin
+  if Not ({IsTestMethod(aTest) and} (fLastError <> Atest)) then
+    exit;
+  testResult := TTestInsightResult.Create;
+  TestResult.TestName:=aTest.TestSuiteName+'.'+aTest.TestName;
+  TestResult.TestResult:=rtPassed;
+  testResult.TestDuration := MilliSecondsBetween(Now,FStart);
+  testResult.TestUnitName := aTest.UnitName;
+  testResult.TestClassName := ATest.ClassName;
+  testResult.TestMethodName := aTest.TestName;
+  fClient.PostResult(testResult,False);
+end;
+
+
+procedure TTestInsightListener.SendTestSuite;
+
+Var
+  aJSON : TJSONObject;
+
+begin
+  aJSON:=TestSuiteToJSON(FRootTest);
+  try
+    fClient.SetTestNames(aJSON);
+  finally
+    aJSON.Free;
+  end;
+end;
+
+end.
+

+ 354 - 0
packages/testinsight/src/testinsightclient.pp

@@ -0,0 +1,354 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2023 by Michael Van Canneyt
+
+    Test Insight client component.
+    
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+unit testinsightclient;
+
+{$mode ObjFPC}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, types, testinsightprotocol, fphttpclient, fpjson, contnrs, inifiles;
+
+Type
+
+  { TAbstractTestInsightClient }
+
+  TAbstractTestInsightClient = class (TObject)
+  private
+    FOptions : TTestInsightOptions;
+    FPendingResultCount: Integer;
+    FResults : TFPObjectList;
+    FBaseURL : String;
+    procedure SetOptions(const value: TTestInsightOptions);
+  protected
+    function GetHasError: Boolean; virtual; abstract;
+    function GetLastErrorMessage: String; virtual; abstract;
+    function JSONToTests(const aJSON: string): TStringDynArray;
+    // URL is Relative to base URL
+    procedure ServerPost(aURL: string; const aContent: string = ''); virtual; abstract;
+    Function ServerGet(aURL: string) : string; virtual; abstract;
+    Procedure ServerDelete(aURL: string) ; virtual; abstract;
+    Function ConcatURL(aURL : String) : String; virtual;
+  public
+    constructor Create(const aBaseURL : String); virtual;
+    destructor Destroy; override;
+    procedure LoadConfig(const aConfigFileName : String; aSection : String = '');
+    procedure LoadConfig(aIni : TCustomIniFile; aSection : String);
+    procedure GetServerOptions;
+    // The client will free the result.
+    procedure PostResult(const testResult: TTestInsightResult; forceSend: Boolean);
+    // The client will free the results.
+    procedure PostResults(const testResults: array of TTestInsightResult; forceSend: Boolean);
+    procedure StartedTesting(const totalCount: Integer);
+    procedure FinishedTesting;
+    procedure ClearTests;
+    Procedure SetTestNames(aJSON : TJSONObject);
+    function GetTests: TStringDynArray;
+    property Options: TTestInsightOptions read FOptions write SetOptions;
+    Property HasError : Boolean Read GetHasError;
+    Property LastErrorMessage : String Read GetLastErrorMessage;
+    Property BaseURL : String Read FBaseURL;
+    Property PendingResultCount : Integer Read FPendingResultCount;
+  end;
+
+  { TTestInsightHTTPClient }
+
+  TTestInsightHTTPClient = class(TAbstractTestInsightClient)
+  private
+    FHttp: TFPHTTPClient;
+    FLastError : String;
+    FInError : Boolean;
+    Procedure SetError(E : Exception);
+  protected
+    function GetHasError: Boolean; override;
+    function GetLastErrorMessage: string; override;
+    procedure ServerPost(aURL: string; const aContent: string = ''); override;
+    Function ServerGet(aURL: string) : string; override;
+    Procedure ServerDelete(aURL: string); override;
+  public
+    Constructor Create(Const aBaseURL : String); override;
+    Destructor Destroy; override;
+  end;
+
+
+implementation
+
+{ TTestInsightHTTPClient }
+
+procedure TTestInsightHTTPClient.ServerPost(aURL: string; const aContent: string = '');
+
+Var
+  Body,Res : TStringStream;
+
+begin
+  Body:=Nil;
+  Res:=TStringStream.Create('');
+  try
+    if aContent<>'' then
+      Body:=TStringStream.Create(aContent);
+    FHTTP.RequestBody:=Body;
+    try
+      FHTTP.AddHeader('Content-Type','application/json');
+      FHTTP.Post(ConcatURL(aURL),Res);
+      SetError(Nil);
+    except
+      on E : Exception do
+        SetError(E);
+    end;
+  finally
+    FHTTP.RequestBody:=Nil;
+    Res.Free;
+    Body.Free;
+  end;
+end;
+
+function TTestInsightHTTPClient.ServerGet(aURL: string): string;
+Var
+  Res : TStringStream;
+
+begin
+  Res:=TStringStream.Create('');
+  try
+    try
+      FHTTP.Get(ConcatURL(aURL),Res);
+      Result:=Res.DataString;
+      SetError(Nil);
+    except
+      on E : Exception do
+        SetError(E);
+    end;
+  finally
+    Res.Free;
+  end;
+end;
+
+procedure TTestInsightHTTPClient.ServerDelete(aURL: string);
+begin
+  try
+    FHTTP.Delete(ConcatURL(aURL));
+    SetError(Nil);
+  except
+    on E : Exception do
+      SetError(E);
+  end;
+end;
+
+constructor TTestInsightHTTPClient.Create(const aBaseURL: String);
+begin
+  inherited Create(aBaseURL);
+  FHTTP:=TFPHTTPClient.Create(Nil);
+//  FHTTP.ConnectTimeout:=100;
+//  FHTTP.IOTimeout:=1000;
+end;
+
+destructor TTestInsightHTTPClient.Destroy;
+begin
+  FreeAndNil(FHTTP);
+  inherited Destroy;
+end;
+
+procedure TTestInsightHTTPClient.SetError(E: Exception);
+begin
+  FInError:=Assigned(E);
+  if Assigned(E) then
+    FLastError:=E.ClassName+': '+E.Message
+  else
+    FLastError:='';
+end;
+
+function TTestInsightHTTPClient.GetHasError: Boolean;
+begin
+  Result:=FInError;
+end;
+
+function TTestInsightHTTPClient.GetLastErrorMessage: string;
+begin
+  Result:=FLastError;
+end;
+
+{ TAbstractTestInsightClient }
+
+procedure TAbstractTestInsightClient.SetOptions(const value: TTestInsightOptions
+  );
+begin
+  FOptions.Assign(Value);
+end;
+
+
+function TAbstractTestInsightClient.JSONToTests(const aJSON: string): TStringDynArray;
+
+Var
+  D : TJSONData;
+  A : TJSONArray;
+  I : Integer;
+
+begin
+  Result:=[];
+  D:=GetJSON(aJSON);
+  try
+    if D=Nil then exit;
+    if D.JSONType=jtArray then
+      A:=D as TJSONArray
+    else if (D.Count=1) and (D.Items[0].JSONType=jtArray) then
+      A:=D.Items[0] as TJSONArray
+    else
+      A:=nil;
+    if A<>Nil then
+      begin
+      SetLength(Result,a.Count);
+      For I:=0 to Length(Result)-1 do
+        Result[i]:=A.Strings[i];
+      end;
+  finally
+    D.Free;
+  end;
+end;
+
+function TAbstractTestInsightClient.ConcatURL(aURL: String): String;
+begin
+  Result:=fBaseURL;
+  if (Result<>'') and (aURL<>'') and (Result[Length(Result)]<>'/') then
+    Result:=Result+'/';
+  Result:=Result+aURL;
+end;
+
+constructor TAbstractTestInsightClient.Create(const aBaseURL: String);
+begin
+  FBaseURL:=aBaseURL;
+  FOptions:=TTestInsightOptions.Create;
+  FResults:=TFPObjectList.Create(False);
+end;
+
+destructor TAbstractTestInsightClient.Destroy;
+begin
+  FResults.Clear;
+  FreeAndNil(FResults);
+  FreeAndNil(FOptions);
+  inherited Destroy;
+end;
+
+procedure TAbstractTestInsightClient.LoadConfig(const aConfigFileName: String; aSection : String = '');
+
+Var
+  aIni: TCustomIniFile;
+
+begin
+  aIni:=TMemIniFile.Create(aConfigFileName);
+  try
+    if aSection='' then
+      aSection:=SConfig;
+    LoadConfig(aIni,aSection);
+  finally
+    aIni.Free;
+  end;
+end;
+
+procedure TAbstractTestInsightClient.LoadConfig(aIni: TCustomIniFile;aSection : String);
+begin
+  FBaseURL:=aIni.ReadString(aSection,KeyBaseURL,BaseURL);
+  Options.ShowProgress:=aIni.ReadBool(aSection,keyShowProgress,Self.Options.ShowProgress);
+  Options.ExecuteTests:=aIni.ReadBool(aSection,keyExecuteTests,Self.Options.ExecuteTests);
+  Options.TestSuite:=aIni.ReadString(aSection,keySuite,Self.Options.TestSuite);
+end;
+
+procedure TAbstractTestInsightClient.GetServerOptions;
+
+begin
+  Options.FromJSON(ServerGet(pathOptions))
+end;
+
+procedure TAbstractTestInsightClient.PostResult(
+  const testResult: TTestInsightResult; forceSend: Boolean);
+begin
+  PostResults([testResult],forceSend);
+end;
+
+procedure TAbstractTestInsightClient.PostResults(
+  const testResults: array of TTestInsightResult; forceSend: Boolean);
+
+Var
+  Res : TTestInsightResult;
+  J : TJSONArray;
+  O : TJSONOBject;
+
+
+begin
+  if ForceSend or (Options.ShowProgress and Options.ExecuteTests) then
+    begin
+    J:=TJSONArray.Create;
+    try
+      For Res in testResults do
+        begin
+        O:=TJSONObject.Create;
+        J.Add(O);
+        Res.ToJSON(O);
+        Res.Free;
+        end;
+      ServerPost(pathResults,J.AsJSON);
+    finally
+      J.Free;
+    end;
+    end
+  else
+    For Res in TestResults do
+      FResults.Add(res);
+end;
+
+procedure TAbstractTestInsightClient.StartedTesting(const totalCount: Integer);
+begin
+  ServerPost(Format('%s?%s=%d', [pathStarted,qryTotalCount,Totalcount]),'');
+end;
+
+procedure TAbstractTestInsightClient.FinishedTesting;
+
+Var
+  A : Array of TTestInsightResult;
+  Len,I : Integer;
+
+begin
+  A:=[];
+  Len:=FResults.Count;
+  if (Len>0) then
+    begin
+    Setlength(A,Len);
+    For I:=0 to Len-1 do
+      A[I]:=TTestInsightResult(FResults[i]);
+    try
+      PostResults(A,True);
+    finally
+      FResults.Clear;
+    end;
+    end;
+  ServerPost(pathFinished,'');
+end;
+
+procedure TAbstractTestInsightClient.ClearTests;
+begin
+  ServerDelete(pathResults);
+end;
+
+procedure TAbstractTestInsightClient.SetTestNames(aJSON : TJSONObject);
+
+begin
+  ServerPost('',aJSON.AsJSON);
+end;
+
+function TAbstractTestInsightClient.GetTests: TStringDynArray;
+begin
+  Result:=JSONToTests(ServerGet(''));
+end;
+
+end.
+

+ 397 - 0
packages/testinsight/src/testinsightprotocol.pp

@@ -0,0 +1,397 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2023 by Michael Van Canneyt
+
+    Test Insight protocol description.
+    
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+unit testinsightprotocol;
+
+{$mode ObjFPC}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, fpJSON;
+
+Type
+  ETestInsight = class(Exception);
+
+  TTestResultType = (rtUnknown, rtPassed, rtFailed, rtError, rtWarning, rtSkipped, rtRunning);
+  TTestPhase = (tpSetUp, tpRunTest, spTearDown, tpNothing);
+
+
+  { TTestInsightResult }
+
+  TTestInsightResult = Class
+  Public
+    TestResult : TTestResultType;
+    TestName: string;
+    TestDuration: Cardinal;
+    TestUnitName: string;
+    TestClassName: string;
+    TestMethodName: string;
+    // Status
+    TestStatus: string;
+    TestExceptionMessage: string;
+    TestExceptionClass: string;
+    TestIsIgnored : Boolean;
+    TestPhase : TTestPhase;
+    // Failure info
+    FailureUnitName: string;
+    FailureMethodName: string;
+    FailureLineNumber: Integer;
+    FailureSourceUnitName : String; // Exception
+    FailureLocationInfo : String;
+    Constructor Create; virtual;
+    procedure FromJSON(const aJSON : TJSONStringType); overload;
+    procedure FromJSON(const aJSON : TJSONObject); overload;
+    function ToJSON: string;
+    Procedure ToJSON(aJSON : TJSONObject);
+  end;
+  TTestInsightResultClass = Class of TTestInsightResult;
+  TTestInsightResultArray = Array of TTestInsightResult;
+
+  { TTestInsightOptions }
+
+  TTestInsightOptions = Class (TPersistent)
+  Public
+    ExecuteTests: Boolean;
+    ShowProgress: Boolean;
+    TestSuite : String;
+    Constructor Create; virtual;
+    Procedure Assign(Source : TPersistent); override;
+    procedure FromJSON(const aJSON : TJSONStringType); overload;
+    procedure FromJSON(const aJSON : TJSONObject); overload;
+    function ToJSON: string;
+    Procedure ToJSON(aJSON : TJSONObject);
+  end;
+  TTestInsightOptionsClass = Class of TTestInsightOptions;
+
+Const
+  // Used in URLS
+  DefaultUrl = 'http://localhost:8081/tests';
+
+  pathTests = '/tests';
+  pathStarted = 'started';
+  pathFinished = 'finished';
+  pathResults = 'results';
+  pathOptions = 'options';
+  qryTotalCount = 'totalcount';
+
+  // Used in TTestInsightResult JSON encoding.
+  keyTestName = 'testname';
+  KeyTestUnitName = 'testunitname';
+  keyTestClassName = 'testclassname';
+  keyTestMethodName = 'testmethodname';
+  keyTestDuration = 'testduration';
+
+  keyResultType = 'testresulttype';
+  keyExceptionMessage = 'exceptionmessage';
+  KeyExceptionClass = 'exceptionclass';
+
+  keyfailureMethodName = 'failuremethodname';
+  keyFailureUnitName = 'failureunitname';
+  keyFailureLineNumber = 'failurelinenumber';
+  KeyFailureSourceUnitName = 'failuresourceunitname';
+  KeyFailureLocationInfo = 'location';
+
+  keyStatus = 'status';
+  keyIsIgnored = 'ignored';
+  KeyTestPhase = 'phase';
+
+  // Config file settings
+  SConfig = 'Config';
+  KeyBaseURL = 'BaseUrl';
+
+  // Used in TTestInsightOptions JSON encoding and Ini file
+  keyExecuteTests = 'ExecuteTests';
+  keyShowProgress = 'ShowProgress';
+  KeySuite = 'Suite';
+
+  ResultTypeNames: array[TTestResultType] of string
+    = ('?','Passed', 'Failed', 'Error', 'Warning', 'Skipped', 'Running');
+  TestPhaseNames: array[TTestPhase] of string
+    = ('Setup', 'Run', 'TearDown','');
+
+Function StringToResultType(aValue : string) : TTestResultType;
+Function ResultTypeToString(aValue : TTestResultType) : string;
+
+Function StringToTestPhase(aValue : string) : TTestPhase;
+Function TestPhaseToString(aValue : TTestPhase) : string;
+
+
+// Convert CR/LF separated list name to expected JSON format
+Function TestStringsToJSON(aContent : String): TJSONObject;
+
+
+implementation
+
+Function ResultTypeToString(aValue : TTestResultType) : string;
+begin
+  Result:=ResultTypeNames[aValue];
+end;
+
+function StringToTestPhase(aValue: string): TTestPhase;
+Var
+  T : TTestPhase;
+
+begin
+  Result:=tpNothing;
+  For T in TTestPhase do
+    if SameText(TestPhaseNames[T],aValue) then
+      Exit(T);
+end;
+
+function TestPhaseToString(aValue: TTestPhase): string;
+begin
+  Result:=TestPhaseNames[aValue];
+end;
+
+Function StringToResultType(aValue : string) : TTestResultType;
+
+Var
+  T : TTestResultType;
+
+begin
+  Result:=rtFailed;
+  For T in TTestResultType do
+    if SameText(ResultTypeNames[T],aValue) then
+      Exit(T);
+end;
+
+Procedure AddTests(aParent : TJSONObject; aList : TStrings; aPath : String; var aIdx : Integer);
+
+Var
+  P : integer;
+  Up : Boolean;
+  Curr,Sub : String;
+  Obj : TJSONObject;
+
+begin
+  Repeat
+    Curr:=aList[aIdx];
+    Up:=(aPath<>'') and Not SameText(Copy(Curr,1,Length(aPath)),aPath);
+    if not Up then
+      begin
+      Delete(Curr,1,Length(aPath));
+      if Curr[1]='.' then delete(Curr,1,1);
+      P:=Pos('.',Curr);
+      if P<>0 then
+        begin
+        Sub:=Copy(Curr,1,P-1);
+        Obj:=TJSONObject.Create;
+        aParent.Add(Sub,Obj);
+        if aPath<>'' then
+          AddTests(Obj,aList,APath+'.'+Sub,aIdx)
+        else
+          AddTests(Obj,aList,Sub,aIdx)
+        end
+      else
+        begin
+        aParent.Add(Curr,TJSONNull.Create);
+        Inc(aIdx);
+        end;
+      end
+  Until (aIdx>=aList.Count) or Up;
+end;
+
+
+Function TestStringsToJSON(aContent : String): TJSONObject;
+
+Var
+  L : TStringList;
+  aPath : String;
+  aIdx : Integer;
+
+begin
+  Result:=nil;
+  L:=TStringList.Create;
+  try
+    L.Text:=aContent;
+    L.Sort;
+    Result:=TJSONObject.Create;
+    aPath:='';
+    aIdx:=0;
+    AddTests(Result,L,aPath,aIdx);
+  finally
+    L.Free;
+  end;
+end;
+
+{ TTestInsightOptions }
+
+constructor TTestInsightOptions.Create;
+begin
+  ExecuteTests:=True;
+  ShowProgress:=True;
+end;
+
+procedure TTestInsightOptions.Assign(Source: TPersistent);
+
+var
+  Src : TTestInsightOptions absolute source;
+
+begin
+  if Source is TTestInsightOptions then
+    begin
+    ExecuteTests:=Src.ExecuteTests;
+    ShowProgress:=Src.ShowProgress;
+    TestSuite:=Src.TestSuite;
+    end
+  else
+    inherited Assign(Source);
+end;
+
+procedure TTestInsightOptions.FromJSON(const aJSON: TJSONStringType);
+Var
+  D : TJSONData;
+
+begin
+  D:=GetJSON(aJSON);
+  try
+    if D is TJSONObject then
+      FromJSON(D as TJSONObject);
+  finally
+    D.Free;
+  end;
+end;
+
+procedure TTestInsightOptions.FromJSON(const aJSON: TJSONObject);
+
+begin
+  with aJSON do
+    begin
+    ExecuteTests:=Get(KeyExecuteTests,ExecuteTests);
+    ShowProgress:=Get(KeyShowProgress,ShowProgress);
+    TestSuite:=Get(KeySuite,TestSuite);
+    end;
+end;
+
+function TTestInsightOptions.ToJSON: string;
+
+Var
+  Obj : TJSONObject;
+
+begin
+  Obj:=TJSONObject.Create;
+  try
+    ToJSON(Obj);
+    Result := Obj.AsJSON;
+  finally
+    Obj.Free;
+  end;
+end;
+
+procedure TTestInsightOptions.ToJSON(aJSON: TJSONObject);
+
+begin
+  with aJSON do
+    begin
+    Add(KeyExecuteTests,ExecuteTests);
+    Add(KeyShowProgress,ShowProgress);
+    Add(KeySuite,TestSuite);
+    end;
+end;
+
+
+{ TTestInsightResult }
+
+constructor TTestInsightResult.Create;
+begin
+  TestResult:=rtUnknown;
+end;
+
+procedure TTestInsightResult.FromJSON(const aJSON: TJSONStringType);
+
+Var
+  D : TJSONData;
+
+begin
+  D:=GetJSON(aJSON);
+  try
+    if D is TJSONObject then
+      FromJSON(D as TJSONObject);
+  finally
+    D.Free;
+  end;
+end;
+
+procedure TTestInsightResult.FromJSON(const aJSON: TJSONObject);
+
+begin
+  With aJSON Do
+    begin
+    TestName:=Get(KeyTestName, TestName);
+    TestUnitName:=Get(KeyTestUnitName, TestUnitName);
+    TestClassName:=Get(KeyTestClassName, TestClassName);
+    TestMethodName:=Get(KeyTestMethodName, TestMethodName);
+
+    TestResult:=StringToResultType(Get(KeyResultType, ResultTypeNames[TestResult]));
+    TestDuration:=Get(KeyTestDuration, TestDuration);
+    TestStatus:=Get(Keystatus, TestStatus);
+    TestIsIgnored:=Get(keyIsIgnored,TestIsIgnored);
+    TestPhase:=StringToTestPhase(Get(KeyTestPhase,TestPhaseToString(TestPhase)));
+
+    TestExceptionMessage:=Get(KeyExceptionMessage, TestExceptionMessage);
+    TestExceptionClass:=Get(KeyExceptionClass, TestExceptionClass);
+
+    FailureUnitName:=Get(KeyFailureUnitName, FailureUnitName);
+    FailureMethodName:=Get(KeyFailureMethodName, FailureMethodName);
+    FailureLineNumber:=Get(KeyFailureLineNumber, FailureLineNumber);
+    FailureSourceUnitName:=Get(KeyFailureSourceUnitName,FailureSourceUnitName);
+    FailureLocationInfo:=Get(KeyFailureLocationInfo,FailureLocationInfo);
+    end;
+end;
+
+function TTestInsightResult.ToJSON: string;
+
+Var
+  Obj : TJSONObject;
+
+begin
+  Obj:=TJSONObject.Create;
+  try
+    ToJSON(Obj);
+    Result := Obj.AsJSON;
+  finally
+    Free;
+  end;
+end;
+
+procedure TTestInsightResult.ToJSON(aJSON: TJSONObject);
+begin
+  With aJSON do
+    begin
+    Add(KeyTestname, TestName);
+    Add(KeyTestUnitName, TestUnitName);
+    Add(KeyTestClassName, TestClassName);
+    Add(KeyTestMethodName,TestMethodName);
+
+    Add(KeyResultType, ResultTypeNames[TestResult]);
+    Add(KeyTestDuration, TestDuration);
+    Add(KeyStatus, TestStatus);
+    Add(keyIsIgnored,TestIsIgnored);
+    Add(KeyTestPhase,TestPhaseToString(TestPhase));
+
+    Add(KeyExceptionMessage, TestExceptionMessage);
+    Add(KeyExceptionClass, TestExceptionClass);
+
+    Add(KeyFailureUnitName, FailureUnitName);
+    Add(KeyFailureMethodName, FailureMethodName);
+    Add(KeyFailureLinenumber, FailureLineNumber);
+    Add(KeyFailureSourceUnitName,FailureSourceUnitName);
+    Add(KeyFailureLocationInfo,FailureLocationInfo);
+    end;
+end;
+
+
+end.
+